Webhooks
Signed, retried delivery of every customer-facing event. One envelope shape, a tyxter-webhook-signature header, and idempotent event ids so replays never double-process.
Register an endpoint
For production and shared staging, register an HTTPS URL that Tyxter can reach. For local sandbox work, use the listener below instead of opening a public tunnel.
curl https://api.tyxter.com/v1/webhook-endpoints \
-H "authorization: Bearer $TYXTER_API_KEY" \
-H "content-type: application/json" \
-d '{
"url": "https://example.com/tyxter-webhook",
"description": "Production receiver",
"subscribed_events": [
"message.sent",
"message.delivered",
"message.read",
"message.failed",
"message.received"
]
}'Listen locally
The local listener polls GET /v1/webhook-events/listen with a sandbox API key and forwards each event to your localhost receiver with the normal Tyxter signature headers. You do not need to register a normal dashboard webhook endpoint for this local sandbox flow. For inbound tests, filter for message.received; message.created is not emitted.
For container-free signature checks, create a webhook endpoint, then pass its webhook_endpoint_id to GET /v1/webhook-events/listen or GET /v1/webhook-events/listen/{outbox_event_id}. The response includes signature_preview with the exact raw body, timestamp, signature, andtyxter-webhook-* headers generated from that endpoint secret.
From this repo, pnpm sandbox:local-loop runs the full local path once: quickstart diagnostics, inbound simulation, sandbox listen, and optional signed forwarding.
docker run --rm \
-e TYXTER_API_URL=https://api.tyxter.com \
-e TYXTER_API_KEY=tx_sandbox_... \
-e TYXTER_WEBHOOK_FORWARD_URL=http://host.docker.internal:3000/webhooks/tyxter \
-v tyxter-cli-data:/data \
ghcr.io/tyxter-dev/tyxter-cli:latest listenTyxter CLI lives at github.com/tyxter-dev/tyxter-cli. It persists its local signing secret and cursor in a Docker volume. Run tyxter doctor to check API access and local reachability, then tyxter status to print the signing secret for the SDK verifier below. The sandbox listener can run directly with a sandbox key.
If you are using Codex or Claude Code to test your app, point it at the CLI repo's AGENTS.md or test-sandbox-webhooks skill. It should run the CLI against your app, simulate a sandbox inbound event, and report evidence from both sides.
Event streams and cursors
GET /v1/webhook-events lists durable delivery logs newest first. Page it with limit and starting_after from the previous response next_cursor.
GET /v1/webhook-events/listen streams customer-visible events oldest first for local agents and verifier probes. Page it with limit and cursor from the previous listen response next_cursor. On the first poll, use start_at=oldest to replay retained events or start_at=tail to skip existing retained events.
Honor Retry-After on 429 responses and next_poll_after_ms on successful listen responses before polling again.
curl "https://api.tyxter.com/v1/webhook-events/listen?limit=25&start_at=tail&wait_ms=5000" \
-H "authorization: Bearer $TYXTER_API_KEY"Temporary production listen sessions
Production keys can use the same no-public-URL listen stream only inside a short-lived diagnostic session. Create a session, pass its listen_session_id while polling, then disable it when the probe finishes. Sessions are capped at five minutes, rate-limited, limited per environment, and only return events created after the session opened.
SESSION_ID=$(curl https://api.tyxter.com/v1/webhook-events/listen-sessions \
-H "authorization: Bearer $TYXTER_API_KEY" \
-H "content-type: application/json" \
-d '{"ttl_seconds":300,"reason":"prod receive smoke"}' | jq -r .id)
curl "https://api.tyxter.com/v1/webhook-events/listen?listen_session_id=$SESSION_ID&event_types=message.received&wait_ms=5000" \
-H "authorization: Bearer $TYXTER_API_KEY"
curl -X DELETE "https://api.tyxter.com/v1/webhook-events/listen-sessions/$SESSION_ID" \
-H "authorization: Bearer $TYXTER_API_KEY"Event envelope
Every delivery has the same top-level shape:
{
"id": "evt_01HZ4...",
"type": "message.delivered",
"created_at": "2026-04-27T12:00:00.000Z",
"environment": "production",
"trace_id": "trc_01HZ4...",
"data": {
"message_id": "msg_01HZ4...",
"status": "delivered",
"to": "+5511999999999",
"from": "pn_01HZ4...",
"provider_message_id": "wamid.abc",
"metadata": { "order_id": "ORD-123" }
}
}data is event-specific; everything else is fixed. Route by type, log trace_id, dedupe on id.
payment.paid before payment.approval_available or payment.created. Dedupe by envelope id, use occurred_at as business-event time, and make state updates monotonic so a late earlier event cannot reopen or regress a terminal resource.{
"id": "evt_01HZ5...",
"type": "message.received",
"created_at": "2026-05-09T12:00:00.000Z",
"environment": "sandbox",
"trace_id": "trc_01HZ5...",
"data": {
"message_id": "msg_01HZ5...",
"status": "received",
"channel": "whatsapp",
"sender": { "type": "phone_e164", "id": "+5511999999999" },
"recipient": { "type": "whatsapp_phone_number", "id": "pn_sandbox_123" },
"provider_message_id": null,
"metadata": {},
"content": {
"type": "text",
"text": { "body": "ping" }
}
}
}Interactive inbound selections
The listen API wraps the customer webhook envelope in each item's payload. For interactive replies, read the selection from payload.data.content.interactive, not from payload.interactive or payload.from.
{
"object": "webhook_event_listen",
"data": [{
"id": "out_01HZ6...",
"type": "message.received",
"payload": {
"id": "evt_out_01HZ6...",
"type": "message.received",
"trace_id": "trc_customer_123",
"data": {
"message_id": "msg_01HZ6...",
"channel": "whatsapp",
"sender": { "type": "phone_e164", "id": "+5511988887777" },
"recipient": { "type": "whatsapp_phone_number", "id": "pn_sandbox_123" },
"metadata": { "customer_id": "cus_123" },
"content": {
"type": "interactive",
"interactive": {
"type": "button_reply",
"button_reply": { "id": "plan:cus_123:pro", "title": "Pro" }
}
}
}
}
}],
"next_cursor": "cur_..."
}List replies use the same path with interactive.type = "list_reply" and interactive.list_reply.id. Public WhatsApp phone identities use strict E.164 with a leading + and preserve every subscriber digit supplied by the provider or sandbox request. Internal matching may use a separate canonical key, but that key is never substituted into the customer webhook. Correlate sandbox taps with a stable tyxter-trace-id on POST /v1/sandbox/inbound-messages, an app customer id in metadata, or a customer-scoped button_reply.id / list_reply.id.
Signature verification
Every request carries three headers:
tyxter-webhook-id: evt_01HZ4...
tyxter-webhook-timestamp: 1714176000
tyxter-webhook-signature: <hex>The signature is HMAC-SHA256 over ${timestamp}.${rawBody} using the endpoint's signing secret, hex-encoded. Verify it before trusting the payload.
Using the SDK
import { verifyWebhookSignature } from '@tyxter/sdk-js';
const ok = verifyWebhookSignature({
secret: process.env.TYXTER_WEBHOOK_SECRET!,
signature: req.headers['tyxter-webhook-signature'],
timestamp: req.headers['tyxter-webhook-timestamp'],
rawBody, // the exact raw body string — not re-stringified JSON
toleranceSeconds: 300,
});
if (!ok) return res.status(401).end();Manual verification (any stack)
signed_payload = "{timestamp}.{rawBody}"
expected = hex( HMAC_SHA256(signing_secret, signed_payload) )
reject if:
- tyxter-webhook-signature or tyxter-webhook-timestamp header missing
- abs(now - timestamp) > 300 seconds
- timingSafeEqual(expected, signature) is falseDelivery and retries
- Delivery is at least once and unordered. Do not infer lifecycle order from request arrival time or
created_at. - Any
2xxresponse is treated as success. - Non-2xx, network error, or timeout → retried with exponential backoff up to 6 attempts over ~24 hours.
- After the final failed attempt we emit
webhook.failed_permanentso your ops can alert. - Every attempt is recorded on the
WebhookDeliveryAttemptlog.
Manual replay is supported — any event can be resent to every active endpoint subscribed to its type.
Replay protection
- Store processed webhook ids and ignore duplicates.
- Reject timestamps outside a five minute tolerance before checking the signature.
- Keep handlers idempotent: a replayed
message.deliveredmust not double-ship or double-credit anything in your app.
Debug failed deliveries
- Start with
GET /v1/webhook-eventsand filter by event type or delivery status. - Open the event detail to inspect each delivery attempt, response code, and response body snippet.
- Fix your receiver, then call
POST /v1/webhook-events/{id}/resend. - Use the event
trace_idto correlate the webhook with the originating message or batch.
Event catalog
| Type | Data |
|---|---|
message.sent | Meta accepted your send. |
message.delivered | Delivered to the recipient's device. Billing fires here. |
message.read | Recipient opened the message. |
message.failed | Permanent failure. error_code + error_message attached. |
message.expired | Terminal: the message was stranded in queued past the queue-age cutoff and never dispatched. status is expired. |
message.received | Inbound message from the user. Text messages include data.content.text.body. |
template.approved / template.rejected / template.paused / template.disabled | Meta template lifecycle state change. |
flow.published / flow.rejected | Flow lifecycle. |
flow.completed | End-user submitted a Flow form. |
phone_number.verified / phone_number.disconnected / phone_number.released / phone_number.tier_changed | Number lifecycle and Meta throughput changes. |
contact.opted_in / contact.opted_out / contact.erased | Consent ledger transitions. |
llm.handoff_requested | Inbound matched a handoff phrase; human takeover requested. |
llm.cost_cap_reached | Daily BRL cap on the LLM route hit. |
credit.low_balance | Credits dropped below the configured threshold. |
spend_limit.hit | Project monthly spend cap reached. |