Results and callbacks
Read the webhook payload your server receives after a verification, and where to verify its signature.
Step 4: Handle Callbacks
Backend Webhook Payload
If you configure a backendCallbackURL in your pipeline settings, Akedly sends a POST request to your server with complete verification details.
Widget webhooks are retried for about 8½ hours
If your server is down, times out, or replies with anything other than 2xx, Akedly sends the same result again after 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours. That is six attempts in total. Each retry can start up to a minute after it is due. Every attempt carries the same body and the same svix-id, so use svix-id to skip repeated deliveries of the same message. It is not a business key: key your records on widgetAttempt.attemptId.
Two things to design around:
- No event envelope. There is no
eventIdoreventType; the payload is the flat object below, identified by itsstatusfield. - Signing does not fail closed. The request is normally signed with the pipeline's
whsec_secret, but unlike utility webhooks the widget still sends if signing fails. Treat a missing signature as "unverifiable", not as proof of forgery.
Retries make delivery much more likely but do not guarantee it, so do not make this your only record of a verification. See Webhooks → Retries for the full rules. If a result never arrives, look the attempt up by the attemptId you saved with a transaction status lookup.
Webhook Timing:
The webhook is sent:
- Immediately after successful verification (OTP verified)
- Immediately after failed verification (invalid OTP, expired, etc.)
V2 Widget callbacks are signed
The V2 Widget callback is an envelope with top-level status and timestamp, plus attempt, transaction, metadata, and optional error data.
- Name
- Payload
- Type
- object
- Description
- The envelope contains
status,timestamp,widgetAttempt,transaction,transactionReq,publicMetadata, andprivateMetadata; failed callbacks may also includeerror.
- Name
- Dev-mode marker
- Type
- widgetAttempt.isTest
- Description
- A test-pair attempt is marked by
widgetAttempt.isTest. It is not a top-level webhook field.
- Name
- Signature
- Type
- svix headers
- Description
- The widget success path supplies the pipeline signing secret to the common dispatcher, which generates
svix-id,svix-timestamp, andsvix-signature.
{
"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"
}
}
Which channel did the code go out on?
transaction.resolvedChannel is the channel the code ended up on (whatsapp, sms, telegram or
email), and transaction.channels lists every channel it was sent on. Passkey verifications have
neither. See Where is the delivery channel? and the full
field reference.
The top-level failure status and attempt status differ
A failed OTP verification is sent with top-level status: "failed", but the
widgetAttempt.status can still be "otp_requested" because an invalid OTP does not
terminalize the attempt. Branch on the top-level status and inspect error; do not assume the
nested attempt status is always "failed".
Make the handler safe to run twice
These examples find your user by the attemptId you saved on the user record when you created the attempt, stored as akedlyAttemptId. Save it at creation, and look the attempt up with that same id.
The guard is the conditional update on your stored record (verified is not already true), not a flag in memory. Send the welcome email only when that update changed the record.
If you call these steps from a reconciler that uses a status lookup, apply only a success. A lookup's pending or failed is not a new failed attempt and has no error object, so do not log it as one.
A crash between the update and the email can lose the email, and sending the email first can repeat it. If the email must survive a crash, write the email request in the same database transaction as the update (an outbox) and send from that. In SQL, use an update limited to rows not yet verified and check the changed-row count.
Verifying Webhook Signatures
When signature headers are present, the JSON payload above is what gets signed — verify the signature before parsing the body, on the raw request bytes. A webhook with no svix-signature header is neither trusted nor rejected: the examples above answer 200 and confirm the attempt with a status lookup before marking anything verified.
