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, and anything the recipient sends back. This is very nearly a WhatsApp-only story. A BYO-SMS send — the route every utility SMS takes today — produces no post-send events: your provider's acceptance is the final word, so an integrator waiting on a webhook for one will wait forever. The single exception is legacy: a utility SMS that went out over Akedly's own Cequens route can still receive a delivery receipt, which does emit a status 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. This is the whole vocabulary for that channel — there is nothing else to subscribe to. None of the rows below fire for a BYO-SMS send: 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 a BYO-SMS send produces no message.status, no message.inbound and no button event. A legacy Cequens-routed utility SMS is the one exception — it can still emit message.status (delivered/failed only) from a carrier delivery receipt. See The three 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 |
| It could not be delivered | message.status — failed, with a reason |
| They type a reply | message.inbound |
| They tap a quick-reply button | button.action |
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, only one is 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 — the three events on this page | Yes |
| Account events | Template approved / rejected, template category changed, phone-number quality, WhatsApp Business Account review, business capability updates | Not yet — dashboard only |
Account events are visible but not delivered — yet
Akedly receives every account event from Meta and records it, so you can see it in the Account events tab. What does not happen is forwarding: none of them are POSTed to your webhook URL, so there is nothing to subscribe to and no payload to code against today.
This matters most for template rejections. A rejected template stops your sends from working, and no webhook will tell you — you have to be watching that tab. If forwarding these would help you, tell us; it is a known gap rather than a deliberate omission.
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.
View in dashboardUtilities → PipelinesSet 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.
Write the handler
Four responsibilities, in this order. Getting the order wrong is the most common cause of lost events:
Node / Express
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. Drop anything you have already handled.
if (seen(event.eventId)) return
remember(event.eventId)
// 4. Now do the slow part, off the request.
queue.push(event)
})
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.
The envelope
Every event shares one outer shell. Only data changes between event types:
Envelope
{
"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 your idempotency key. Deduplicate on it — the same event can arrive more than once.
- Name
eventType- Type
- string
- Description
A dotted namespace. Match the exact string; more types will be added over time.
- Name
eventVersion- Type
- string
- Description
Always
"1"today — the value is fixed in the backend and there is no version negotiation, so no"2"event exists and nothing you can send changes it. Match on the exact string. How a future breaking change would be delivered is not settled, so do not build on an assumption either way; watch this page.
- Name
pipelineId- Type
- string
- Description
Which pipeline emitted the event. This is how you demultiplex when several pipelines POST to one URL.
- Name
timestamp- Type
- string
- Description
When Akedly emitted the event. Not the same as the provider timestamps inside
data.
The outer shell is fixed in the backend as it stands today: event-specific fields live inside data, not at the top level. Nothing negotiates or enforces that across versions, so treat it as the current shape rather than a promise — read eventVersion, and watch this page.
The three events
message.status
A delivery transition. In practice this means WhatsApp — see the channel note below.
data
{
"transactionId": "68f1c2...",
"wamid": "wamid.HBgLMjAx...",
"smsMessageId": null,
"templateId": "68e9a1...",
"phoneNumber": "+201017438478",
"status": "delivered",
"failureCode": null,
"failureReason": null,
"channel": "whatsapp",
"statusTimestamp": "2026-07-23T12:34:56Z"
}
wamid is Meta's message ID and smsMessageId is the SMS provider's. Read the event's own channel field to tell them apart rather than inferring it from which ID is null: a WhatsApp status carries wamid with smsMessageId null, and a legacy Cequens-routed utility SMS carries smsMessageId with wamid null. Do not treat smsMessageId === null as an invariant — a BYO-SMS send emits no status events at all, but a Cequens one does. 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.
One narrow exception survives: a utility SMS carried by Akedly's legacy Cequens route still has a DLR handler, and it emits message.status with channel: "sms" and smsMessageId set. Only delivered and failed can arrive that way — sent and read remain WhatsApp-only in every case. New utility SMS does not use that route, so treat it as something to tolerate in your handler rather than something to build on. If you need SMS delivery tracking today, take it from your provider's own dashboard or callbacks — not from here.
statusTimestamp can be null
It is the provider's timestamp for the transition, not when we received it — Meta's on a WhatsApp status, the carrier's on a legacy Cequens SMS receipt. If the provider 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.
data
{
"wamid": "wamid.HBgLMjAx...",
"contextWamid": null,
"transactionId": null,
"templateId": null,
"phoneNumber": "+201017438478",
"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.
data
{
"wamid": "wamid.HBgLMjAx...",
"contextWamid": "wamid.HBgLMjAy...",
"transactionId": "68f1c2...",
"templateId": "68e9a1...",
"phoneNumber": "+201017438478",
"buttonId": "confirm_delivery",
"buttonLabel": "Confirm delivery",
"buttonPayload": "Yes, received",
"tappedAt": "2026-07-23T12:36:02Z",
"sourceKnown": true
}
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.
| Attempt | Sent after |
|---|---|
| 1 | Immediately |
| 2 | 1 second |
| 3 | 5 seconds |
| 4 | 25 seconds |
| 5 | 125 seconds |
After five attempts the event is dropped
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.
Return 2xx the moment the signature checks out, even if downstream processing later fails. A 500 from your own queue tells Akedly to resend an event you have already accepted.
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:
What gets hashed
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 an event to see its real payload, generate a live signature with a demo secret, and watch what happens when your server stops answering. Everything below runs in your browser — nothing you type is sent anywhere.
What happened to a message you sent. 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.HBgMMjAxMDE3NDM4NDc4FQIAERgSMTUwN0VF","smsMessageId": null,"templateId": "6a5d588964b5bec0107f1568","phoneNumber": "+201017438478","status": "delivered","failureCode": null,"failureReason": null,"channel": "whatsapp","statusTimestamp": "2026-08-04T09:12:41.000Z"}}
transactionIdstringThe send this status belongs to.wamidstring · nullableWhatsApp message id. Null on SMS.smsMessageIdstring · nullableYour SMS provider's id. Null on WhatsApp.templateIdstring · nullableNull for a free-text SMS send.phoneNumberstring · nullableRecipient, in E.164 format.statusstringsent · delivered · read · failed. sent and read are WhatsApp-only.failureCodestring · nullableOnly present when status is failed.failureReasonstring · nullableThe provider's own wording — Meta, or the carrier on a legacy Cequens SMS. Only when failed.channelstringwhatsapp or sms.statusTimestampstring · nullableOrder on this, not on arrival time.Test and debug
Every attempt is recorded per pipeline with its status, attempt count, the last HTTP status code we saw, and the last error. That log is the first place to look when an event did not arrive:
View in dashboardUtilities → Pipelines → Webhook deliveriesPer-event delivery history: pending, delivered or failed, with attempt count and the last response from your endpoint.
Reading it:
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. Unreachable host, TLS failure, or slower than the 10-second timeout.deliveredbut nothing happened on your side — you acknowledged and then dropped it. Check your dedupe store isn't discarding first-timeeventIds.
