Skip to content
Akedly
Testing & going live

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

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 versionWebhookSent whenSigned?Retried?
V2 WidgetVerification resultThe user enters a code (right or wrong), or verifies with a passkeyYes, almost alwaysYes — up to 6 attempts over about 8½ hours
V1.2 RESTVerification resultThe user's code is checked (right or wrong)Yes, almost alwaysYes — up to 6 attempts over about 8½ hours
V1.2 RESTotp.channels.resolvedAlways, once the code has gone out — WhatsApp success includedYesUp to 5 attempts within a few minutes
V1.2 RESTotp.channels.updatedOnly if WhatsApp fails after sending (the code moved to another channel, or to none). Not sent when WhatsApp succeedsYesUp to 5 attempts within a few minutes
V1 RESTSame as V1.2The two otp.channels.* events only when the request uses waitForChannels: trueNo for the verification result; yes for the channel eventsSame 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.

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:

Route by body shape

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.


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 (a 4xx or 5xx is 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 the svix-id header is also the same on every attempt; only svix-timestamp and svix-signature are fresh.
  • One success webhook per attempt. A V2 attempt sends at most one success result. Each wrong code sends its own failed result, and each one is retried on its own.
  • Results can arrive out of order. A failed result 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 a widgetAttempt.attemptId or mainTransaction.transactionID, never let a later failed / Failed or pending / Pending result 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 http to https, 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 versionWebhookThe channel the code went out onEvery channel used
V2 WidgetVerification resulttransaction.resolvedChanneltransaction.channels
V1 / V1.2 RESTVerification resultmainTransaction.resolvedChannelmainTransaction.channels
V1 / V1.2 RESTotp.channels.resolved / otp.channels.updated—data.channels
V1.2 / V2Passkey verificationNone — no code was sentNone — 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 was sms.
  • resolvedChannel is the single channel the code ended up on. If WhatsApp failed and the code fell back to SMS, it says sms.
  • channels lists 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.updated is sent only when WhatsApp fails after sending. If WhatsApp succeeds, the otp.channels.resolved event 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 waitForChannels does not wait for the provider to confirm, so channels can 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.

Verification result

{
  "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, telegram or email. See Where is the delivery channel?

  • Name
    channels
    Type
    string[]
    Description

    Every channel the code was sent on.

  • Name
    verificationAddress
    Type
    object
    Description

    phoneNumber and/or email the code was sent to. Unmasked here; the masked copy is in widgetAttempt on 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

    true for 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 a reason when 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.transactionReqID in 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 on error.code, not on widgetAttempt.status or transaction.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

    code and message, on failures only (for example INVALID_OTP, TRANSACTION_EXPIRED, MAX_ATTEMPTS_EXCEEDED).

  • Name
    widgetAttempt
    Type
    object
    Description

    The widget session: attemptId, widgetId, status, isTest, the masked verificationAddress, otpConfig, publicMetadata and 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, transactionReq has no inputOTP or verificationDate yet. A passkey verification sends only transactionID, status, verificationAddress and verificationMethod, with transactionReq: 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.

Signed channel event

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

Channel update with no 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.


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:

  1. 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.
  2. Check the request. The panel shows the headers and body, whether the webhook is signed, and which field holds the channel.
  3. 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.


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.


Where to find your secret

Each pipeline has its own signing secret, prefixed with whsec_. To retrieve it:

  1. Open your pipeline in the dashboard
  2. Scroll to the Webhooks section
  3. 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.

The secret looks like this:

Example secret

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:

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 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.sign over 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 eventId that is reused across retries. On utility message events the svix-id changes 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 dedupeKey in 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, and dedupeKey; the delivery row has no automatic TTL and remains the duplicate-delivery guard until it is removed. Receivers should deduplicate the stable eventId.
  • 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 shows lastError: "destination changed".
  • Name
    Template status delivery
    Type
    up to 7 attempts over about 23 h 51 min
    Description
    template.status_changed uses 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 and eventId are 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 signed svix-timestamp for the same eventId before 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 with destination changed instead 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_changed is sent only when Meta's new status is APPROVED or REJECTED. 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/details accepts the Authorization: Bearer token and returns the customer-safe template as it is at the successful GET, not a status-change snapshot. Responses set Cache-Control: no-store, private. If the detail-token index is unavailable, the endpoint returns a safe, retryable 503 before checking or consuming the token. Rate-limited requests return a retryable 429 Too Many Requests with Retry-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 uniform 404 with DETAIL_TOKEN_INVALID and the exact message The template detail token is invalid or expired. Retry 503 and transient 5xx responses promptly before the token expires. For 404, do not retry with the same token; read the latest stored token and retry once only if it differs.
Event typeData fields
message.statustransactionId, wamid, smsMessageId, templateId, phoneNumber, status, failureCode, failureReason, channel, statusTimestamp
message.inboundwamid, contextWamid, transactionId, templateId, phoneNumber, messageBody, receivedAt, sourceKnown
button.actionwamid, contextWamid, transactionId, templateId, phoneNumber, buttonId, buttonLabel, buttonPayload, tappedAt, sourceKnown
template.status_changedsource, 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 eventId that is reused across retries. On utility message events the svix-id changes 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 dedupeKey in 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, and dedupeKey; the delivery row has no automatic TTL and remains the duplicate-delivery guard until it is removed. Receivers should deduplicate the stable eventId.
  • 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 typeData fields
otp.channels.resolvedtransactionID, transactionReqID, apiVersion, channels, reason
otp.channels.updatedtransactionID, 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.

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


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.