Utility Webhooks
POST /utilities/send returns as soon as Akedly accepts your message — not when it arrives. What happens afterwards reaches you here: whether it was delivered, whether it was read, anything the recipient sends back, and Meta's decision when it approves or rejects one of your templates. The message events are a WhatsApp-only story. A direct BYO-SMS leg produces no post-send events: your provider's acceptance is the final word, so an integrator waiting on a webhook for that SMS leg will wait forever. If a WhatsApp attempt later falls back to BYO SMS, its own message.status failure can still arrive; that event describes the WhatsApp attempt, not necessarily the overall request, and the fallback SMS leg emits no event.
Managed Vodafone integrations differ
BYO SMS here describes an integration you build and own. If Akedly operates a managed Vodafone integration for your account, several of the capabilities described below are fixed by Akedly and are not yours to set — see Managed Vodafone.
This is the outbound direction
Webhooks are what Akedly sends to you. Don't confuse them with request signing, which is how you prove a call to Akedly came from you — different secret (usk_), different headers. See Request Signing.
What happens after a send
New to webhooks entirely? Start at Webhooks — it covers what they are, how to receive one, and how the three Akedly webhook systems differ. This page assumes that and goes straight to the utility payloads.
One WhatsApp send produces a stream of events. These are the per-send events; one more, template.status_changed, is not tied to a send and follows the table. None of the rows below fire for a direct BYO-SMS leg: Akedly does not ingest delivery receipts from a BYO provider — there is no route that accepts one — and replies and button taps are WhatsApp features, so that SMS leg produces no message.status, no message.inbound and no button event. A WhatsApp attempt that later falls back to BYO SMS can still emit its own message.status failure; the fallback SMS leg emits no event. See The four events below for the per-channel detail.
| When | You receive |
|---|---|
| Akedly hands the message to WhatsApp | message.status — sent |
| It reaches the recipient's device | message.status — delivered |
| They open it (WhatsApp only, and only if they allow read receipts) | message.status — read |
| A WhatsApp attempt could not be delivered | message.status — failed, with a reason |
| They type a reply | message.inbound |
| They tap a quick-reply button | button.action |
Separately from any send, Meta approves or rejects one of your templates → template.status_changed. It fires once per accepted Meta APPROVED or REJECTED transition on a matched customer-owned Utilities template. It goes to every eligible direct HTTPS callback URL on that account, not to the pipeline of a particular send, and it carries a one-use token for fetching the template's full details. Other Meta statuses and HTTP callback URLs receive no template status event. See The four events.
Events are not ordered, and not all of them arrive
Meta delivers status updates asynchronously and can reorder or repeat them, so a read may land before its delivered. Never treat arrival order as the truth — key off the status value and the timestamps, not the sequence you happen to observe. And read is a courtesy: a recipient with read receipts disabled never produces one, so it is not something you can wait for.
Two kinds of event, and which are forwarded
Meta sends Akedly two different families of webhook, and the dashboard splits them exactly that way under Channels → Monitoring → Meta webhooks:
| Family | What it covers | Sent to your endpoint? |
|---|---|---|
| Message events | Delivery status, replies, button taps — message.status, message.inbound, button.action | Yes |
| Account events: template status | A template approved or rejected by Meta — template.status_changed | Yes, to direct HTTPS callback URLs. This is the one account event that is forwarded. |
| Account events: everything else | Other template status changes (such as pending, paused or disabled), template category changes, phone-number quality, WhatsApp Business Account review, business capability updates | No — dashboard only |
Other account events are visible but not delivered
Akedly records the account events Meta sends, so the events it receives appear in the Account events tab. Only template approvals and rejections are forwarded to your webhook URL, as template.status_changed. Nothing else in that tab is POSTed. There is no payload to code against for other template status changes, phone-number quality, WABA review, business capability or template category changes. Watch the tab for those.
Point Akedly at your endpoint
Webhooks are configured per pipeline. You need two things on the utility pipeline: the URL to POST to, and the signing secret used to prove the request is ours.
Set backendCallbackURL, then reveal the whsec_ signing secret. It is shown once — store it before closing the dialog, and rotate rather than guess if you lose it.
Use a direct HTTPS URL. template.status_changed is sent only to HTTPS callback URLs, and Akedly does not follow a redirect for it.
Write the handler
Verify the request, acknowledge it quickly, and queue every verified attempt with its signed svix-timestamp. In the queue worker, retain the token from the newest verified attempt before deduplicating business work:
import express from 'express'
import { Webhook } from 'svix'
const app = express()
const wh = new Webhook(process.env.AKEDLY_WEBHOOK_SECRET) // whsec_...
// Verification needs the RAW body — parsing to JSON first breaks the signature.
app.post('/akedly/webhooks', express.raw({ type: 'application/json' }), (req, res) => {
let event
try {
// 1. Verify. Throws if the signature or timestamp doesn't check out.
event = wh.verify(req.body, {
'svix-id': req.headers['svix-id'],
'svix-timestamp': req.headers['svix-timestamp'],
'svix-signature': req.headers['svix-signature'],
})
} catch {
return res.sendStatus(400) // Not from us. Do not process.
}
// 2. Acknowledge immediately — before doing any real work.
res.sendStatus(200)
// 3. Queue every verified attempt with its verified, signed attempt timestamp.
queue.push({ event, svixTimestamp: req.headers['svix-timestamp'] })
})
Acknowledge first, process second
Akedly waits 10 seconds for a response. If your handler writes to a database, calls another service, or sends an email before replying, a slow dependency turns into a failed delivery and a retry — and eventually into a lost event. Reply 200, then work. After signature verification, store each template.status_changed token with its svix-timestamp. Implement saveTokenIfNewer as one atomic database conditional update keyed by eventId: update only when the incoming verified timestamp is greater than the persisted one. A tie or an older timestamp keeps the existing token. A lock shared by all workers for that key is also safe; an in-memory read/compare/write can race. Save before deduplicating business work.
The envelope
Every event shares one outer shell. Only data changes between event types:
{
"eventId": "evt_9f2c1b7a4e5d6f8a0b1c2d3e4f5a6b7c",
"eventType": "message.status",
"eventVersion": "1",
"pipelineId": "68f1c2...",
"timestamp": "2026-07-23T12:34:56Z",
"data": { }
}
- Name
- eventId
- Type
- string
- Description
Unique per event and stable across retries. Use it as the idempotency key for business work. For
template.status_changed, retain the token from the greatest verifiedsvix-timestampbefore deduplicating, because a repeatedeventIdcan carry a replacement token.
- Name
- eventType
- Type
- string
- Description
A dotted namespace. Match the exact string; more types will be added over time.
- Name
- eventVersion
- Type
- string
- Description
"1"for every event, includingtemplate.status_changed.
- Name
- pipelineId
- Type
- string
- Description
Which pipeline emitted the event. This is how you demultiplex when several pipelines POST to one URL. For
template.status_changedit is the pipeline that represents the callback URL — see that event below.
- Name
- timestamp
- Type
- string
- Description
When Akedly emitted the event. Not the same as the provider timestamps inside
data.
The outer shell is fixed by the webhook contract: event-specific fields live inside data, not at the top level.
The four events
message.status
A WhatsApp delivery transition.
{
"transactionId": "68f1c2...",
"wamid": "wamid.HBgLMjAx...",
"smsMessageId": null,
"templateId": "68e9a1...",
"phoneNumber": "+201234567890",
"status": "delivered",
"failureCode": null,
"failureReason": null,
"channel": "whatsapp",
"statusTimestamp": "2026-07-23T12:34:56Z"
}
wamid is Meta's message ID. The current customer utility status path is WhatsApp-only, so
smsMessageId is null here; a direct BYO-SMS leg emits no message.status event. A WhatsApp
attempt that later falls back to BYO SMS can still emit its own failed status, but that status
describes the WhatsApp attempt and does not by itself mean the overall request is terminal. The
fallback SMS leg emits no event. failureCode and failureReason are populated only when
status is failed.
BYO SMS sends do not produce delivery events
Utility SMS goes out through your own BYO provider, and Akedly ingests no delivery receipt from it — there is no route that accepts one — so a send is treated as final once your provider accepts it. So a BYO-SMS send emits no message.status event at all.
This applies to the SMS leg. When a WhatsApp attempt later uses the configured BYO-SMS fallback,
the failed WhatsApp attempt can still emit its message.status event; that event does not by itself
say that the overall request is terminal. The fallback SMS leg emits no delivery event.
statusTimestamp can be null
It is Meta's timestamp for the transition, not when Akedly received it. If Meta omits it, the
field is null rather than being back-filled with our ingest time. Guard before you parse it.
message.inbound
A free-text reply on WhatsApp.
{
"wamid": "wamid.HBgLMjAx...",
"contextWamid": null,
"transactionId": null,
"templateId": null,
"phoneNumber": "+201234567890",
"messageBody": "is my order shipped?",
"receivedAt": "2026-07-23T12:35:10Z",
"sourceKnown": false
}
messageBody is UTF-8 and may be Arabic — pass it through without transformation. receivedAt is Meta's timestamp. The null transactionId here is not a bug; see the next section.
button.action
A quick-reply button tap.
{
"wamid": "wamid.HBgLMjAx...",
"contextWamid": "wamid.HBgLMjAy...",
"transactionId": "68f1c2...",
"templateId": "68e9a1...",
"phoneNumber": "+201234567890",
"buttonId": "confirm_delivery",
"buttonLabel": "Confirm delivery",
"buttonPayload": "Yes, received",
"tappedAt": "2026-07-23T12:36:02Z",
"sourceKnown": true
}
template.status_changed
Meta approved or rejected a matched customer-owned Utilities template. This is Meta's status (the second of the two approval tracks), not Akedly's review status, and not on its own whether the template is sendable. Akedly sends it only for APPROVED and REJECTED; PENDING, PAUSED, DISABLED and any other Meta status produce no event.
Only matched Utilities templates emit this event
The status update must resolve to exactly one customer-owned Utilities template. The OTP template, an unknown template, and an ambiguous name or language fallback produce no customer event.
Requires a direct HTTPS callback URL
The body carries a bearer token, so Akedly sends this event only to a callback URL that uses HTTPS and does not follow redirects. A redirect response counts as a failed attempt. An HTTP callback URL receives no template status event and gets no delivery row. Point the pipeline's callback URL straight at your HTTPS endpoint.
{
"source": "meta",
"templateId": "68e9a1...",
"metaTemplateId": "1249873516203348",
"name": "order_shipped",
"language": "en",
"previousStatus": "PENDING",
"status": "APPROVED",
"reason": null,
"statusTimestamp": "2026-07-23T12:33:20Z",
"details": {
"method": "GET",
"path": "/api/v1/utilities/templates/details",
"token": "<fresh opaque token>"
}
}
- Name
- source
- Type
- string
- Description
Always
meta.
- Name
- templateId
- Type
- string
- Description
Akedly's id for the template — the value you send with and the one
message.statuscarries.
- Name
- metaTemplateId
- Type
- string or null
- Description
Meta's own id, falling back to the id stored on the template;
nullonly when neither exists.
- Name
- name
- Type
- string or null
- Description
The template name from Meta, falling back to the name stored on the template;
nullonly when neither exists.
- Name
- language
- Type
- string or null
- Description
The template language code from Meta, falling back to the language stored on the template;
nullonly when neither exists.
- Name
- previousStatus
- Type
- string or null
- Description
The Meta status before this change, for example
PENDINGorPAUSED, or any other value Meta uses.nullwhen Meta had never reported one.
- Name
- status
- Type
- string
- Description
The new Meta status:
APPROVEDorREJECTED. Other statuses produce no event.
- Name
- reason
- Type
- string or null
- Description
Meta's reason when it gives one, typically on
REJECTED;nullotherwise. It may be in your account's business locale; pass it through.
- Name
- statusTimestamp
- Type
- string or null
- Description
Meta's time for the change, or
nullwhen Meta sent no usable time. Not the envelopetimestamp, which is when Akedly staged the event.
- Name
- details.method
- Type
- string
- Description
The lookup method. Always
GET.
- Name
- details.path
- Type
- string
- Description
The relative path to Fetch template details.
- Name
- details.token
- Type
- string
- Description
A one-use bearer credential for Fetch template details. It is valid for five minutes and scoped to this template and delivery. Treat it as opaque.
Order on statusTimestamp, not on arrival
Akedly applies a transition only when Meta's time is strictly newer than the last one it applied, so a late or repeated notification produces no event. A newer change back to an earlier status still does. If a template is approved, then paused, then approved again, the pause sends nothing and the second approval arrives as its own event with previousStatus: "PAUSED". When Meta sends no usable time, the change is applied without that fence, except an untimed PENDING never overwrites APPROVED or REJECTED. On your side, deduplicate on eventId, and when statusTimestamp is present, order on it rather than on when the POST arrived.
Who receives it
The event is not tied to a send, so it goes to every eligible callback URL on that account:
non-sandbox utility pipelines that have a direct HTTPS callback URL. Pipelines that share a URL get one
delivery, signed with the secret of the lowest pipeline id among them; that pipeline is the
envelope's pipelineId, so verify with its whsec_ secret. Nothing is replayed: adding a callback
URL does not send a template's current status, and a URL added after a change does not receive that
change.
Every retry carries a new token
Each token is minted immediately before its POST and expires five minutes after minting. A retry keeps the eventId but carries a new details.token, so the body and signature change and the previous token stops working. A 2xx normally stops further attempts, but delivery is at least once, so a duplicate can still arrive. For the same eventId, retain the token with the greatest verified, signed svix-timestamp before deduplicating business work. Use one atomic database conditional update comparing with the persisted timestamp (or a lock shared by all workers for that key). Write only when the incoming timestamp is greater, and keep the existing token on a tie. A plain read/compare/write can race. The token may expire before a queued worker redeems it.
The body contains a credential
Keep raw template.status_changed bodies and details.token values out of logs, analytics and error trackers. Do not put the token in a URL.
This event is delivered on its own schedule, not the message-event one — see Delivery, retries and failure.
Fetch template details
Exchange the details.token from a template.status_changed webhook for the template's full, customer-safe details: body, header, footer, buttons, variables with examples, channels, both statuses, category and Meta's submission error. The response shows the template as it is when the GET succeeds, not a snapshot from the status change. The bearer token is the only credential: no API key, request signing or customer session is needed.
- Name
- Authorization
- Type
- string
- Description
Bearerfollowed bydata.details.tokenfrom thetemplate.status_changedwebhook.
A successful response has status: "success" and the allowlisted template fields under data:
- Name
- data._id
- Type
- string
- Description
- Akedly's template id.
- Name
- data.name
- Type
- string
- Description
- The template name.
- Name
- data.language
- Type
- string
- Description
- The template language code.
- Name
- data.body
- Type
- string
- Description
- The complete message body.
- Name
- data.header
- Type
- object, optional
- Description
- The text header, when present.
- Name
- data.footer
- Type
- string, optional
- Description
- The footer, when present.
- Name
- data.buttons
- Type
- array, optional
- Description
- The template buttons, when present.
- Name
- data.variables
- Type
- array
- Description
- Variable definitions with
nameand optionalexample,fallbackandmaxLengthvalues.
- Name
- data.usableOn
- Type
- object
- Description
- Whether the template is usable on SMS and WhatsApp.
- Name
- data.parameterFormat
- Type
- string
- Description
NAMEDorPOSITIONAL.
- Name
- data.akedlyStatus
- Type
- string
- Description
- Akedly's template review status.
- Name
- data.metaStatus
- Type
- string or null
- Description
- Meta's template status.
- Name
- data.metaCategory
- Type
- string, optional
- Description
- The category Meta assigned, when present.
- Name
- data.suggestedCategory
- Type
- string, null or absent
- Description
- A category Meta suggested, when present.
- Name
- data.whatsappSubmitError
- Type
- string, null or absent
- Description
- Meta's latest submission or rejection detail, when present.
- Name
- data.whatsappResubmittable
- Type
- boolean
- Description
- Whether the template can be submitted to Meta again.
- Name
- data.rejectionReason
- Type
- string, null or absent
- Description
- Akedly's review reason, not Meta's rejection reason.
- Name
- data.sourceLibraryName
- Type
- string, null or absent
- Description
- The source library name, when present.
- Name
- data.createdAt
- Type
- string
- Description
- Creation time in ISO 8601 format.
- Name
- data.updatedAt
- Type
- string
- Description
- Last update time in ISO 8601 format.
Meta and Akedly reasons are separate
On a Meta rejection, template.status_changed.data.reason carries Meta's reason. The GET response's data.whatsappSubmitError carries Meta's submission detail; GET data.rejectionReason is Akedly's review reason.
One read, five minutes
The first successful GET uses up the token. If the detail-token index is unavailable, the endpoint returns a safe 503 before checking or consuming the token; retry the job promptly so it reads the current token within its five-minute lifetime. A rate-limited request returns retryable 429 Too Many Requests with Retry-After; the handler above copies that value to error.retryAfter, which your queue should use to schedule the retry. The job then reads the current token again; retry only while it remains within its five-minute lifetime. Once the index is ready, a missing, malformed, used, expired or superseded token, a deleted template, or a deleted or ineligible representative pipeline returns the same 404. The same response applies if that pipeline changes owner or its callback URL changes, so it does not reveal whether a template exists. If a 404 races with a newer retry for the same event, read the stored token again and retry once only when it differs; if it is unchanged or the retry also returns 404, stop automatic retries and check whether another worker already saved the template details before opening the template in Utilities → Templates. If a 200 is lost in transit, the token may already be spent. Retry transient 5xx responses promptly while the token is still valid.
Responses are sent with Cache-Control: no-store, private. Do not cache them in a shared proxy.
Which send does a reply belong to
When someone replies you usually want to know what they are replying to. WhatsApp only tells us that when the user explicitly replied to a specific message, so Akedly reports honestly rather than guessing. Both inbound events carry sourceKnown:
| Value | Meaning | transactionId / templateId |
|---|---|---|
true | Linked to a specific send you made | Populated |
false | Could not be linked to any send | Both null |
sourceKnown: false means unproven origin — not spam
It is not a quality signal and says nothing about the sender's intent. A customer who opens WhatsApp and types a question — rather than long-pressing your message and hitting reply — produces false every single time. That is ordinary behaviour, and routing those messages to a junk queue will lose real customer questions.
Why we don't guess. Attribution comes strictly from Meta's context.id. The tempting fallback — "attribute it to the most recent send to this number" — quietly breaks: a customer replying three days later gets stitched onto an unrelated template, and you cannot distinguish a correct attribution from a wrong one afterwards. An honest null is more useful than a plausible lie. If a heuristic works at your volume, apply it in your own handler where you know the trade-off.
Where unattributed replies are delivered. A WhatsApp thread belongs to a phone number, not a pipeline, so an unattributed inbound is account-scoped. Akedly resolves the account from the receiving WhatsApp Business Account and delivers to its utility pipelines that have a webhook URL, deduplicated by destination URL — if several pipelines share one backend you receive a single delivery, and the envelope's pipelineId is whichever pipeline signed it.
What buttons can and cannot tell you
Only quick-reply buttons reach your backend:
| Button type | Emits button.action? | Why |
|---|---|---|
| Quick reply | Yes | The tap sends a message back through the webhook |
| URL | No | Opens the link on the device — never reaches our servers |
| Call | No | Dials on the device — never reaches our servers |
URL and Call taps cannot be tracked
This is a WhatsApp constraint, not an Akedly limitation — there is no event to subscribe to and no setting that enables one. If you need click-through measurement, point the URL button at a link you control and measure the hit on your own server.
Stable ids instead of display text. A quick-reply button can carry an action label you set when authoring the template, so your handler switches on an id that survives a copy rewrite or a translation. Meta only echoes the button's text reliably, so Akedly matches that text against the stored template locally and substitutes your configured actionId and actionLabel. Up to three buttons per template may carry one.
When nothing matches — an unattributed tap, a reworded button, or one with no label configured — the event still fires, falling back to Meta's raw text and payload. Enrichment never costs you the event, so write your handler to tolerate the fallback:
const action = event.data.buttonId ?? event.data.buttonPayload
Delivery, retries and failure
Akedly POSTs the event and waits up to 10 seconds. Any 2xx means delivered. Any other status, a timeout, or a connection error counts as a failed attempt and triggers a retry.
Two schedules apply. The three message events retry quickly, in the process that sent them; template.status_changed is staged durably and retried for about a day by a background worker.
Message events: five attempts in about two and a half minutes
| Attempt | Sent after |
|---|---|
| 1 | Immediately |
| 2 | 1 second |
| 3 | 5 seconds |
| 4 | 25 seconds |
| 5 | 125 seconds |
After five attempts a message event is dropped
For message.status, message.inbound and button.action the whole retry window is about two and a half minutes. There is no dead-letter queue and no manual replay — a delivery that exhausts its attempts is marked failed and is gone. If an outage outlasts that window you will have a hole in your event history, so treat webhooks as a fast path and reconcile important state from the API rather than relying on them as your only record.
Worse than simply stopping: the record that this event was already attempted lives for 7 days. So for that week the same occurrence will not be re-queued even if it happens again identically. Once attempts are exhausted, assume the event is not coming and reconcile — do not wait for it.
Template status: seven attempts over about a day
A template.status_changed delivery is written to storage first and picked up by a worker that runs about once a minute, so the first POST normally lands within about a minute after Akedly accepts and stages the transition, subject to the worker backlog, and the schedule survives a restart. If an attempt fails, the rest follow this schedule:
| Attempt | Sent after |
|---|---|
| 1 | Within about a minute after Akedly accepts and stages the transition, subject to the worker backlog |
| 2 | 1 minute |
| 3 | 5 minutes |
| 4 | 25 minutes |
| 5 | 2 hours 5 minutes |
| 6 | 6 hours 15 minutes |
| 7 | 15 hours |
What is frozen, and what is not
The eventId and destination are fixed when the event is staged and reused on every attempt. The body is not: each attempt carries a new details.token, so the body and signature change. Each attempt also gets fresh svix-id, svix-timestamp and svix-signature headers, so verify every attempt and deduplicate on eventId. A 2xx normally stops later attempts, but delivery is at least once and a duplicate may still arrive. The destination is checked before each attempt: if the representative pipeline is deleted, stops being eligible, has its owner changed, or gets a new callback URL before the next attempt, including between retries, the old URL may have been called on earlier attempts but is not called again, and the delivery ends as failed with destination changed. After the seventh failed attempt — about 23 hours 51 minutes after the first — the delivery is failed for good; there is no replay.
Return 2xx the moment the signature checks out, even if downstream processing later fails. A non-2xx triggers a retry; a 2xx normally stops later attempts, though at-least-once delivery means a duplicate can still arrive.
Verifying it came from Akedly
Your endpoint is on the public internet, so anyone who learns the URL can POST something shaped like a real event. Every utility webhook therefore carries three headers:
- Name
- svix-id
- Type
- string
- Description
A unique id for this message —
msg_followed by 32 hex characters. Akedly mints a new value for each delivery attempt, including retries. Do not use it for deduplication; use the envelope's stableeventIdinstead.
- Name
- svix-timestamp
- Type
- string
- Description
When we sent it, in Unix seconds. Reject anything too far from your own clock to blunt replay attempts.
- Name
- svix-signature
- Type
- string
- Description
The signature itself, formatted
v1,<base64>. More than one space-separated value can appear during a secret rotation — treat a match on any of them as valid.
The signed value is the first two headers joined to the raw body with full stops:
svix-id . svix-timestamp . raw request body
That string is hashed with HMAC-SHA256 keyed on your whsec_ secret, base64-decoded after stripping the prefix. This is the Standard Webhooks scheme, so any official Svix library does it in one call — as in the handler above.
Verify the raw bytes, not a re-encoded object
The signature covers the exact bytes we sent. If your framework parses the JSON and you re-encode it before checking, key order or spacing can shift and every signature fails. Capture the raw body first — express.raw({ type: 'application/json' }) on the webhook route, not express.json().
This is the same scheme, and the same whsec_ secret, that signs V2 widget webhooks — so verification code written for one works for the other unchanged. Ready-made verifiers in Node, Python, PHP, Go and Ruby are on the Authentication webhooks page; they are kept in one place rather than copied here so the two cannot drift apart.
Try it hands-on
Pick one of the four events to see its real payload, generate a live signature with a demo secret, and watch what happens when your server stops answering, on either retry schedule. Everything below runs in your browser; nothing you type is sent anywhere.
What happened to a WhatsApp attempt. A later BYO-SMS fallback leg emits no status event. Every event uses the same outer envelope — only the highlighted data block changes.
{"eventId": "evt_9c1f4b7ea2d84306b5e07c19af3d6b28","eventType": "message.status","eventVersion": "1","pipelineId": "6a4b6596d3653633b086117b","timestamp": "2026-08-04T09:12:41.000Z","data": {"transactionId": "8bc469dd1e06d2b711892d95648c90a8","wamid": "wamid.HBgMMjAxMjM0NTY3ODkwFQIAERgSMTUwN0VF","smsMessageId": null,"templateId": "6a5d588964b5bec0107f1568","phoneNumber": "+201234567890","status": "delivered","failureCode": null,"failureReason": null,"channel": "whatsapp","statusTimestamp": "2026-08-04T09:12:41.000Z"}}
transactionIdstringThe send this status belongs to.wamidstringWhatsApp message id.smsMessageIdstring · nullableNull on the current WhatsApp utility status path. BYO-SMS sends emit no status event.templateIdstring · nullableThe utility template used for the send, when present.phoneNumberstring · nullableRecipient, in E.164 format.statusstringsent · delivered · read · failed. sent and read are WhatsApp-only.failureCodestring · nullableOnly present when status is failed.failureReasonstring · nullableMeta's own wording. A failed WhatsApp attempt can precede a BYO-SMS fallback; only when failed.channelstringwhatsapp.statusTimestampstring · nullableOrder on this, not on arrival time.Test and debug
Each delivery/event is recorded per pipeline with a cumulative attempt count, its latest status, the last HTTP status code we saw, and the last error. This is a delivery summary, not a per-attempt history, and it is the first place to look when an event did not arrive. Template status deliveries are listed with the message events, under the pipeline that represents the callback URL. If that representative pipeline is deleted, its delivery row is no longer reachable through that pipeline's history.
Per-event delivery history: pending, delivered or failed, with attempt count and the last response from your endpoint.
Reading it:
- No row for a template change:
template.status_changedis recorded only for a MetaAPPROVEDorREJECTEDchange sent to a direct HTTPS callback URL. Other Meta statuses and HTTP callback URLs produce no delivery row. Check the template's Meta status in Utilities → Templates and the scheme of the pipeline's callback URL. - Template callback returns a redirect (
3xx): Akedly does not follow redirects. Each redirect response fails that attempt and is retried; the delivery stayspendinguntil the retry schedule ends. If the final attempt also redirects, it is markedfailedwithRequest failed with status code 307inlastError(or the matching3xxstatus). Point the callback URL straight at the final HTTPS endpoint. failedwith a4xx— we reached you and you rejected it. A400usually means signature verification failed: check you are verifying the raw body, not a re-serialised JSON object.failedwith a5xx— your handler threw. Look for work happening before the acknowledgement.failedwith no status code — we never got a response. An unreachable host, TLS failure, or a request slower than the 10-second timeout causes this. Fortemplate.status_changed, token issuance or persistence can also fail before an attempt's POST; that attempt does not reach your endpoint, although earlier attempts may have. In that case,lastErrorcan beUnable to issue template detail tokenorUtility template detail token persistence failed.failedwithdestination changed— atemplate.status_changeddelivery whose representative pipeline was deleted, became ineligible, had its owner changed, or got a new callback URL before the next attempt, including between retries. The old URL may have been called on prior attempts, but it is not called again, and the new one only receives future changes.deliveredbut nothing happened on your side — you acknowledged and then dropped it. Check your dedupe store isn't discarding first-timeeventIds.
