Platform

Webhooks

Signed, retried delivery of eligible customer-facing events. 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.

Webhook registrations remain scoped to the organization, project, and environment of the API key that creates them; an integration spanning several projects still registers one endpoint per project and environment. One receiver URL may be reused across environments, projects, or organizations. Each registration has its own endpoint-specific signing secret, so the receiver must select the secret for the endpoint that sent the request rather than routing or selecting a secret by URL. Patterns are on the Multi-tenant platforms page.

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"
    ]
  }'

Testing your endpoint

For a registered HTTPS endpoint, POST /v1/webhook-endpoints/{id}/test with an Idempotency-Key creates one signed webhook.test delivery and returns a pending receipt containing webhook_event_id. Store that id, then poll GET /v1/webhook-events/{id} for the terminal status and attempts[0].status_code and attempts[0].failure_class before declaring the receiver ready. Pending, successful, historical, and unclassified attempts expose a null failure_class. The probe ignores subscribed_events, is not a subscribable external event, and does not enter fanout or listen streams. It uses the normal signature, transport and response-evidence path; every test failure is terminal after attempt 1. Probes read while disabled or on probation never change health counters, health timestamps or probation, even if they settle after re-enable. Active non-probation probes retain normal failure-increment/success-reset behavior only while the current endpoint is also active and outside probation. Disabled skips do not count as receiver failures. Each endpoint has five test requests per minute, including authenticated idempotency replays; honor Retry-After and error.retry_after_ms on 429. A breaker-disabled endpoint accepts this probe without re-enabling itself; a manually disabled endpoint returns 409 webhook_endpoint_disabled. Agent recovery is diagnose with GET /v1/webhook-endpoints/{id}, repair the receiver, test, read the result with GET /v1/webhook-events/{webhook_event_id}, then PATCH status active. A failed test never blocks PATCH. The API queues delivery; only the worker calls the receiver. Re-enabling starts probation until the first successful real delivery; the probation threshold is three consecutive real delivery failures. Error responses include error.feedback pointing to POST /v1/feedback. Never resend webhook.test: single resend returns 409 webhook_test_not_resendable; bulk resend skips explicit ids and status selections, while event_type webhook.test returns 400 invalid_bulk_webhook_resend. Every fresh probe must use this rate-limited route.

POST/v1/webhook-endpoints/{id}/test202 Accepted
curl -X POST https://api.tyxter.com/v1/webhook-endpoints/$WEBHOOK_ENDPOINT_ID/test \
  -H "authorization: Bearer $TYXTER_API_KEY" \
  -H "idempotency-key: $IDEMPOTENCY_KEY"

Debug failed deliveries

Repeated 401/403 delivery failures usually mean your receiver is verifying the wrong endpoint-specific signing secret. Reusing one receiver URL across environments, projects, or organizations is supported, but the receiver must select the signing secret for the endpoint that sent each request. Debug failed deliveries by inspecting the endpoint delivery attempts and disabled_detail; after correcting receiver verification, test the breaker-disabled endpoint, read the result, then re-enable it with PATCH /v1/webhook-endpoints/{id} and status set to active.

Phone-service suspension

Phone-service suspension blocks new automatic forwarding of affected inbound customer content through webhooks and listen. Content suppressed at intake or at an automatic forwarding decision stays omitted after recovery. Status receipts, reactions, opt-out and unrelated phones keep their existing paths. Historical webhook-event reads retain their normal retention rules. A fresh explicit resend can return phone_service_suspended; bulk resend counts denied events as skipped. After recovery, explicit resend creates a new attempt without restarting older automatic work. An accepted idempotent resend keeps its original result.

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 rate-limited, limited per environment, and only return events with created_at >= session.created_at and created_at < session.expires_at.

Production listen sessions accept a requested TTL from 30 to 300 seconds and default to five minutes, which is also the maximum. Only the owning API key retains and passes its listen_session_id while polling. They expose only events with created_at >= session.created_at and < session.expires_at and are not a durable or gapless transport. If POST /v1/webhook-events/listen-sessions returns 409 webhook_listen_session_active_exists, the required recovery metadata is error.details.active_session.{id, expires_at, created_at}. Outside grace, only the owner may reuse and poll that id; with 30 seconds or less remaining, only the owner may POST again and must use the returned new id. The old id is disabled by that replacement. A sibling must not poll that id or loop POST: it waits until error.details.active_session.expires_at or coordinates with the owner to disable. The conflict does not consume a create rate-limit token.

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. Note that environment is the kind — sandbox or production — not an id: the envelope carries no organization or project ids. Use the endpoint registration and its endpoint-specific signing secret to select the right receiver configuration; do not infer tenant routing from the receiver URL.

Webhook delivery is at least once and ordering is not guaranteed, including between events for the same resource. Dedupe by envelope id, use occurred_at as business-event time, and apply lifecycle updates monotonically 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", "profile_name": "Ana Silva" },
    "recipient": { "type": "whatsapp_phone_number", "id": "pn_sandbox_123" },
    "provider_message_id": "sb_wamid_1f7b0c9d4e2a48f3b5c6d7e8f9a0b1c2",
    "metadata": {},
    "content": {
      "type": "text",
      "text": { "body": "ping" }
    }
  }
}

Inbound media

Meta image.id and audio.id values are provider handles, not Tyxter asset_id values; never pass them to GET /v1/media/{asset_id} or render private lookaside.fbsbx.com URLs. Use data.content.media.asset_id and mint a fresh download URL with GET /v1/media/{asset_id}/download-url.

Tyxter delays the customer-visible event while its worker resolves and stores the private provider bytes. On success, media.status is consumed. After bounded retry exhaustion, the event is still delivered with media.status = "failed" and a stable media.failure object, so an inbox can render a durable attachment error instead of waiting forever. Neither form contains a signed URL; mint one only when the user opens or plays the attachment.

For inbound WhatsApp audio, optional media.voice projects Meta's voice signal: true identifies a voice note, false an ordinary audio file, and omission a legacy row or absent provider signal.

Branch on data.content.media.status before reading anything else. consumed means the bytes are stored and a download URL can be minted. failed always carries failure.code and failure.message, and it is the only status that carries a failure object. expired means your retention window closed and deleted means the contact was erased; both mean the bytes are gone and no download URL can be minted, while asset_id still identifies the attachment so a replayed event stays matchable to what you already stored.

{
  "type": "message.received",
  "data": {
    "message_id": "msg_01HZ7...",
    "status": "received",
    "channel": "whatsapp",
    "sender": { "type": "phone_e164", "id": "+5511999999999", "profile_name": "Ana Silva" },
    "recipient": { "type": "whatsapp_phone_number", "id": "123456789" },
    "provider_message_id": "wamid.abc",
    "metadata": {},
    "content": {
      "type": "media",
      "media": {
        "asset_id": "mda_01HZ7...",
        "provider_media_id": "987654321",
        "kind": "audio",
        "voice": true,
        "mime_type": "audio/ogg",
        "byte_length": 18432,
        "filename": null,
        "status": "consumed"
      }
    }
  }
}

Audio transcription is a later, explicit operation. Success emits message.media_transcribed with data.transcript.text, language, duration, provider, model, and the same media_asset_id. Terminal failure emits message.media_transcription_failed with the same error_code as the transcript GET receipt and no speech or provider details. Neither delays nor replaces the playback-ready message.received event.

Existing webhook endpoints are not automatically subscribed when Tyxter adds an event type. Add both transcription events with PATCH /v1/webhook-endpoints/{webhook_endpoint_id} or edit the endpoint in the dashboard.

text is null once contact erasure or your retention window removed the transcribed speech, while status stays "succeeded" and the provider, model, duration, and completion time survive. Treat a null text as erased content, never as a failed transcription.

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", "profile_name": "Sandbox User" },
        "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 normally use strict E.164 with a leading + and preserve every subscriber digit supplied by the provider or sandbox request. If Meta withholds the customer phone on a production inbound message, message.received.data.sender is the explicit unresolved value { type: "phone_e164", id: "" }. Never use that empty id as a recipient or replace it with a synthetic identifier. Internal matching may use separate identity evidence, but it 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.

Every new message.* payload includes data.sender.profile_name. It is the captured customer WhatsApp profile name only for a resolved inbound WhatsApp sender; it is null otherwise, including after message privacy cleanup. It is not a verified identity or a business display name, and Tyxter does not store it as a contact field. Stored historical event snapshots retain their original bytes and can therefore lack this later field.

Template button taps

A tap on a quick-reply button attached to a template message arrives on the same path: content.type is interactive and the selection is at content.interactive.button_reply. You do not need to branch on whether the button came from a template or from an interactive message.

"content": {
  "type": "interactive",
  "interactive": {
    "type": "button_reply",
    "button_reply": {
      "id": "plan:cus_123:pro",
      "title": "Escolher plano"
    }
  }
}

button_reply.id is the quick-reply payload you set on the template button — the value to route on. WhatsApp defaults it to the button label when you do not set one, so give each button a distinct payload (encode your customer or plan key in it) if you need taps to be unambiguous. button_reply.title is the label the customer saw.

Need the untouched provider payload — say, the context object naming the template message that was replied to? Read the message itself: GET /v1/messages/{message_id} returns the stored payload in full. (On the list route the raw payload is opt-in: GET /v1/messages?include=payload.) The webhook carries the normalized selection; the message read carries everything the provider sent. payload is a diagnostic window. New WhatsApp quote/forward receipts expose content.context:reply_to carries the Tyxter message_id (null when unresolved) andprovider_message_id, alongside forwarded andfrequently_forwarded booleans. Detail and payload-inclusive list reads expose the same shape as top-level context. Older receipts may lack normalized context. Messaging capabilities lists what is supported today.

Unsupported and unknown inbound types

Not every inbound message is text, media, or interactive. Two other outcomes each carry their own block keyed off content.type. unsupported means the provider refused to deliver what the customer sent — WhatsApp video notes (instant video), polls, and some view-once content, which Meta reports with error 131051 and the fixed explanation "Message type is currently not supported." The bytes never reach Tyxter and never will, so there is no media block on these rows and no attachment to fetch later; the only useful reaction is replying to ask for a regular video, audio, or text. unknown means the provider did deliver the message, in a type Tyxter does not project into a typed field yet — a shared location, contact card, order, or system notice — so the content is there to read: fetch the raw provider envelope with GET /v1/messages/{message_id}. Never ask an unknown sender to resend; only the typed view is missing.

"content": {
  "type": "unsupported",
  "unsupported": {
    "provider_type": "video_note",
    "reason": { "code": 131051, "message": "Message type is currently not supported." }
  }
}
"content": {
  "type": "unknown",
  "unknown": {
    "provider_type": "location"
  }
}

Neither carries text, media, or interactive — the block named by type is the whole content. provider_type is the provider’s own name for the format and is null when it named none; reason is the provider’s numeric code with its own explanation, and it is null when the provider sent no usable error block. The pair is whole or absent, never half-reported, so a receiver branching on reason.code can trust the message beside it.

The message read carries the same two blocks under the same names: GET /v1/messages/{message_id} and the GET /v1/messages list rows expose unsupported and unknown as nullable sibling fields of media. Like media, they are present without include=payload, so a polling integration classifies these rows on the default list read; media is null on both, and both are null on every other message. For an unknown message, that read is also where the content itself is: the detail response always includes the stored payload, and the list route returns it with GET /v1/messages?include=payload.

Rehearse both branches before connecting WhatsApp: POST /v1/sandbox/inbound-messages accepts type: "unsupported" (its provider_type defaults to video_note, and the refusal reason is always the real WhatsApp pair — code 131051 with Message type is currently not supported.) and type: "unknown" (provider_type defaults to location). The stored row, the message read, and the message.received content are the same ones the real provider path produces.

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';

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.

Endpoint requirements

Your endpoint must answer 2xx directly: Tyxter never follows redirects, so any 3xx response is a failed delivery attempt (the same security posture as Stripe and Svix), and the attempt log records it as fetch failed: unexpected redirect. The URL must be HTTPS on a publicly resolvable host — localhost, private, and link-local targets are refused, and URLs carrying username:password credentials are rejected at registration. Google Apps Script web apps can never work as webhook endpoints: they answer every POST with a redirect to script.googleusercontent.com and do not expose request headers to doPost, so the tyxter-webhook-signature header cannot be verified there either. Front Apps Script (or anything like it) with a small HTTPS receiver — a Cloud Function or Cloudflare Worker — that returns 200 immediately and forwards the payload onward.

Forward compatibility

Tyxter adds to webhook payloads and API responses without a version change, so treat both kinds of addition as normal: new fields on any object, and new values in any enum-like field (a status, a reason, an error_code). Ignore fields you do not recognize, give every switch over such a field a default branch, and never reject or fail an event because of a value you have not seen before. New event types are never sent to an existing endpoint until you subscribe it to them. Tyxter does not remove or rename a field, an enum value or an error code without a deprecation period.

Delivery and retries

Each delivery attempt has one fixed 10-second deadline covering DNS validation, the request, and reading your response body, so a handler that answers late is recorded as a failed attempt even when it finishes the work afterwards, and the event is redelivered on the next rung of the ladder. Acknowledge with 2xx as soon as you have durably accepted the event, do the slow work asynchronously, and dedupe on the envelope id so a timed-out-but-completed attempt cannot double-process.
POST/v1/webhook-events/{id}/resend

Resend re-delivers that one event to its own endpoint. A webhook event is recorded per subscribed endpoint, so a resend never fans out to the others. The new attempt is appended to the event's attempt log and continues the ladder rather than restarting it: it takes the next attempt number, so a resend after the eighth attempt runs once and earns no further automatic retries. To replay many events at once, use POST /v1/webhook-events/bulk-resend, which selects by event ids, event type, or delivery status.

Replay protection

Debug failed deliveries

Event catalog

TypeData
message.sentMeta accepted your send.
message.deliveredDelivered to the recipient's device. This status does not decide transport billing.
message.readRecipient opened the message.
message.failedPermanent failure. Carries the cause inline — error_code (stable, safe to branch on), error_message (human-readable, ending in the next step to take), and provider_error (the raw error object from the provider) — so you do not have to call back to read why.
message.expiredTerminal. Either the message was stranded in queued past the queue-age cutoff and never dispatched, or Meta accepted it but never reported it delivered or failed within 30 days, after which no outcome can arrive. status is expired; error_message says which.
message.delivery_timeoutNot terminal, and the only event that reports the absence of a provider callback: the send was accepted, and then no delivery status came back inside the confirmation window. status is normally still sent, the message may well have been delivered, and if a status arrives later you get that event too. data.delivery_unconfirmed_at is when the window closed — the same value as delivery_unconfirmed_at on GET /v1/messages/{message_id}. Both fields are read from the message’s live state at delivery time, so if a real status landed between detection and this webhook, data.delivery_unconfirmed_at is absent (the stamp is cleared on any status) and status may already read delivered, read, or failed — treat the field as optional. Treat the event as “verify with the recipient”, never as a failure. Production only.
message.reaction_receivedCustomer reaction to a message in the conversation: yours or, as WhatsApp allows, their own. The existing target-message snapshot stays; data.reaction adds emoji, removed, from (reacting user) and reacted_message_id (Tyxter target ID). from is null when Meta withholds the user's phone. Removal has an empty emoji and removed: true. Simulate with POST /v1/sandbox/inbound-messages using type: reaction and reaction: { message_id, emoji }.
message.receivedInbound message from the user. Text messages include data.content.text.body. Two values report content that is not projected as text, media, or interactive: data.content.type is unsupported when the provider refused to deliver it (a WhatsApp video note; detail in data.content.unsupported, and the bytes never arrive) and unknown when it delivered a type Tyxter does not project yet (a shared location; detail in data.content.unknown, content readable from the message payload).
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.
phone_number.quality_changed / phone_number.messaging_tier_changed / phone_number.name_status_changedMeta-reported health for one of your numbers moved: its quality rating, its daily unique-recipient allowance, or its display-name review state. Each carries the full health snapshot (quality_rating, messaging_tier, messaging_limit_tier, name_status, meta_health_synced_at) plus the previous value of whichever dimension moved, so you never need a follow-up read. Note that messaging_tier is Meta’s daily unique-recipient allowance, a different axis from the send-rate tier reported by phone_number.tier_changed. Fired only on a real change — the first time a number’s health is read is not a change, and re-reading the same value emits nothing. Production only.
phone_number.messaging_limit_paused / phone_number.messaging_limit_resumedTyxter stopped, then restarted, starting NEW conversations on one of your numbers because its active allowance ran out. For linked Meta phones, that is a shared Business Portfolio allowance; the phone health tier is descriptive and an open customer-service window remains specific to the sending phone. These report Tyxter’s own pacing, not a move by Meta. A pause is not a failure and nothing is lost: affected messages stay queued and send by themselves once allowance frees up, and messages to recipients already inside an open conversation window keep flowing throughout. Do not retry, do not re-create the messages, and do not page anyone — the useful reaction is to stop feeding NEW recipients to that portfolio, or route them to a sender backed by a different allowance. Another phone in the same linked portfolio does not add capacity. Both carry the full health snapshot plus effective_cap, tier_source, and detected_by_message_id. The remaining fields split by half rather than appearing on both. For a linked pause, portfolio_cap is the shared authority and per_phone_cap is null. For an unlinked Meta pause, portfolio_cap is null and per_phone_cap is the conservative fallback; when that unlinked phone itself reports an unlimited tier, per_phone_cap is null and tier_source is tier_unlimited_unlinked_default, because the fallback cap still applies. Sandbox instead follows its deterministic simulated phone tier. The PAUSE carries those fields plus safety_margin_percent (the percentage KEPT, not held back), and pause_reason — either messaging_limit_reached (our accounting stopped the send) or provider_limit_hit (we still showed allowance and WhatsApp rejected it anyway). Only the RESUME carries previous_pause_reason, which repeats the pause_reason of the pause it ends so you can close out the pause you saw; it is null only when that reason could not be recovered. They fire on the TRANSITION, not per message, and one pacing episode yields one pause and one resume: the resume is OBSERVED, not scheduled — it is reported when a send to a new recipient next succeeds with no deferred backlog left behind it, so a paused number that stops sending emits nothing and you must never wait for a resume before sending again. Order the pair by the envelope’s occurred_at, since a resume can overtake a retrying pause. Reachable from a sandbox key.
provider_connection.policy_warningprovider_connection.policy_warning is a non-enforcing Meta policy warning: sends are not blocked. Its exact data shape is provider_connection_id, provider, display_name, nullable/open violation_type, and observed_at; continued violations may still lead Meta to restrict or disable the account. Subscribe explicitly to receive it. Review Meta policy guidance and Account Quality. Production only.
provider_connection.disable_scheduledprovider_connection.disable_scheduled is advisory Meta schedule evidence, not a suspended connection: sends continue until Meta actually disables the account. Its exact data shape is provider_connection_id, provider, display_name, nullable ISO waba_ban_date, and observed_at; it deliberately excludes tenant ids, WABA/phone ids, status, reason, restrictions, credentials, raw provider data, and operator-only counts. Subscribe explicitly to receive it. Review Account Quality before Meta’s actual disablement. Production only.
provider_connection.suspendedMeta acted against your WhatsApp Business Account itself, so the connection is suspended and sends on it will fail. This is not a token problem: the stored credential is kept and rotating or re-authenticating it changes nothing. data.reason is one of banned, scheduled_for_disable, restricted, review_rejected, app_uninstalled, or account_deleted — treat any other value as a generic account-level suspension. Meta’s own raw state rides along in data.waba_ban_state and data.account_review_status, and data.restrictions lists each active restriction with its expiration and remediation when Meta supplied them. data.appeal_url is where the account is contested — it is null exactly for app_uninstalled and account_deleted, where there is nothing to appeal and connecting the account again is the real fix. Stop sending on receipt and surface it to a human. The connection returns to connected once Meta clears the account. Tyxter learns about these actions two ways: Meta’s account webhooks, which report a ban, restriction, or review decision within seconds once the account-state fields are subscribed on Tyxter’s Meta app, and a daily reconciliation sweep that reads each connected account’s state directly from Meta and catches anything a webhook missed. Subscribing those fields on the production Meta app is still an outstanding operational step on Tyxter’s side, so until it lands the daily sweep is what detects an account action — expect up to a day of lag rather than seconds. Either way, the connection status is the authoritative signal for whether a connection can send, so keep polling GET /v1/provider-connections/status rather than reading silence on this event as a healthy account. Production only.
provider_connection.reinstatedWhen Meta clears the account action, Tyxter emits provider_connection.reinstated with status connected and the event-time reinstated_from and reinstated_at fields. Resume sends only after receiving this event or confirming the connection is ready through GET /v1/provider-connections/status. A connection whose signup had not finished returns to pending instead and emits no provider_connection.reinstated; finish registration to make it ready. Production only.
group.created / group.create_failed / group.deleted / group.delete_failed / group.invite_link_reset / group.invite_link_reset_failed / group.participant_joined / group.participant_removedWhatsApp Business group events (beta) report what happened to a group Tyxter created: group.created and group.create_failed for the create, group.deleted and group.delete_failed for the delete, group.invite_link_reset and group.invite_link_reset_failed for an invite-link reset, and group.participant_joined and group.participant_removed for a join or removal Tyxter records. Each is produced for the endpoints subscribed to its type and delivered under the rules on this page; a delete that stays deleting because Tyxter could not tell whether Meta deleted the group (for example the call timed out) produces neither group.deleted nor group.delete_failed unless Meta later reports the outcome, so read the group instead of waiting, and once a contact erasure matches a participant, the joins and removals already recorded for them produce no further events. The six lifecycle events carry group_id, phone_number_id, status, subject, provider_group_id, invite_link, failure and occurred_at. On the three failed events, failure is the failed operation's own failure, stored with the event; every other field but occurred_at, and failure on the other three events, is the group as Tyxter reads it when it sends the event or serves it on a listen read, so a delayed or replayed event can show a later state. The two participant events carry group_id, phone_number_id, participant.wa_id, participant_count and occurred_at, plus initiated_by (participant or business) on a removal. invite_link lets anyone who has it join the group, so store lifecycle payloads as secrets. A sandbox key never receives group.delete_failed or group.invite_link_reset_failed. Existing endpoints receive none of these events until you add their types to subscribed_events with PATCH /v1/webhook-endpoints/{webhook_endpoint_id}, which replaces the whole list: send the types the endpoint already has plus the eight group types. The WhatsApp Business groups guide walks a group through these events, and the API reference has the full payload rules.
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.topped_upcredit.topped_up reports a cash credit with topup_id, amount_brl, payment_method, optional provider, and balance_brl. provider can be absent on historical events; do not infer it. A promotion bonus uses provider and payment_method set to promotion, while campaign and redemption details are never delivered.
credit.hard_block_engaged
credit.hard_block_lifted
credit.hard_block_engaged reports a production API-key and MCP credit restriction; credit.hard_block_lifted reports event-time recovery. The block engages when the balance falls to -5 BRL or below and lifts when it rises above -5 BRL; floor_brl is that -5 BRL threshold. Both carry organization_id, four-decimal balance_brl and floor_brl, and an event-time top_up_url scoped to the organization. Engaged also carries trigger: usage, phone_renewal, reversal, or reconciliation. A lift can follow a reservation refund and does not prove a purchase. Subscribe to both events, dedupe by envelope id, and confirm current balance before resuming work because delivery can be late or out of order. By default, the dashboard bell shows an organization-wide alert, while email reaches eligible configured billing recipients. Channel preferences still apply; email also honors recipient preferences, quiet hours, digest cadence, and existing email budgets. Each new block or recovery episode remains distinct; the recovery notice does not promise other sending restrictions have cleared.
credit.low_balanceCredits dropped below the configured threshold.
spend_limit.hitProject monthly spend cap reached.