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.

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 first-attempt evidence before declaring the receiver ready. 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, response-evidence, and breaker path: success resets the endpoint breaker, failure increments it, and every test failure is terminal after attempt 1. Each endpoint has five test requests per minute, including authenticated idempotency replays; honor Retry-After and error.retry_after_ms on 429. A disabled endpoint returns 409 webhook_endpoint_disabled: repair the receiver and PATCH it to status active before a fresh probe. 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, re-enable the endpoint with PATCH /v1/webhook-endpoints/{id} and status set to active before sending a fresh test.

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. Note that environmentis 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" },
    "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.voiceprojects 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" },
    "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" },
        "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.

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.

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_typeis 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/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.

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.

Delivery and retries

Each delivery attempt has a single 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. Billing fires here.
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: the message was stranded in queued past the queue-age cutoff and never dispatched. status is expired.
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.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_tieris 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; 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_reasonof 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. The resume is OBSERVED, not scheduled — it is reported when a send to a new recipient next succeeds, 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 Qualitybefore 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 connectedonce 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. Production only.
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.low_balanceCredits dropped below the configured threshold.
spend_limit.hitProject monthly spend cap reached.