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);
}