Skip to content
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, 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.

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.

WhenYou receive
Akedly hands the message to WhatsAppmessage.status — sent
It reaches the recipient's devicemessage.status — delivered
They open it (WhatsApp only, and only if they allow read receipts)message.status — read
A WhatsApp attempt could not be deliveredmessage.status — failed, with a reason
They type a replymessage.inbound
They tap a quick-reply buttonbutton.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.

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:

FamilyWhat it coversSent to your endpoint?
Message eventsDelivery status, replies, button taps — message.status, message.inbound, button.actionYes
Account events: template statusA template approved or rejected by Meta — template.status_changedYes, to direct HTTPS callback URLs. This is the one account event that is forwarded.
Account events: everything elseOther template status changes (such as pending, paused or disabled), template category changes, phone-number quality, WhatsApp Business Account review, business capability updatesNo — 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.

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:

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. Queue every verified attempt with its verified, signed attempt timestamp.
  queue.push({ event, svixTimestamp: req.headers['svix-timestamp'] })
})

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 stable across retries. Use it as the idempotency key for business work. For template.status_changed, retain the token from the greatest verified svix-timestamp before deduplicating, because a repeated eventId can 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, including template.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_changed it 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.

data

{
  "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.

message.inbound

A free-text reply on WhatsApp.

data

{
  "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.

data

{
  "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.

data

{
  "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.status carries.

  • Name
    metaTemplateId
    Type
    string or null
    Description

    Meta's own id, falling back to the id stored on the template; null only when neither exists.

  • Name
    name
    Type
    string or null
    Description

    The template name from Meta, falling back to the name stored on the template; null only 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; null only when neither exists.

  • Name
    previousStatus
    Type
    string or null
    Description

    The Meta status before this change, for example PENDING or PAUSED, or any other value Meta uses. null when Meta had never reported one.

  • Name
    status
    Type
    string
    Description

    The new Meta status: APPROVED or REJECTED. Other statuses produce no event.

  • Name
    reason
    Type
    string or null
    Description

    Meta's reason when it gives one, typically on REJECTED; null otherwise. 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 null when Meta sent no usable time. Not the envelope timestamp, 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.

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

    Bearer followed by data.details.token from the template.status_changed webhook.

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 name and optional example, fallback and maxLength values.
  • Name
    data.usableOn
    Type
    object
    Description
    Whether the template is usable on SMS and WhatsApp.
  • Name
    data.parameterFormat
    Type
    string
    Description
    NAMED or POSITIONAL.
  • 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.

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:

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:

js
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

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

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:

AttemptSent after
1Within about a minute after Akedly accepts and stages the transition, subject to the worker backlog
21 minute
35 minutes
425 minutes
52 hours 5 minutes
66 hours 15 minutes
715 hours

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

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.HBgMMjAxMjM0NTY3ODkwFQIAERgSMTUwN0VF",
"smsMessageId": null,
"templateId": "6a5d588964b5bec0107f1568",
"phoneNumber": "+201234567890",
"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.
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.

Reading it:

  • No row for a template change: template.status_changed is recorded only for a Meta APPROVED or REJECTED change 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 stays pending until the retry schedule ends. If the final attempt also redirects, it is marked failed with Request failed with status code 307 in lastError (or the matching 3xx status). Point the callback URL straight at the final HTTPS endpoint.
  • 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. An unreachable host, TLS failure, or a request slower than the 10-second timeout causes this. For template.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, lastError can be Unable to issue template detail token or Utility template detail token persistence failed.
  • failed with destination changed — a template.status_changed delivery 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.
  • delivered but nothing happened on your side — you acknowledged and then dropped it. Check your dedupe store isn't discarding first-time eventIds.