Platform

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.

POST/v1/webhook-endpoints
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 listen

Tyxter 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.

POST/v1/webhook-events/listen-sessions
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"
Production listen sessions are for ephemeral validation and conformance probes. Use a registered HTTPS endpoint for durable production webhook transport.

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.

Delivery order is not guaranteed, including for events about the same resource. A fast transition can deliver 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 false
Always use the raw request body. Re-serializing JSON changes key order and whitespace and your signature will not match.

Delivery and retries

POST/v1/webhook-events/{'{id}'}/resend

Manual replay is supported — any event can be resent to every active endpoint subscribed to its type.

Replay protection

Debug failed deliveries

Event catalog

TypeData
message.sentMeta accepted your send.
message.deliveredDelivered to the recipient's device. Billing fires here.
message.readRecipient opened the message.
message.failedPermanent failure. error_code + error_message attached.
message.expiredTerminal: the message was stranded in queued past the queue-age cutoff and never dispatched. status is expired.
message.receivedInbound message from the user. Text messages include data.content.text.body.
template.approved / template.rejected / template.paused / template.disabledMeta template lifecycle state change.
flow.published / flow.rejectedFlow lifecycle.
flow.completedEnd-user submitted a Flow form.
phone_number.verified / phone_number.disconnected / phone_number.released / phone_number.tier_changedNumber lifecycle and Meta throughput changes.
contact.opted_in / contact.opted_out / contact.erasedConsent ledger transitions.
llm.handoff_requestedInbound matched a handoff phrase; human takeover requested.
llm.cost_cap_reachedDaily BRL cap on the LLM route hit.
credit.low_balanceCredits dropped below the configured threshold.
spend_limit.hitProject monthly spend cap reached.