Akedly

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.

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.

WhenYou receive
Akedly hands the message to WhatsAppmessage.statussent
It reaches the recipient's devicemessage.statusdelivered
They open it (WhatsApp only, and only if they allow read receipts)message.statusread
It could not be deliveredmessage.statusfailed, with a reason
They type a replymessage.inbound
They tap a quick-reply buttonbutton.action

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:

FamilyWhat it coversSent to your endpoint?
Message eventsDelivery status, replies, button taps — the three events on this pageYes
Account eventsTemplate approved / rejected, template category changed, phone-number quality, WhatsApp Business Account review, business capability updatesNot yet — dashboard only

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 → Pipelines

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.

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)
})

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.

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:

ValueMeaningtransactionId / templateId
trueLinked to a specific send you madePopulated
falseCould not be linked to any sendBoth null

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 typeEmits button.action?Why
Quick replyYesThe tap sends a message back through the webhook
URLNoOpens the link on the device — never reaches our servers
CallNoDials on the device — never reaches our servers

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.

AttemptSent after
1Immediately
21 second
35 seconds
425 seconds
5125 seconds

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 stable eventId instead.

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

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.

POST to your endpoint
{
"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"
}
}
Inside data — message.status
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 deliveries

Per-event delivery history: pending, delivered or failed, with attempt count and the last response from your endpoint.

Reading it:

  • failed with a 4xx — we reached you and you rejected it. A 400 usually means signature verification failed: check you are verifying the raw body, not a re-serialised JSON object.
  • failed with a 5xx — your handler threw. Look for work happening before the acknowledgement.
  • failed with no status code — we never got a response. Unreachable host, TLS failure, or slower than the 10-second timeout.
  • delivered but nothing happened on your side — you acknowledged and then dropped it. Check your dedupe store isn't discarding first-time eventIds.

Was this page helpful?