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 · CANCELEDGET /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=5f8a2c4e6b0d9f13a7c5e8b2d4f6a0c1e3b5d7f9a1c3e5b7d9f1a3c5e7b9d1f3The 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
- Enter your sending domain (or a single address for instant sandbox testing).
- Install the three DKIM CNAME records shown in the dashboard (optional fourth CNAME for a custom Return-Path).
- Click "Check verification" — status flips to
VERIFIEDonce 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:
- Register your business as a Principal Entity on any DLT portal (one-time, uses your PAN/GST) and paste your PE ID.
- Register your sender header and paste it in.
- 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.
- Have a Meta Business Manager and complete Meta business verification with your legal documents — the longest lead item, so start it first.
- Provide a phone number that can receive an OTP and is not active on consumer WhatsApp.
- Choose your display name (Meta approves it).
- 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.