Posthorn docs

Everything below reflects the frozen /v1 contract. Authenticate every request with X-API-Key: ph_live_…. Additive changes stay in /v1; ignore fields and enum values you don't recognize.

Quickstart

One template, one channel, one recipient, one mandatory idempotency key. That is the whole integration:

import { Posthorn } from "@posthorn/sdk";

const posthorn = new Posthorn({ apiKey: process.env.POSTHORN_API_KEY! });

const msg = await posthorn.messages.send({
  template: "team-invitation",
  channel: "email",
  to: { email: "alice@example.com" },
  idempotency_key: `invite-${invitationId}`,
  variables: { inviter_name: "Priya", team_name: "Orion", accept_url: acceptUrl },
});

// msg.id polls the full timeline:
const latest = await posthorn.messages.get(msg.id);

The same request over plain HTTP:

curl https://api.posthorn.in/v1/messages \
  -H "X-API-Key: $POSTHORN_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "template": "team-invitation",
    "channel": "email",
    "to": { "email": "alice@example.com" },
    "idempotency_key": "invite-9f2c",
    "variables": {
      "inviter_name": "Priya",
      "team_name": "Orion",
      "accept_url": "https://app.example.com/accept/9f2c"
    }
  }'

A fresh send answers 202 with the message object; a replay of the same idempotency key answers 200 with the original. Errors share one envelope everywhere — branch on error.code, never on the human-readable message:

{
  "error": {
    "code": "missing_variables",
    "message": "Template 'team-invitation' (email rendition) requires variables that are missing: team_name, accept_url.",
    "details": { "missing_variables": ["team_name", "accept_url"] },
    "request_id": "req_01J0WY0CCC4R6ZX4M8Q2KTVH5N"
  }
}

Send API essentials

Idempotency

idempotency_key is a required body field, unique per workspace and enforced by the database — not a best-effort cache. Retrying a timed-out request with the same key and an equivalent payload returns 200 with the original message; the same key with a different payload is refused with 409 idempotency_conflict and details.original_message_id. Keys are kept for the life of the message, so a retry is safe at any distance. The 202-versus-200 split is itself the replay indicator.

Channels and recipients

Requests accept channel: "email" | "sms" | "whatsapp". The recipient is exactly one of to.email or to.phone (E.164, e.g. +919876543210). variables is a flat map of strings, numbers, and booleans — pre-format dates and currency on your side. Response enums are open: new statuses and channels may appear within /v1, so deserialize them as strings.

Scheduling

Pass a future send_at (ISO 8601) and the message is accepted as SCHEDULED. Cancel it any time before promotion with POST /v1/messages/{id}/cancel; it lands in the terminal CANCELED state. Canceling an already-canceled message returns 200 unchanged, so a timed-out cancel can be blindly retried; once the message is QUEUED or later the cancel answers 409 message_not_cancelable. The template version is pinned when the message is accepted — scheduled messages render the version that was current at accept time.

Delivery tracking

Statuses

ACCEPTED → (SCHEDULED) → QUEUED → SENDING → SENT → DELIVERED
terminal: BOUNCED · COMPLAINED · FAILED · SUPPRESSED · PARKED · CANCELED

GET /v1/messages/{id} returns the message with its full event timeline — accepted, each delivery attempt, provider errors, final state. Polling this endpoint is the source of truth; webhooks only accelerate it. Sends to suppressed recipients are accepted (202) and then marked SUPPRESSED — never an error. A message that cannot proceed (unverified identity, unapproved template, disabled channel) is PARKED with a prose status_reason and a machine-readable status_reason_code.

Webhooks

Eight event types: message.sent, message.delivered, message.bounced, message.complained, message.failed, message.suppressed, message.parked, message.canceled — one envelope for all of them. The envelope id is stable across redeliveries, so use it as your dedupe key:

{
  "id": "evt_01J0WY0D3F8K2M5P9QRSTV6W7X",
  "type": "message.delivered",
  "created_at": "2026-07-12T09:14:03Z",
  "data": {
    "message": { "id": "msg_01J0WY0CCC4R6ZX4M8Q2KTVH5N", "status": "DELIVERED", "...": "..." }
  }
}

Respond with any 2xx within 10 seconds; otherwise the delivery is retried with exponential backoff — 8 attempts over roughly 24 hours. A dead endpoint never blocks the pipeline: polling stays truthful.

Verifying Posthorn-Signature

Every delivery is signed with your endpoint's secret (shown once at configuration):

Posthorn-Signature: t=1752310443,v1=5f8a2c4e6b0d9f13a7c5e8b2d4f6a0c1e3b5d7f9a1c3e5b7d9f1a3c5e7b9d1f3

The signature is hex(HMAC-SHA256(secret, "{t}." + raw_body)) where the key is the entire secret string including the whsec_ prefix, as UTF-8 bytes. Verify over the raw request bytes, compare in constant time, and reject timestamps older than 5 minutes. With the SDK it is one line:

posthorn.webhooks.verify(rawBody, req.headers["posthorn-signature"], process.env.POSTHORN_WEBHOOK_SECRET!);

Or by hand:

import { createHmac, timingSafeEqual } from "node:crypto";

export function verifyWebhook(
  rawBody: Buffer, // the raw request bytes — never a re-serialized parse
  signatureHeader: string, // the Posthorn-Signature header value
  secret: string, // the full "whsec_…" string, exactly as issued
): boolean {
  const parts = new Map(
    signatureHeader.split(",").map((p) => p.split("=") as [string, string]),
  );
  const t = parts.get("t");
  const v1 = parts.get("v1");
  if (!t || !v1) return false;
  if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false; // replay window
  const expected = createHmac("sha256", secret)
    .update(`${t}.`)
    .update(rawBody)
    .digest("hex");
  const a = Buffer.from(v1, "hex");
  const b = Buffer.from(expected, "hex");
  return a.length === b.length && timingSafeEqual(a, b);
}

Channel onboarding

Posthorn owns every provider account and integration — the pipes. You complete only the identity and compliance steps that are legally yours: regulators and spam filters attribute messages to your brand, not to the platform. Per-channel readiness is visible in the dashboard and via GET /v1/channels.

Email — about 10 minutes plus DNS propagation

  1. Enter your sending domain (or a single address for instant sandbox testing).
  2. Install the three DKIM CNAME records shown in the dashboard (optional fourth CNAME for a custom Return-Path).
  3. Click "Check verification" — status flips to VERIFIED once the records propagate.

SMS

International: pick a sender ID or ask for a provisioned number — done. Sending to India additionally requires TRAI DLT registration, which is legally bound to your business:

  1. Register your business as a Principal Entity on any DLT portal (one-time, uses your PAN/GST) and paste your PE ID.
  2. Register your sender header and paste it in.
  3. Per template: Posthorn generates the exact DLT-format text — register it on the portal and paste back the template ID.

The compliance tracker shows per-template DLT state. Sends on an unregistered template park with the reason — they never silently drop.

WhatsApp

  1. Have a Meta Business Manager and complete Meta business verification with your legal documents — the longest lead item, so start it first.
  2. Provide a phone number that can receive an OTP and is not active on consumer WhatsApp.
  3. Choose your display name (Meta approves it).
  4. Templates: Posthorn submits them for approval on your behalf; watch their status in the compliance tracker.

Questions? support@posthorn.in — or sign in and follow the checklists in the dashboard.