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:
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, and a final reply that is not 2xx counts as a failure. Verification results (V1, V1.2 and V2) are saved and retried for about 8½ hours — see Retries. OTP channel events are saved and retried up to five times within a few minutes. Utility message events (message.status, message.inbound, button.action) use up to five attempts run in the sending process, and utility template.status_changed deliveries up to seven attempts over about 23 hours 51 minutes. Reconcile important outcomes from trusted API state instead of treating a retry as a delivery guarantee. To check a result yourself, use a transaction status lookup and act on its data, not on an unsigned body.
Webhooks by API version
Every pipeline has one webhook URL — the Logging Webhook URL (backendCallbackURL) in its Webhooks section. What arrives at that URL depends on the API version the pipeline uses. Find your version, and you know every webhook you will get.
| API version | Webhook | Sent when | Signed? | Retried? |
|---|---|---|---|---|
| V2 Widget | Verification result | The user enters a code (right or wrong), or verifies with a passkey | Yes, almost always | Yes — up to 6 attempts over about 8½ hours |
| V1.2 REST | Verification result | The user's code is checked (right or wrong) | Yes, almost always | Yes — up to 6 attempts over about 8½ hours |
| V1.2 REST | otp.channels.resolved | Always, once the code has gone out — WhatsApp success included | Yes | Up to 5 attempts within a few minutes |
| V1.2 REST | otp.channels.updated | Only if WhatsApp fails after sending (the code moved to another channel, or to none). Not sent when WhatsApp succeeds | Yes | Up to 5 attempts within a few minutes |
| V1 REST | Same as V1.2 | The two otp.channels.* events only when the request uses waitForChannels: true | No for the verification result; yes for the channel events | Same as V1.2 |
Test-mode transactions (isTest: true) still get their verification result but never produce otp.channels.* events. Nothing is sent when the pipeline has no webhook URL. TOTP (authenticator-app) pipelines have their own unsigned, single-attempt callback and are not covered on this page.
Sending utility messages instead?
Utility webhooks are a different and considerably stronger contract — always signed, retried (up to five attempts for message events, up to seven over about a day for template status), with a delivery log you can inspect. They have their own page: Utility Webhooks.
Telling them apart on one URL
All of these arrive at the same URL, so route on the shape of the body before you do anything else:
app.post('/webhooks/akedly', express.raw({ type: 'application/json' }), (req, res) => {
let body
try {
body = JSON.parse(req.body)
} catch {
return res.sendStatus(400)
}
if (body.eventType?.startsWith('otp.channels.')) {
// V1 / V1.2 channel event — always signed: verify before you accept it
if (!verifyAkedlySignature(req)) return res.sendStatus(401)
res.sendStatus(200)
return handleChannelEvent(body.data)
}
const signed = Boolean(req.headers['svix-signature'])
if (signed && !verifyAkedlySignature(req)) return res.sendStatus(401)
res.sendStatus(200)
if (body.widgetAttempt) {
// V2 Widget verification result — normally signed
// An unsigned result cannot be verified. confirmThenHandleWidgetResult uses only
// widgetAttempt.attemptId from it: it runs an authenticated transaction status lookup
// and acts on the lookup's data, never on this body
return signed ? handleWidgetResult(body) : confirmThenHandleWidgetResult(body)
}
if (body.mainTransaction) {
// V1.2 verification result — normally signed; V1 — never signed
// An unsigned result cannot be verified. confirmThenHandleRestResult uses only
// mainTransaction.transactionID from it: it runs an authenticated transaction status
// lookup and acts on the lookup's data, never on this body
return signed ? handleRestResult(body) : confirmThenHandleRestResult(body)
}
})
verifyAkedlySignature(req) wraps the wh.verify(req.body, req.headers) call from Verify in your stack and returns false when it throws. A signed request is checked before you answer 200, so a forged signed request is never acknowledged. Unsigned results are acknowledged straight away but only acted on once the API confirms them. To confirm a result that is late or unsigned, use a transaction status lookup and act on the lookup's data, not on the unsigned body.
A retry can deliver something you already processed, so make your handler idempotent. Key on eventId for channel events. For verification results, key on fields in the body, which never change between attempts: widgetAttempt.attemptId + status + timestamp on V2, and mainTransaction.transactionID + mainTransaction.status + transactionReq.verificationDate on V1 and V1.2. The svix-id header is a fast path for signed retries of the same delivery, but not a complete key — a result that goes out unsigned, or through the rare single-send fallback, has no svix-id or a different one. See Retries. A status lookup is not a webhook copy and carries no svix-id; see Using lookups with webhooks.
V1 verification-result callbacks are not signed
On the V1 REST API the verification-result callback arrives with no svix-id, svix-timestamp or svix-signature headers. Do not build a handler that rejects this unsigned callback — it will reject every real result. First check that the transactionID is one you created, and treat the URL itself as guessable rather than secret. Then confirm the result with an authenticated transaction status lookup, and act on the lookup's answer, not on the callback body. V1.2 results are signed with your pipeline's secret; move to V1.2 if you need to verify results by signature.
V2 and V1.2 results can occasionally arrive unsigned
V2 Widget and V1.2 verification results are normally signed with the pipeline's secret, but unlike Utilities they are not withheld when signing fails. Treat a missing signature as "cannot verify", not as proof of forgery.
Retries
Verification results — V1, V1.2 and V2 — are saved before they are sent, so a restart on our side does not lose them.
- What counts as a failure: no answer within 10 seconds, a connection error, or a final reply that is not
2xx(a4xxor5xxis retried the same way). - Schedule: the first attempt goes out immediately. After a failure, the next attempt waits 1 minute, then 5 minutes, 30 minutes, 2 hours and 6 hours. That is six attempts over about 8½ hours. A retry normally starts within a minute of becoming due, later if we are catching up on a backlog.
- After the sixth failure we stop. Nothing more is sent for that result.
- The body never changes between attempts. It is the same bytes, including the original
timestamp. On signed V2 Widget and V1.2 results thesvix-idheader is also the same on every attempt; onlysvix-timestampandsvix-signatureare fresh. - One success webhook per attempt. A V2 attempt sends at most one
successresult. Each wrong code sends its ownfailedresult, and each one is retried on its own. - Results can arrive out of order. A
failedresult that has not been delivered yet is dropped once the same attempt (V2) or transaction (V1/V1.2) succeeds, but one already in flight can still land after the success. Once you have recorded a success for awidgetAttempt.attemptIdormainTransaction.transactionID, never let a laterfailed/Failedorpending/Pendingresult for the same id undo it on your record, whether it arrives by webhook or in a status lookup. - Changing or clearing the URL cancels pending retries. A retry is only sent to the URL the result was first sent to. If the pipeline's webhook URL has changed or been removed since, the retry is dropped instead of going to the old address.
- Your URL must answer directly. Point the pipeline at the final address. A redirect (for example
httptohttps, or a missing trailing slash) can turn the POST into a GET and lose the body.
If we cannot save a result because of a fault on our side, we still send it once, without retries. Retries make delivery much more likely but do not guarantee it, and there is no delivery history for verification results in the dashboard yet. Do not make the webhook your only record of a verification — confirm anything that matters from the API, for example with a transaction status lookup, and act on its data, not on an unsigned body.
Where is the delivery channel?
Short answer: on every OTP webhook, in the same place for the same API version.
| API version | Webhook | The channel the code went out on | Every channel used |
|---|---|---|---|
| V2 Widget | Verification result | transaction.resolvedChannel | transaction.channels |
| V1 / V1.2 REST | Verification result | mainTransaction.resolvedChannel | mainTransaction.channels |
| V1 / V1.2 REST | otp.channels.resolved / otp.channels.updated | — | data.channels |
| V1.2 / V2 | Passkey verification | None — no code was sent | None — the transaction carries verificationMethod: "passkey" instead |
- Values are lowercase:
whatsapp,sms,telegram,email. We never tell you which SMS provider carried the message — only that it wassms. resolvedChannelis the single channel the code ended up on. If WhatsApp failed and the code fell back to SMS, it sayssms.channelslists every channel the code was sent on. It can hold more than one (for example WhatsApp and Telegram together). Treat it as a set; the order means nothing.- The verification result is a snapshot taken when the user's code is checked. By then any WhatsApp fallback has normally already happened.
- No update means no change.
otp.channels.updatedis sent only when WhatsApp fails after sending. If WhatsApp succeeds, theotp.channels.resolvedevent you already have is final. - V2 has no channel events. For V2, the verification result is the only webhook that carries the channel. On V1 and V1.2, the channel events tell you as soon as the code goes out, before the user types anything.
- V1 without
waitForChannelsdoes not wait for the provider to confirm, sochannelscan include a channel that was attempted but not yet confirmed.
Verification result payloads
The verification result is the webhook every OTP pipeline receives. V2 and V1/V1.2 carry the same transaction record under different names — transaction on V2, mainTransaction on V1/V1.2.
{
"status": "success",
"timestamp": "2026-10-01T12:00:42.150Z",
"widgetAttempt": {
"attemptId": "attempt_a1b2c3d4e5f67890",
"widgetId": "widget_x1y2z3",
"userId": "6821bb199f9cfd6b2754e6ef",
"status": "verified",
"isTest": false,
"verificationAddress": {
"phoneNumber": "+201****7890",
"email": null
},
"otpConfig": {
"digits": 6,
"resendCount": 0
},
"publicMetadata": {
"orderId": "order_abc789"
},
"createdAt": "2026-10-01T11:59:40.000Z",
"expiresAt": "2026-10-01T12:04:40.000Z",
"captchaVerifiedAt": "2026-10-01T11:59:52.500Z",
"otpRequestedAt": "2026-10-01T11:59:59.800Z",
"completedAt": "2026-10-01T12:00:42.120Z"
},
"transaction": {
"transactionID": "ae2eacaebe3ed78b105498d5d0cfe54f11b4130a0847d67b36927752148679e5",
"status": "Successful",
"verificationAddress": {
"phoneNumber": "+201234567890"
},
"OTP": "123456",
"resolvedChannel": "whatsapp",
"channels": [
"whatsapp"
],
"isTest": false,
"userID": "6821bb199f9cfd6b2754e6ef",
"pipelineID": "6821bba09f9cfd6b2754e752",
"creationDate": "2026-10-01T12:00:00.000Z",
"expirationDate": "2026-10-01T12:03:00.000Z",
"updateDate": "2026-10-01T12:00:42.120Z"
},
"transactionReq": {
"_id": "6a1f0c2f9d1b4a0012345679",
"mainTransactionID": "ae2eacaebe3ed78b105498d5d0cfe54f11b4130a0847d67b36927752148679e5",
"status": "Successful",
"isTest": false,
"channels": [
"whatsapp"
],
"sentVerificationDate": "2026-10-01T12:00:01.200Z",
"inputOTP": "123456",
"verificationDate": "2026-10-01T12:00:42.120Z",
"creationDate": "2026-10-01T12:00:00.300Z",
"expirationDate": "2026-10-01T12:03:00.300Z"
},
"publicMetadata": {
"orderId": "order_abc789"
},
"privateMetadata": {
"internalUserId": "internal_xyz"
}
}
The transaction record
transaction (V2) and mainTransaction (V1/V1.2) hold the same fields.
- Name
- transactionID
- Type
- string
- Description
The transaction's ID. Use it to look the transaction up through the API.
- Name
- status
- Type
- string
- Description
"Pending","Successful"or"Failed".
- Name
- resolvedChannel
- Type
- string
- Description
The channel the code ended up on:
whatsapp,sms,telegramoremail. See Where is the delivery channel?
- Name
- channels
- Type
- string[]
- Description
Every channel the code was sent on.
- Name
- verificationAddress
- Type
- object
- Description
phoneNumberand/oremailthe code was sent to. Unmasked here; the masked copy is inwidgetAttempton V2.
- Name
- OTP
- Type
- string
- Description
The code that was sent. Not present on passkey verifications. Treat the whole body as sensitive.
- Name
- isTest
- Type
- boolean
- Description
truefor a dev-mode test-pair transaction. Nothing was sent and nothing was billed.
- Name
- userID / pipelineID
- Type
- string
- Description
Your account ID and the pipeline ID.
- Name
- creationDate / expirationDate / updateDate
- Type
- string
- Description
ISO 8601 timestamps.
- Name
- verificationMethod / reason
- Type
- string
- Description
Passkey verifications only:
verificationMethod: "passkey", plus areasonwhen a V1.2 passkey verification fails.
The send record
transactionReq describes the send the user answered. A transaction can have several — one per resend.
- Name
- _id
- Type
- string
- Description
The send's ID. It matches
data.transactionReqIDin the channel events.
- Name
- mainTransactionID
- Type
- string
- Description
The parent
transactionID.
- Name
- status
- Type
- string
- Description
"Pending","Successful"or"Failed".
- Name
- channels
- Type
- string[]
- Description
The channels this send went out on.
- Name
- inputOTP
- Type
- string
- Description
The code the user typed.
- Name
- sentVerificationDate / verificationDate
- Type
- string
- Description
When the code was sent, and when the user's code was checked.
- Name
- isTest / creationDate / expirationDate
- Type
- mixed
- Description
The test flag and the send's timestamps.
V2-only fields
- Name
- status
- Type
- string
- Description
"success"or"failed". Branch on this and onerror.code, not onwidgetAttempt.statusortransaction.status— a wrong code (INVALID_OTP) leaves the attempt at"otp_requested"and the transaction at"Pending", because the user can still try again. Once attempts run out (MAX_ATTEMPTS_EXCEEDED) the transaction is"Failed".
- Name
- error
- Type
- object
- Description
codeandmessage, on failures only (for exampleINVALID_OTP,TRANSACTION_EXPIRED,MAX_ATTEMPTS_EXCEEDED).
- Name
- widgetAttempt
- Type
- object
- Description
The widget session:
attemptId,widgetId,status,isTest, the maskedverificationAddress,otpConfig,publicMetadataand its timestamps.
- Name
- publicMetadata / privateMetadata
- Type
- object
- Description
The metadata you passed when creating the attempt.
- Name
- transaction / transactionReq
- Type
- object | null
- Description
On a wrong code,
transactionReqhas noinputOTPorverificationDateyet. A passkey verification sends onlytransactionID,status,verificationAddressandverificationMethod, withtransactionReq: null.
These are the only fields we send on these two objects. If we add one later it will be listed here first, so ignore any field you do not recognise rather than failing on it.
OTP channel events
Eligible OTP transactions can produce a second callback stream on the pipeline's Logging Webhook URL (backendCallbackURL). The same URL receives both the OTP verification-result callback and these signed channel events. Route channel events by the top-level eventType (otp.channels.resolved or otp.channels.updated) and verify the raw body before accepting them. Route results by the mainTransaction object. V1.2 results are normally signed too, so verify them the same way; V1 results are unsigned, so do not reject a result just because the svix-* headers are absent.
Only V1.2 transactions always qualify. A V1 transaction qualifies when its request uses waitForChannels: true. V1 requests without that option, V2 Widget transactions, and transactions with isTest: true do not emit these events. Akedly emits nothing when the pipeline has no Logging Webhook URL.
The first event is otp.channels.resolved. It is sent after activation's channel wait, which has a maximum of five seconds, and after the channel array is persisted. A later WhatsApp provider failure can produce otp.channels.updated after the failed delivery has been reconciled and the new channel array has been persisted. That update can contain an empty channels array when no fallback channel is available.
channels belongs to the TransactionReq identified by transactionReqID. It is a unique array of lowercase enum values: whatsapp, sms, telegram, and email. Treat it as a set; array order is not semantic. The initial event can therefore include WhatsApp while its provider result is still pending at the five-second limit. Each event is saved and delivered on its own, so an update can reach you before the initial event. For the same transaction request, otp.channels.updated supersedes otp.channels.resolved regardless of arrival order.
{
"eventId": "evt_7f2a1d4c9e8b6a50123456789abcdeff",
"eventType": "otp.channels.resolved",
"eventVersion": "1",
"pipelineId": "67d1b8d2f4a10c2b6e8f9a31",
"timestamp": "2026-09-20T12:00:00.000Z",
"data": {
"transactionID": "67d1b8d2f4a10c2b6e8f9a31c1d7e4b6a2f9c0d8e5b3a1f6c4d2e8b7a5c3d9e0",
"transactionReqID": "66edc0f2a7f9c1d2e3f4a5b6",
"apiVersion": "v1.2",
"channels": ["sms"],
"reason": "activation"
}
}
Here is the same request after WhatsApp fails without a fallback:
{
"eventId": "evt_4c91e8b27a6d503f1e2c9b7a8d6f4051",
"eventType": "otp.channels.updated",
"eventVersion": "1",
"pipelineId": "67d1b8d2f4a10c2b6e8f9a31",
"timestamp": "2026-09-20T12:00:08.000Z",
"data": {
"transactionID": "67d1b8d2f4a10c2b6e8f9a31c1d7e4b6a2f9c0d8e5b3a1f6c4d2e8b7a5c3d9e0",
"transactionReqID": "66edc0f2a7f9c1d2e3f4a5b6",
"apiVersion": "v1.2",
"channels": [],
"reason": "whatsapp_failure"
}
}
The update uses the same envelope and data fields, with eventType: "otp.channels.updated". Its reason is "whatsapp_fallback" when a replacement channel was persisted, or "whatsapp_failure" when the WhatsApp failure left no fallback channel. Akedly deduplicates updates by provider message ID plus its settled resolution, so a duplicate notification for the same provider message and outcome does not create another logical update. The initial event is admitted at most once per TransactionReq; an update is admitted only after that initial dispatch claim exists.
eventId and svix-id both stay the same across delivery retries of one event; svix-timestamp and svix-signature are fresh on each attempt. Verify the svix-id, svix-timestamp, and svix-signature headers before processing the body, acknowledge quickly, and deduplicate by eventId rather than by transaction ID alone.
Each event is saved before it is sent, so a restart on our side does not lose it. If a POST fails, the next attempt becomes due after 1 second, 5 seconds, 25 seconds, then 125 seconds, and a background job picks it up within a minute of becoming due. The fifth total attempt is the last; after it fails, the delivery is marked failed and nothing more is sent.
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. The Utilities template.status_changed event is stricter: Meta approvals and rejections go only to direct HTTPS callback URLs.
Try it before you have traffic
The dashboard can fire a sample webhook 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:
- Pick the webhook. V1 and V1.2 pipelines can test the verification result and both channel events. V2 pipelines test the verification result. Utility pipelines test
message.status. - Check the request. The panel shows the headers and body, whether the webhook is signed, and which field holds the channel.
- Press Send. The response panel shows your server's live answer, and the request panel switches to the exact request that was sent.
The sample is built by the same code that builds real webhooks, with sample values, and is signed exactly when the real one would be.
How to recognise a test
A verification-result test starts with "event": "webhook.test" and "test": true — real verification results never contain those two fields. Channel-event and utility tests are exact real envelopes; their IDs do not exist in your account. Either way, nothing was sent to a phone and nothing was billed.
OTP verification-result callbacks contain the verification code
On the V1 and V1.2 REST APIs the verification result of a code-based verification includes an OTP field holding the actual code (the V1 request is also 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 and V1.2 verification results, to OTP channel events, and to utility pipeline webhooks such as
message.status, message.inbound, button.action and template.status_changed. The V1 verification-result callback
is not signed, so it carries no svix-* headers; treat that delivery as a UX signal and confirm the outcome
independently with an authenticated transaction status lookup, and act on the lookup's data, not on the callback body. For V1.2 passkeys, the ceremony's resultToken is a second signed proof you can verify offline.
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.
Check the callback's signing mode
V2 widget and V1.2 verification results are signed in almost all cases. The V1 verification-result callback is unsigned, while OTP channel events are always signed. Check Webhooks by API version 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
For any OTP pipeline, including a V1 pipeline whose requests do not use waitForChannels, an
authenticated pipeline owner with the pipelines:update permission can retrieve the secret once
through GET /api/v1/pipelines/id/{pipelineID}/webhook-secret using the owner's user-session
Authorization: Bearer token if the dashboard does not show the control. Signed channel events are
emitted for eligible live V1.2 transactions and for V1 requests with waitForChannels: true; a V1
pipeline can manage its secret even when a request does not emit a channel event.
After you copy it, acknowledge it with POST /api/v1/pipelines/id/{pipelineID}/webhook-secret/acknowledge.
If it has already been acknowledged or is lost, regenerate it with
POST /api/v1/pipelines/id/{pipelineID}/webhook-secret/regenerate; regeneration invalidates the
previous 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:
whsec_<base64-encoded key from the dashboard>
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:
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
msg_*identifier. V2 and V1.2 verification results and OTP channel events keep the same value on every retry of one queued delivery; utility message events get a fresh value on each attempt. A verification result sent through the single-send fallback has its own value, so do not use it as the only duplicate key.
- 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.
Widget webhook contract
- 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
- up to 6 attempts over about 8½ hours
- Description
- V2 results are saved before sending and retried after 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours with the same body and svix-id.
Utility webhook contract
- Name
- Signed delivery retries
- Type
- up to 5 attempts (utility message events)
- Description
- Utility message events get up to five total attempts in the sending process: one initial attempt and up to four retries. OTP channel events use the same five-attempt schedule but are saved first and retried by a background job.
- Name
- Retry backoff
- Type
- 1s, 5s, 25s, 125s
- Description
- The default delays between those five attempts are one, five, 25, and 125 seconds.
- Name
- Stable event ID
- Type
- eventId
- Description
- The envelope creates one
eventIdthat is reused across retries. On utility message events thesvix-idchanges per attempt, so it is not the stable deduplication identifier there.
- Name
- Dispatch admission retention
- Type
- log: 7 days; OTP row: no automatic TTL
- Description
- Admissions on the in-process five-attempt path claim
dedupeKeyin a dispatch log retained for seven days. OTP channel deliveries also use a partial unique index on a delivery row for each pipeline, event type, anddedupeKey; the delivery row has no automatic TTL and remains the duplicate-delivery guard until it is removed. Receivers should deduplicate the stableeventId.
- Name
- Utility signing
- Type
- required
- Description
- Utility delivery calls the common dispatcher with signature required.
- Name
- Utility dedupe key
- Type
- trigger-specific
- Description
- Utility status triggers use
message.status:<channel>:<pipeline>:<message-or-transaction>:<status>; inbound and button triggers use<eventType>:<pipeline>:<wamid>.
- 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. Template status deliveries appear in the same list because the query filters by pipeline only; a retired destination showslastError: "destination changed".
- Name
- Template status delivery
- Type
- up to 7 attempts over about 23 h 51 min
- Description
template.status_changeduses a template-specific retry schedule: it is staged durably and sent by a worker that runs about once a minute, so the first attempt normally lands within a minute of the accepted Meta transition. Unlike the five-attempt message and OTP channel event paths, seven total attempts are spread over about 23 hours 51 minutes. Each accepted transition and representative destination is admitted through a unique delivery claim rather than through the seven-day dispatch log. The destination andeventIdare frozen when the event is staged. Each POST carries a new details token minted immediately before it, so the body and signature change on every attempt. After signature verification, retain the token from the greatest signedsvix-timestampfor the sameeventIdbefore deduplicating business work. This must be one atomic database conditional update comparing with the persisted timestamp (or a lock shared by all workers for that key): write only when incoming is greater, and keep the existing token on a tie, so out-of-order workers cannot replace a newer token with an older one. A 2xx normally stops retries, but at-least-once delivery means a duplicate may still arrive. Redirects are not followed; a redirect fails the attempt. A delivery whose representative pipeline is deleted, made ineligible, has its owner changed, or is given a new callback URL is checked before the next attempt, including between retries; prior attempts may already have called the old URL, but it is not called again, and the delivery fails withdestination changedinstead of calling the old URL.
- Name
- Template status destinations
- Type
- one per direct HTTPS callback URL
- Description
- Eligible destinations are the owner's non-sandbox utility pipelines that have a direct HTTPS callback URL. Pipelines that share a URL receive one delivery, signed with the secret of the lowest pipeline ID among them; that pipeline is the envelope pipelineId. HTTP callback URLs receive no template status event and get no delivery row.
- Name
- Template status admission
- Type
- APPROVED or REJECTED only
- Description
template.status_changedis sent only when Meta's new status isAPPROVEDorREJECTED. Other Meta statuses, such as PENDING, PAUSED and DISABLED, produce no event and no delivery row.
- Name
- Template details token
- Type
- one-use bearer, 5 minutes
- Description
- Minted just before each POST and expires five minutes after minting. A newer attempt invalidates it. Stored only as a digest. The first successful read consumes it.
- Name
- Template details endpoint
- Type
- public GET, Cache-Control no-store, private
- Description
GET /api/v1/utilities/templates/detailsaccepts theAuthorization: Bearertoken and returns the customer-safe template as it is at the successful GET, not a status-change snapshot. Responses setCache-Control: no-store, private. If the detail-token index is unavailable, the endpoint returns a safe, retryable503before checking or consuming the token. Rate-limited requests return a retryable429 Too Many RequestswithRetry-After; wait for that interval, then retry before the token expires. Once ready, missing, invalid, expired, used or superseded tokens, tokens for deleted templates, and tokens whose representative pipeline was deleted, became ineligible, changed owner or changed callback URL get one uniform404withDETAIL_TOKEN_INVALIDand the exact messageThe template detail token is invalid or expired. Retry503and transient5xxresponses promptly before the token expires. For404, do not retry with the same token; read the latest stored token and retry once only if it differs.
| 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 |
| template.status_changed | source, templateId, metaTemplateId, name, language, previousStatus, status, reason, statusTimestamp, details.method, details.path, details.token |
V1 / V1.2 verification result contract
- Name
- V1.2 result signing
- Type
- signed, best effort
- Description
- V1.2 verification results are signed with the pipeline webhook signing secret. If the secret cannot be resolved the result is still sent, without svix headers. Signing follows the pipeline's API version, so V1 pipelines receive unsigned results.
- Name
- V1 / V1.2 result retries
- Type
- up to 6 attempts over about 8½ hours
- Description
- Results are saved before sending and retried after 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours with the same body; signed attempts of one delivery share one svix-id.
OTP channel event contract
- Name
- Signed delivery retries
- Type
- up to 5 attempts (utility message events)
- Description
- Utility message events get up to five total attempts in the sending process: one initial attempt and up to four retries. OTP channel events use the same five-attempt schedule but are saved first and retried by a background job.
- Name
- Retry backoff
- Type
- 1s, 5s, 25s, 125s
- Description
- The default delays between those five attempts are one, five, 25, and 125 seconds.
- Name
- Stable event ID
- Type
- eventId
- Description
- The envelope creates one
eventIdthat is reused across retries. On utility message events thesvix-idchanges per attempt, so it is not the stable deduplication identifier there.
- Name
- Dispatch admission retention
- Type
- log: 7 days; OTP row: no automatic TTL
- Description
- Admissions on the in-process five-attempt path claim
dedupeKeyin a dispatch log retained for seven days. OTP channel deliveries also use a partial unique index on a delivery row for each pipeline, event type, anddedupeKey; the delivery row has no automatic TTL and remains the duplicate-delivery guard until it is removed. Receivers should deduplicate the stableeventId.
- Name
- OTP channel event signing
- Type
- required
- Description
- OTP channel events use the pipeline signing secret and require Svix signature verification.
- Name
- OTP channel values
- Type
- unique lowercase array
- Description
- The channels array belongs to one TransactionReq and contains a unique set from whatsapp, sms, telegram, and email. Array order is not semantic.
- Name
- OTP dedupe key
- Type
- request + provider message
- Description
- The initial event is admitted once per pipeline and TransactionReq. An update is admitted once per provider message and resolution for that request.
- Name
- OTP channel event history
- Type
- not public
- Description
- OTP channel event delivery history is not exposed through the public OTP pipeline API or dashboard.
| Event type | Data fields |
|---|---|
| otp.channels.resolved | transactionID, transactionReqID, apiVersion, channels, reason |
| otp.channels.updated | transactionID, transactionReqID, apiVersion, channels, reason |
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.
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.
// 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.
