TypeScript Examples
There is no official SDK. These examples use the built-in fetch and node:crypto of Node.js 18 or later, with no other dependencies. The snippets use top-level await, so run them as an ES module, for example by saving them as example.mts and running npx tsx example.mts. Put the setup block first; every later snippet uses its api helper.
Setup
The helper unwraps the response envelope, throws RouteonApiError on any non-2xx answer, and retries only when the request-rate limiter answered 429 with meta.details.code = "rate_limited", waiting for that response’s Retry-After.
import { randomUUID } from "node:crypto";
const BASE_URL = "https://emails.routeon.io/api/v1";
const API_KEY = "<API_KEY>"; // load it from your secret store; never commit it
interface FieldError {
field: string;
message: string;
}
interface Meta {
reason?: string;
validation_error?: FieldError[];
details?: Record<string, unknown> | null;
per_page?: number;
cursor?: string | null;
[key: string]: unknown;
}
interface ApiResult<T> {
status: number;
payload: T;
meta: Meta;
replayed: boolean; // true when the response came from the idempotency cache
}
class RouteonApiError extends Error {
constructor(
readonly status: number,
readonly reason: string,
readonly code: string | undefined,
readonly validationErrors: FieldError[],
readonly details: Record<string, unknown>,
readonly retryAfterSeconds: number | undefined,
) {
super(`Routeon API ${status} ${reason}${code ? ` (${code})` : ""}`);
this.name = "RouteonApiError";
}
}
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
async function api<T>(
method: string,
path: string,
options: { body?: unknown; idempotencyKey?: string; maxAttempts?: number } = {},
): Promise<ApiResult<T>> {
const maxAttempts = options.maxAttempts ?? 5;
for (let attempt = 1; ; attempt++) {
const headers: Record<string, string> = { Authorization: `Bearer ${API_KEY}` };
if (options.body !== undefined) headers["Content-Type"] = "application/json";
if (options.idempotencyKey) headers["Idempotency-Key"] = options.idempotencyKey;
const res = await fetch(`${BASE_URL}${path}`, {
method,
headers,
body: options.body === undefined ? undefined : JSON.stringify(options.body),
});
const text = await res.text();
let envelope: { payload?: unknown; meta?: Meta } = {};
try {
envelope = text ? JSON.parse(text) : {};
} catch {
envelope = {};
}
if (res.ok) {
return {
status: res.status,
payload: (envelope.payload ?? null) as T,
meta: envelope.meta ?? {},
replayed: res.headers.get("Idempotency-Replayed") === "true",
};
}
const meta = envelope.meta ?? {};
const details = (meta.details ?? {}) as Record<string, unknown>;
const code = typeof details.code === "string" ? details.code : undefined;
const retryAfterHeader = res.headers.get("Retry-After");
const retryAfter = retryAfterHeader ? Number(retryAfterHeader) : undefined;
// Only the request-rate limiter clears in seconds. Daily caps also answer 429
// (otp_recipient_daily_cap, transactional_daily_limit_exceeded) and are not retried.
if (res.status === 429 && code === "rate_limited" && attempt < maxAttempts) {
await sleep((retryAfter ?? 1) * 1000);
continue; // same Idempotency-Key: a rate-limited request never ran
}
throw new RouteonApiError(
res.status,
meta.reason ?? `HTTP_${res.status}`,
code,
meta.validation_error ?? [],
details,
retryAfter,
);
}
}
Check your API key
interface Me {
tenant_id: string | null;
scopes: string[];
auth_type: "jwt" | "api_key";
}
const me = await api<Me>("GET", "/me");
console.log(me.payload.auth_type, me.payload.scopes);
A call outside the key’s scopes fails with 403 FORBIDDEN and no code.
Send a transactional email
The template must be active and of class transactional. Derive the idempotency key from your own business event so a retry after a timeout cannot send twice.
interface TransactionalSendResponse {
message_id: string;
send_at: string | null;
}
const sent = await api<TransactionalSendResponse>("POST", "/transactional/send", {
idempotencyKey: "order-1042-confirmation",
body: {
domain_id: "<DOMAIN_ID>",
to: "jane.doe@example.com",
template_id: "<TRANSACTIONAL_TEMPLATE_ID>",
variables: { first_name: "Jane", order_number: "1042" },
tags: ["order-confirmation", "shop:eu"],
metadata: { order_id: "1042", customer_tier: "gold" },
},
});
console.log(sent.payload.message_id);
To schedule it, add send_at: "<FUTURE_ISO8601_TIMESTAMP>" (explicit UTC offset, at most 30 days ahead). Cancel a scheduled message that has not been picked up yet:
await api<{ message_id: string; status: "canceled" }>("DELETE", `/transactional/${sent.payload.message_id}`);
Send a batch of transactional emails
type BatchItemResult =
| { index: number; status: "queued"; message_id: string }
| { index: number; status: "failed"; code: string; errors?: FieldError[] };
const recipients = [
{ to: "jane.doe@example.com", variables: { first_name: "Jane" } },
{ to: "john.smith@example.com", variables: { first_name: "John" } },
];
const batch = await api<{ results: BatchItemResult[] }>("POST", "/transactional/send-batch", {
idempotencyKey: `shipping-notices-${randomUUID()}`,
body: {
domain_id: "<DOMAIN_ID>",
template_id: "<TRANSACTIONAL_TEMPLATE_ID>",
tags: ["shipping-notice"],
items: recipients,
},
});
// Results are matched by index. Resend failed recipients under a NEW key;
// replaying the old key returns the same failures.
const failed = batch.payload.results.filter((r) => r.status === "failed");
for (const r of failed) console.log(recipients[r.index].to, r.status === "failed" ? r.code : "");
Look up messages
type MessageStatus =
| "queued" | "sending" | "sent" | "delivered" | "bounced"
| "complained" | "failed" | "suppressed" | "deferred" | "canceled";
interface Message {
id: string;
campaign_id: string | null;
to_email: string;
status: MessageStatus;
error_message: string | null;
send_at: string | null;
template_id: string | null; // null when the message carries its own content instead of a template
source_type: "blast" | "automation" | "transactional" | "otp" | null;
tags: string[];
metadata: Record<string, string>;
created_at: string;
}
interface MessageWithEvents extends Message {
events: { type: "delivery" | "open" | "click" | "bounce" | "complaint" | "reject" | "deferral"; at: string }[];
}
const message = await api<MessageWithEvents>("GET", `/messages/${sent.payload.message_id}`);
console.log(message.payload.status, message.payload.events.map((e) => e.type));
const tagged = await api<Message[]>("GET", "/messages?tag=order-confirmation&per_page=50");
console.log(tagged.payload.length, "messages tagged order-confirmation");
Request and verify an OTP code
OTP needs a template of class critical.
const otp = await api<{ otp_id: string; expires_at: string }>("POST", "/otp/request", {
idempotencyKey: `otp-${randomUUID()}`,
body: {
domain_id: "<DOMAIN_ID>",
to: "jane.doe@example.com",
template_id: "<OTP_TEMPLATE_ID>",
purpose: "login",
code_length: 6,
ttl_seconds: 300,
},
});
async function verifyOtp(otpId: string, code: string): Promise<"ok" | "wrong" | "request_new_code"> {
try {
const r = await api<{ verified: boolean; attempts_remaining: number }>("POST", "/otp/verify", {
body: { otp_id: otpId, code },
});
return r.payload.verified ? "ok" : "wrong";
} catch (err) {
// 410: otp_not_found | otp_expired | otp_already_used | otp_attempts_exceeded
if (err instanceof RouteonApiError && err.status === 410) return "request_new_code";
throw err;
}
}
console.log(await verifyOtp(otp.payload.otp_id, "<CODE_FROM_USER>"));
Create and send a campaign
The template must be active and of class marketing. sender_profile_id is optional; without it the sender profile routed for campaigns, or your default one, is used.
interface Campaign {
id: string;
name: string;
type: "blast" | "automation" | "transactional";
status: "draft" | "scheduled" | "sending" | "paused" | "sent" | "canceled";
sender_profile_id: string | null;
}
const campaign = await api<Campaign>("POST", "/campaigns", {
idempotencyKey: "campaign-create-product-update",
body: {
name: "Product update",
type: "blast",
domain_id: "<DOMAIN_ID>",
template_id: "<MARKETING_TEMPLATE_ID>",
sender_profile_id: "<SENDER_PROFILE_ID>",
audience: { include: [{ kind: "list", id: "<LIST_ID>" }] },
},
});
async function launch(campaignId: string): Promise<Campaign> {
try {
return (await api<Campaign>("POST", `/campaigns/${campaignId}/send`)).payload;
} catch (err) {
// 409 without a code: some recipients would get more than one email that day.
if (err instanceof RouteonApiError && err.status === 409 && err.code === undefined) {
console.log("conflicts:", err.details.conflict_count);
const r = await api<Campaign>("POST", `/campaigns/${campaignId}/send`, {
body: { conflict_resolution: "exclude_conflicts" },
});
return r.payload;
}
throw err; // 409 with a code: invalid_state, campaign_needs_content_review, ...
}
}
console.log((await launch(campaign.payload.id)).status); // "sending"
Create a webhook and verify its signature
const webhook = await api<{ id: string; status: string; secret: string }>("POST", "/webhooks", {
body: {
url: "https://hooks.example.com/routeon",
events: ["email.delivered", "email.bounced", "email.complained", "contact.unsubscribed"],
description: "Delivery status sync",
},
});
// webhook.payload.secret is returned only here. Store it in your secret store.
Each delivery is a POST with X-Timestamp (Unix seconds) and X-Signature, the hex HMAC-SHA256 of `${timestamp}.${rawBody}` keyed with the secret. Verify the raw bytes before parsing them. A minimal receiver, as a separate program:
import { createServer } from "node:http";
import { createHmac, timingSafeEqual } from "node:crypto";
const WEBHOOK_SECRET = "<WEBHOOK_SECRET>";
const TOLERANCE_SECONDS = 300;
function verifySignature(rawBody: Buffer, signature?: string, timestamp?: string): boolean {
if (!signature || !timestamp || !/^d+$/.test(timestamp)) return false;
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > TOLERANCE_SECONDS) return false;
const expected = createHmac("sha256", WEBHOOK_SECRET).update(`${timestamp}.`).update(rawBody).digest();
const received = Buffer.from(signature, "hex");
return received.length === expected.length && timingSafeEqual(received, expected);
}
createServer((req, res) => {
const chunks: Buffer[] = [];
req.on("data", (chunk: Buffer) => chunks.push(chunk));
req.on("end", () => {
const rawBody = Buffer.concat(chunks);
const ok = verifySignature(
rawBody,
req.headers["x-signature"] as string | undefined,
req.headers["x-timestamp"] as string | undefined,
);
if (!ok) {
res.writeHead(401).end();
return;
}
const event = JSON.parse(rawBody.toString("utf8")) as {
id: string;
type: string;
occurred_at: string;
data: Record<string, unknown>;
};
// A delivery that timed out is retried, so deduplicate on event.id.
console.log(event.type, event.id);
res.writeHead(204).end(); // answer 2xx within 10 seconds
});
}).listen(8080);
See Webhooks for the data of each event type.
Handle errors
The api helper already waits out rate_limited. Everything else reaches your code as a RouteonApiError. Branch on code (from meta.details.code) first, then on reason, and read validationErrors for VALIDATION_ERROR: some codes, such as send_at_too_far or template_not_active, arrive only as a validationErrors[].message.
try {
await api("POST", "/otp/request", {
idempotencyKey: `otp-${randomUUID()}`,
body: { domain_id: "<DOMAIN_ID>", to: "jane.doe@example.com", template_id: "<OTP_TEMPLATE_ID>", purpose: "login" },
});
} catch (err) {
if (!(err instanceof RouteonApiError)) throw err;
switch (err.code) {
case "otp_resend_cooldown":
console.log(`resend allowed in ${err.details.retry_after_seconds} s`);
break;
case "otp_recipient_daily_cap":
case "transactional_daily_limit_exceeded":
console.log("daily cap reached; do not retry today");
break;
case "rate_limited":
console.log("still rate limited after all retries; slow down");
break;
default:
if (err.reason === "VALIDATION_ERROR") {
for (const e of err.validationErrors) console.log(`${e.field || "(request)"}: ${e.message}`);
} else {
console.log(err.status, err.reason, err.code);
}
}
}
See Errors for every code and where it appears.
Page through a list
Cursor-paginated lists return meta.cursor while more pages exist. It is absent on the last page.
async function* listAll<T>(path: string): AsyncGenerator<T> {
let cursor: string | undefined;
do {
const sep = path.includes("?") ? "&" : "?";
const page = await api<T[]>("GET", cursor ? `${path}${sep}cursor=${encodeURIComponent(cursor)}` : path);
yield* page.payload;
cursor = typeof page.meta.cursor === "string" && page.meta.cursor ? page.meta.cursor : undefined;
} while (cursor);
}
for await (const m of listAll<Message>("/messages?tag=order-confirmation&per_page=200")) {
console.log(m.id, m.status);
}