Before you start
Accounts are set up with you during onboarding. For the DKIM record and the rest of your sending setup, see transactional email deliverability.
Send email from Node.js, step by step
The example sends an order confirmation and attaches the order ID as metadata, so every delivery event can be matched back to the order.
1. Store your API key
Keep the key out of your code. Set it as an environment variable on the server that calls tinyRelay.
export TINYRELAY_API_KEY="your-api-key"$env:TINYRELAY_API_KEY = "your-api-key"2. Build the request body
Build a plain object. It is serialized to JSON on the way out. attributes fill merge tags in the body; metadata comes back on every webhook.
const email = {
subject: "Order SO-1001 is confirmed",
body: "<p>Hi [FIRST_NAME], thanks for your order. We will email you again when it ships.</p>",
sender: {
from: { name: "Acme Store", email: "orders@acme.com" },
replyTo: { name: "Acme Support", email: "support@acme.com" },
},
recipients: {
to: [
{ name: "Ann Lee", email: "ann@example.com", attributes: { FIRST_NAME: "Ann" } },
],
},
campaignId: "order_confirmed_SO-1001",
metadata: { order_id: "SO-1001", tenant_id: "acme" },
};3. POST it to /email/campaign
Wrap the call in a small sendEmail function and treat anything other than 202 as an error. The three tabs do the same thing.
// tinyrelay.mjs
const API_URL = "https://api.tinyemail.com/v1/relay/email/campaign";
export async function sendEmail(email) {
const res = await fetch(API_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-RELAY-ACCESS-TOKEN": process.env.TINYRELAY_API_KEY,
},
body: JSON.stringify(email),
});
if (res.status !== 202) {
throw new Error(`tinyRelay ${res.status}: ${await res.text()}`);
}
const { requestId } = await res.json();
return requestId;
}// tinyrelay.mjs (npm install axios)
import axios from "axios";
const tinyRelay = axios.create({
baseURL: "https://api.tinyemail.com/v1/relay",
timeout: 10000,
headers: { "X-RELAY-ACCESS-TOKEN": process.env.TINYRELAY_API_KEY },
});
export async function sendEmail(email) {
try {
const { data } = await tinyRelay.post("/email/campaign", email);
return data.requestId;
} catch (err) {
// axios rejects any non-2xx status
if (err.response) {
throw new Error(`tinyRelay ${err.response.status}: ${JSON.stringify(err.response.data)}`);
}
throw err;
}
}// tinyrelay.mjs (no dependencies)
import https from "node:https";
const API_URL = "https://api.tinyemail.com/v1/relay/email/campaign";
export function sendEmail(email) {
const payload = JSON.stringify(email);
return new Promise((resolve, reject) => {
const req = https.request(API_URL, {
method: "POST",
timeout: 10000,
headers: {
"Content-Type": "application/json",
"Content-Length": Buffer.byteLength(payload),
"X-RELAY-ACCESS-TOKEN": process.env.TINYRELAY_API_KEY,
},
}, (res) => {
let body = "";
res.setEncoding("utf8");
res.on("data", (chunk) => (body += chunk));
res.on("end", () => {
if (res.statusCode === 202) resolve(JSON.parse(body).requestId);
else reject(new Error(`tinyRelay ${res.statusCode}: ${body}`));
});
});
req.on("timeout", () => req.destroy(new Error("tinyRelay request timed out")));
req.on("error", reject);
req.end(payload);
});
}Base URL from current site samples; confirm in the docs.
Which HTTP client should you use?
Built into current Node.js. No dependency, promise-based, and you check res.status yourself.
A popular npm package. Serializes the body to JSON for you and rejects on any non-2xx status.
The lowest level. No dependency, but you collect the response chunks and handle timeouts.
4. Check for a 202
sendEmail resolves with the requestId when tinyRelay answers 202. Save it on the order record. It is also on every webhook for this send, next to your order_id.
The 202 response
A request that passes validation returns 202. That means tinyRelay accepted the send; what happens to each email afterwards arrives as webhook events.
{
"requestId": "833c5151-5a98-4bad-a30f-7cc816352b7a",
"message": "SUCCESS"
}requestIdidentifies this send. Store it next to your own record.- If you did not set
campaignId, therequestIdis used as the campaign ID, and every webhook event for the send carries it. - A
202is not delivery. Watch forDELIVEREDorBOUNCEDevents.
Send to many recipients from Node.js
Map your records straight into recipients.to. Each customer gets their own merge attributes and their own order_id in metadata, merged over the request-level tenant_id and app.
const requestId = await sendEmail({
subject: "Your order is confirmed",
body: "<p>Hi [FIRST_NAME], order [ORDER_ID] is confirmed.</p>",
sender: { from: { name: "Acme Store", email: "orders@acme.com" } },
metadata: { tenant_id: "acme", app: "checkout" },
recipients: {
to: orders.map((o) => ({
name: o.customerName,
email: o.customerEmail,
attributes: { FIRST_NAME: o.firstName, ORDER_ID: o.id },
metadata: { order_id: o.id },
})),
},
});The rules, from the Send Email docs:
Request-level and recipient-level maps merge. The cap is on the merged map.
At most 500 recipients in one request may carry their own metadata.
Each metadata key can be up to 64 characters.
Values up to 256 characters: flat strings, numbers or booleans.
attributes are merge tags rendered into the body, such as [FIRST_NAME], and never echoed on webhooks. metadata is never rendered into the email and is always echoed on webhooks.
"attributes": { "FIRST_NAME": "Ann" } // in the email: "Hi Ann"
"metadata": { "user_id": "u_1042" } // on every webhook eventRequest-level and recipient-level maps merge, and the recipient's keys win.
// request { "tenant_id": "acme", "plan": "pro" }
// recipient { "plan": "team" }
// merged { "tenant_id": "acme", "plan": "team" }Values must be flat strings, numbers or booleans.
{ "order_id": "SO-1", "total": 42, "gift": true } // flat: fine
{ "order": { "id": "SO-1" } } // nested object: not flatMetadata is scoped to campaignId. Reusing a campaignId across batches replaces the earlier metadata, so use a unique one per batch or leave it out.
"campaignId": "password_reset_batch_0142" // unique per batch
// or leave it out: the requestId is used as the campaign IDHandle delivery webhooks
tinyRelay posts an event to your webhook endpoint for each thing that happens to an email. There are seven event types:
- SENDSent to the receiving server
- DELIVEREDEmail delivered
- BOUNCEDHARD_BOUNCED or SOFT_BOUNCED · adds bounceCategory
- OPENOpened by the recipient
- CLICKA tracked link was clicked · adds url
- UNSUBSCRIBEUnsubscribe link clicked
- SPAMMarked as spam
SEND: the email was sent. Apps usually mark the message as sent against their record.
Each event carries eventType, receiver, requestId, campaignId and your merged metadata. BOUNCED events add bounceCategory, and CLICK events add the url that was clicked.
svix-id, svix-timestamp and svix-signature headers and delivered at least once, so verify each request and deduplicate on svix-id.Read email webhooks and delivery events, then verify webhook signatures. Bounce categories are explained in the email bounce codes reference; field-level detail is in the webhook docs.
Troubleshooting
The Send Email endpoint documents four error statuses. Match the status you get to the row below.
| Status | Meaning | What to check |
|---|---|---|
400 | MeaningBad Request | What to checkThe body failed validation. The response carries a message field; read it, fix the field it names and resend. |
401 | MeaningAPI key missing or invalid | What to checkCheck the header is named X-RELAY-ACCESS-TOKEN and holds the key issued at onboarding. If the key is right, confirm the server you are calling from has an IP on your allowlist. |
405 | MeaningMethod Not Allowed | What to checkSend a POST to /email/campaign. Other methods are refused. |
412 | MeaningMetadata limits broken | What to checkThe merged metadata map has more than 10 keys, or more than 500 recipients carry their own metadata. Nothing is sent; trim the metadata and resend. |
Full error schemas are in the Send Email API reference.
FAQ
No. tinyRelay has no package to install. Use the built-in fetch, the node:https module or a client such as axios to make one HTTPS POST.
No. The API key must stay on your server, and tinyRelay only accepts calls from allowlisted IP addresses, so send from your Node.js backend.
Put your order ID in metadata. It is echoed on every webhook event for that send, merged with any request-level metadata, with the recipient's keys winning.
A 202 status with a requestId in the body. Treat any other status as an error and read the response body for the reason.