Webhooks
A webhook is an HTTPS URL that receives a signed POST for every message delivered to your inbox, replies included. The body is the same envelope the API returns.
Create one
In the dashboard under Webhooks, or with the API. URLs must be HTTPS and resolve to a public host; private and loopback addresses are rejected with invalid_webhook_url. The signing secret (whsec_…) is returned once. Store it.
curl https://api.carte.sh/v1/webhooks \
-H "Authorization: Bearer $CARTE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url":"https://example.com/carte"}'{
"id": "wh_01J8ZK3Q9V7N4X2M6P8R5T1W3Y",
"url": "https://example.com/carte",
"active": true,
"target": null,
"created_at": "2026-09-14T10:00:00.000Z",
"secret": "whsec_…"
}Only one segment
Pass target to hear only messages addressed to one member or agent of your business (acme/finance-agent, or acme+finance-agent@in.carte.sh). Messages sent to the bare handle, or to another segment, skip that webhook. A webhook without a target receives everything, targeted messages included.
curl https://api.carte.sh/v1/webhooks \
-H "Authorization: Bearer $CARTE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url":"https://example.com/finance","target":"finance-agent"}'Payload and headers
The body is the message envelope (see Concepts), including json and short-lived file download_urls. from.party is the business or domain that sent it and from.actor the person, agent or mailbox that did the sending.
{
"id": "msg_01J8ZK3Q9V7N4X2M6P8R5T1W3Y",
"thread_id": "thr_01J8ZK3Q9V7N4X2M6P8R5T1W3Y",
"in_reply_to": null,
"from": {
"party": { "kind": "handle", "handle": "northwind", "display_name": "Northwind Traders" },
"actor": { "kind": "agent", "id": "agt_…", "name": "invoice-bot", "email": null, "verified": true, "provider": "api_key" }
},
"to": { "party": { "kind": "handle", "handle": "acme", "display_name": "Acme Corp" } },
"type": "invoice",
"subject": "Invoice #1042",
"note": null,
"json": { "total": 1240.5, "currency": "USD" },
"json_sha256": "sha256:…",
"files": [ { "id": "file_…", "filename": "invoice-1042.pdf", "download_url": "https://storage.example/…", … } ],
"validation": { "status": "not_applicable", "errors": [] },
"received_at": "2026-09-14T10:00:00.000Z",
"content_hash": "sha256:…",
"receipt_url": "https://carte.sh/r/rcpt_…"
}Replies to your messages arrive the same way, as message.received with in_reply_to set to the message they answer and thread_id shared with it. There is no separate reply event: filter on in_reply_to.
POST /carte HTTP/1.1
Content-Type: application/json
User-Agent: carte-webhooks/1.0
Carte-Event: message.received
Carte-Delivery-Id: dlv_01J8ZK3Q9V7N4X2M6P8R5T1W3Y
Carte-Signature: t=1757844000,v1=5f1a…e3c9Carte-Event:message.receivedfor real messages,message.testfor test events.Carte-Delivery-Id: unique per attempt. Use it to de-duplicate.Carte-Signature:t=<unix seconds>,v1=<hex>wherev1is HMAC-SHA256 of`${t}.${rawBody}`with your secret.
Verify the signature
Always verify against the raw request body, before parsing. Reject timestamps more than 5 minutes old to stop replays.
import { createHmac, timingSafeEqual } from "node:crypto"
export function verify(header: string, body: string, secret: string) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")))
const t = Number(parts.t)
if (Math.abs(Date.now() / 1000 - t) > 300) return false
const expected = createHmac("sha256", secret)
.update(`${t}.${body}`)
.digest("hex")
return timingSafeEqual(
Buffer.from(expected, "hex"),
Buffer.from(parts.v1, "hex")
)
}Or use the SDK, which works in Node, Bun and edge runtimes with WebCrypto and a constant-time compare.
import { verifyWebhookSignature } from "@carte/sdk"
export async function POST(req: Request) {
const body = await req.text()
const ok = await verifyWebhookSignature({
header: req.headers.get("carte-signature") ?? "",
body,
secret: process.env.CARTE_WEBHOOK_SECRET!,
})
if (!ok) return new Response("invalid signature", { status: 400 })
const envelope = JSON.parse(body)
if (envelope.in_reply_to) {
// a reply in an existing thread (envelope.thread_id)
}
return new Response(null, { status: 204 })
}Delivery and retries
Carte waits 10 seconds for a response and does not follow redirects. Any 2xx is success. Anything else is retried up to 6 attempts in total:
| Attempt | When |
|---|---|
| 1 | immediately |
| 2 | +1 minute |
| 3 | +5 minutes |
| 4 | +30 minutes |
| 5 | +2 hours |
| 6 | +12 hours |
After the last failure the delivery is dropped; the message stays in your inbox, so poll GET /v1/inbox with since to catch up. Respond quickly and do the work asynchronously.
Testing
The Send test event button in the dashboard (or POST /v1/webhooks/{id}/test) delivers a message.test envelope once, without retries. The delivery log shows every attempt with status code, response time and error.
curl -X POST https://api.carte.sh/v1/webhooks/wh_…/test \
-H "Authorization: Bearer $CARTE_API_KEY"
# { "queued": true, "job_id": "job_…" }
curl https://api.carte.sh/v1/webhooks/wh_…/deliveries \
-H "Authorization: Bearer $CARTE_API_KEY"