Webhooks
A webhook is how Akedly tells your server that something happened — a message was delivered, a customer replied, a verification finished. You give us a URL, and we send a message to it.
What is a webhook?
Normally you call us: you send a request, we send a response. A webhook is the other direction — something happens on our side, and we call you.
The alternative is polling: asking us "has it delivered yet?" every few seconds. That is slow and wasteful. With a webhook you find out the moment it happens.
In practice a webhook is just an HTTP POST to a URL you own. If you can handle a form submission, you can handle a webhook:
A minimal endpoint
app.post('/webhooks/akedly', (req, res) => {
res.sendStatus(200) // 1. say "got it" immediately
handle(req.body) // 2. then do your work
})
Answer first, work afterwards
Reply 200 as soon as the message arrives, then do your processing. We give up
on a request after 10 seconds. Utility deliveries retry after a failed
attempt; V2 Widget and OTP API authentication callbacks are single-delivery, so
reconcile important outcomes from trusted API state instead of relying on a
retry.
What Akedly can send you
Authentication has two webhook paths and they do not behave the same way. Which applies depends on how you integrate.
| You are using | You get | Signed? | Retried? | History? |
|---|---|---|---|---|
| V2 Widget | The verification result | Usually | No | No |
| OTP API (V1.0 / V1.2) | The verification result | No | No | No |
Both send once. There is no retry and no delivery record on either, so neither should be your only record of a verification — confirm anything that matters from the API. For idempotency, use a stable identifier from the payload: transactionID in the V1/V1.2 mainTransaction object, or widgetAttempt.attemptId for V2.
Sending utility messages instead?
Utility webhooks are a different and considerably stronger contract — always signed, retried five times, with a delivery log you can inspect. They have their own page: Utility Webhooks.
OTP API results are not signed
On the OTP REST API your callbacks arrive with no svix-id, svix-timestamp or svix-signature headers. Do not build a handler that rejects unsigned requests — it will reject every real result. Verify by checking the transactionID against your own records, and treat the URL as guessable rather than secret.
The V2 Widget can occasionally send unsigned
Widget results are normally signed with the same secret, but unlike Utilities the widget does not refuse to send when signing fails. Treat a missing signature as "cannot verify", not as proof of forgery.
Setting up your endpoint
Your webhook URL lives on the pipeline, in its Webhooks section. Paste a URL, save, and we start sending. Leave it empty and we send nothing.
The same section holds your signing secret — the value you need to prove a message came from us.
Use HTTPS even though HTTP is accepted
The dashboard asks for an HTTPS endpoint, and a plain http:// URL is in fact accepted too. Send them over HTTPS anyway: webhook bodies carry personal data, and on the OTP API the verification code itself.
Try it before you have traffic
The dashboard can fire a sample request at your URL and show you exactly what your server replied — useful long before any real traffic exists.
Open Test your endpoint in the Webhooks section, check the headers and body we will send, press Send, and the response panel shows your server's live answer.
The test body is not a real body
The sample begins with "event": "webhook.test" and "test": true. Those markers do not appear in ordinary V1, V1.2 or V2 Widget callbacks. Utility pipeline test sends are the exception: the dashboard sends a real-shaped message.status envelope instead. Use the test to prove your server is reachable and answers 200 — but write your parsing against the real payloads, not against this sample.
OTP callbacks contain the verification code
On the OTP API the payload includes an OTP field holding the actual code — visible in the sample above — and the request is unsigned. Treat these callbacks as sensitive: use HTTPS, keep the raw body out of your logs, and do not forward it to third-party tooling.
Scope: signed callbacks
Signing applies to V2 Widget backend callbacks and to utility pipeline webhooks such as
message.status, message.inbound, and button.action. The direct OTP callback path used by
V1 / V1.2 does not pass a signing secret to the dispatcher, so it carries no svix-* headers;
treat that delivery as a UX signal and confirm the outcome independently (for V1.2 passkeys,
verify the signed resultToken offline instead).
Why webhooks are signed
Your callback URL lives on the public internet. Anyone who learns it — through logs, browser network tabs, mistyped DNS, a leaked screenshot — can POST forged payloads to it claiming a verification succeeded. Without a signature check, your application has no way to tell a real Akedly webhook from a fake one.
Signing solves this. Akedly hashes the request body together with a timestamp and a unique message ID, using a secret only you and Akedly know. You re-compute the hash on your side; if it matches, the request is genuine and untampered.
Only one of the two Auth paths is signed
The V2 widget is signed in almost all cases, but OTP API callbacks are never signed — there is nothing to verify on that path, and a handler that rejects unsigned requests will drop every real result. Check the table above before you build a verifier.
Where to find your secret
Each pipeline has its own signing secret, prefixed with whsec_. To retrieve it:
- Open your pipeline in the dashboard
- Scroll to the Webhooks section
- Click View signing secret
View-once
The secret is shown one time only. Copy it into your secret manager, select I've saved this secret somewhere safe, then click Done. After you acknowledge it, the dashboard will refuse to show it again. If you lose it, click Regenerate to mint a new one (this invalidates the old secret).
The secret looks like this:
Example secret
whsec_kU3+tF2dG8XzL9pQrSvT4Bh6jYwMnRb1cZxK7eNoAi0=
Treat it like a password. Store it in your environment, never commit it, and rotate it if you suspect exposure.
The signed headers
Every signed webhook arrives with three headers:
Example headers
svix-id: msg_2N4kJpQrSvT4Bh6jYwMnRb1cZx
svix-timestamp: 1745190600
svix-signature: v1,kU3+tF2dG8XzL9pQrSvT4Bh6jYwMnRb1cZxK7eNoAi0=
The svix-signature value contains one or more space-separated entries, each in the form v1,<base64>.
Webhook contract
- Name
svix-id- Type
- header
- Description
- A fresh
msg_*identifier is generated for each dispatcher call. Utility retries therefore receive a new value on each attempt.
- Name
svix-timestamp- Type
- Unix seconds
- Description
- The signing time converted to a Unix timestamp in seconds.
- Name
svix-signature- Type
- header
- Description
- The signature returned by Svix
Webhook.signover the message ID, timestamp, and serialized payload.
- Name
Signed body- Type
- JSON string
- Description
- Akedly signs
JSON.stringify(payload)and sends that same string as the POST body.
- Name
Generated-header precedence- Type
- server-controlled
- Description
- Customer-supplied svix-*, content-type, user-agent, host, and content-length headers are filtered before Akedly adds its generated headers.
- Name
HTTP timeout- Type
- 10 seconds
- Description
- Each common webhook POST uses a 10-second Axios timeout.
- Name
V2 Widget signing- Type
- signed
- Description
- The widget callback path passes the pipeline webhook signing secret to the common dispatcher.
- Name
V2 Widget retries- Type
- single dispatcher call
- Description
- The V2 callback path has no application-level retry loop; retry behavior outside this path is not defined by this contract.
- Name
Utility signing- Type
- required
- Description
- Utility delivery calls the common dispatcher with signature required.
- Name
Utility retries- Type
- up to 5 attempts
- Description
- A utility delivery retries failed dispatches up to five times.
- Name
Retry backoff- Type
- 1s, 5s, 25s, 125s
- Description
- The default delays between utility attempts are one, five, 25, and 125 seconds.
- Name
Dedupe key- Type
- unique for 7 days
- Description
- Dispatch admission claims
dedupeKeyagainst a unique index with a seven-day TTL. Status triggers usemessage.status:<channel>:<pipeline>:<message-or-transaction>:<status>; inbound and button triggers use<eventType>:<pipeline>:<wamid>.
- Name
Stable event ID- Type
- eventId
- Description
- The envelope creates one
eventIdthat is reused across retries; the per-attemptsvix-idis not the stable deduplication identifier.
- Name
Delivery history- Type
- owner-scoped API
- Description
- Utility delivery records are available at
GET /api/v1/utilities/pipelines/:id/webhook-deliveries, newest first, with page and limit values clamped to 1–100 and a default limit of 20.
| Event type | Data fields |
|---|---|
| message.status | transactionId, wamid, smsMessageId, templateId, phoneNumber, status, failureCode, failureReason, channel, statusTimestamp |
| message.inbound | wamid, contextWamid, transactionId, templateId, phoneNumber, messageBody, receivedAt, sourceKnown |
| button.action | wamid, contextWamid, transactionId, templateId, phoneNumber, buttonId, buttonLabel, buttonPayload, tappedAt, sourceKnown |
Signing algorithm
If you're using one of the official Svix SDKs (next section), you don't need to implement this — it's here for reference and for any stack without an SDK.
Algorithm
1. Decode the secret:
key = base64_decode( secret.replace("whsec_", "") )
2. Build the signed payload:
signed = "${svix-id}.${svix-timestamp}.${raw_body}"
3. Compute the expected signature:
expected = base64_encode( HMAC_SHA256(key, signed) )
4. Split svix-signature on spaces. For each entry, strip the `v1,` prefix before the
constant-time compare with the computed base64 value. If any match, the request is valid.
5. Reject if | svix-timestamp − now | > 5 minutes (replay protection).
The raw_body is the unparsed request body bytes — don't JSON.parse then re-serialize, or you'll lose the exact byte sequence the signature was computed over.
Verify in your stack
Pick your language. The signing algorithm is identical across all of them — these snippets just wrap the math.
Verify webhook signature
// npm install svix
const { Webhook } = require('svix')
const wh = new Webhook(process.env.AKEDLY_WEBHOOK_SECRET)
// rawBody must be the unparsed string body — not req.body (parsed JSON).
// In Express, mount express.raw({ type: 'application/json' }) on this route.
wh.verify(rawBody, req.headers) // throws WebhookVerificationError if invalid
Common pitfalls
Three things that catch most integrators
1. Forgetting to base64-decode the secret. The whsec_ prefix isn't decorative — strip it, then base64-decode the rest. The decoded bytes are the actual HMAC key. Hashing with the prefixed string directly will never validate.
2. Hashing the raw body alone. The signed string is "${id}.${timestamp}.${body}", not body. Skip the prefix and your signature won't match.
3. Reading req.body after framework parsing. Express's default JSON parser reads the stream and exposes parsed JSON on req.body — the raw bytes are gone. Mount express.raw({ type: 'application/json' }) on your webhook route (or its equivalent in your framework) so the unparsed bytes are still available for verification.
Rotating the secret
If a secret leaks — or just on a routine schedule — click Regenerate in the pipeline's Webhooks section. Akedly issues a new whsec_... and immediately starts signing webhooks with it.
Plan for the cutover
There's no overlap window. The moment you regenerate, every subsequent webhook is signed with the new secret. Update your environment with the new value and redeploy before the next webhook fires, or your verifier will reject good payloads.
If you can't deploy instantly, deploy a temporary dual-secret check (try old, then new) ahead of the rotation, then drop the old one once traffic settles.
