Verify and callbacks
Verify the OTP your user entered, then read the verification-result callback your server receives.
Step 3: Verify OTP
Submit the user's OTP input for verification using the transactionReqID from Step 2.
Request Body
- Name
- transactionReqID
- Type
- string
- Description
The transaction request ID returned in Step 2's
data.transactionReqID.
- Name
- otp
- Type
- string
- Description
The OTP code entered by the user (must be sent as a string). Arabic-Indic (
٠-٩) and Persian (۰-۹) digits are accepted and converted to ASCII digits.
- Name
- returnTarget
- Type
- object
- Requirement
- Optional
- Description
Pass this when you plan to offer passkey enrollment and need a proven SDK result. Use
{"origin":"https://your-site.example"}for web or{"url":"myapp://akedly-passkey"}for a native deep link. Akedly signs the sanitized target intodata.enrollmentToken.
OTP length is variable — do not hardcode 6
A pipeline may issue 4, 5 or 6 digit codes (digits in Step 2 — default 6). Size your OTP input from the pipeline's configured length or accept a range; a UI hardwired to six boxes silently breaks the moment a pipeline is switched to 4 or 5, and auto-submit-on-full never fires.
Success Response
- Name
- status
- Type
- string
- Description
"success"on valid OTP.
- Name
- data.verified
- Type
- boolean
- Description
truewhen the OTP is verified successfully.
- Name
- data.transactionID
- Type
- string
- Description
The main transaction ID.
- Name
- data.frontendCallbackURL
- Type
- string
- Description
Callback URL if configured on the pipeline.
- Name
- data.enrollmentToken
- Type
- string
- Description
Optional. Present only when passkeys are enabled for both the account and the pipeline. A single-use, short-lived (~2 min) token you hand to the passkey enroll ceremony to offer the user a passkey right after this successful verify. Absent otherwise — treat its presence as "you may offer enrollment now." See V1.2 Passkeys.
- Name
- message
- Type
- string
- Description
"OTP verified successfully".
Failed Response
- Name
- status
- Type
- string
- Description
"error".
- Name
- code
- Type
- string
- Description
Error code:
"INVALID_OTP"(403),"MAX_ATTEMPTS_EXCEEDED"(429),"TRANSACTION_EXPIRED"(410), or"ALREADY_VERIFIED"(409).
- Name
- message
- Type
- string
- Description
Error description.
If the verify response never reaches you
Look the transaction up with its mainTransaction.transactionID using a transaction status lookup. Apply only a success to your records. Any other answer means "not verified yet".
Webhook Callbacks
When a transaction completes — successfully or not — Akedly POSTs the legacy verification-result JSON payload to the backendCallbackURL configured on your pipeline. This is how your server learns the result without polling. The same URL can also receive the separate signed OTP channel events described in OTP channel events.
V1.2 verification-result callbacks are signed
The direct V1.2 verification-result callback body contains mainTransaction and transactionReq. Dev-mode state is carried on those transaction objects. Verify the Svix signature on the raw body before acting; if a result ever arrives unsigned, confirm it with a server-side check on transactionID instead. This contract covers the verification-result callback; signed otp.channels.resolved and otp.channels.updated events use the separate signed channel-event contract.
- Name
- Payload
- Type
- object
- Description
- The callback is an object with sanitized
mainTransactionand thetransactionReqobject; it has no top-level status envelope.
- Name
- Dev-mode marker
- Type
- boolean fields
- Description
- For a test-pair transaction,
mainTransaction.isTestandtransactionReq.isTestare true.
- Name
- Signature
- Type
- svix headers, best effort
- Description
- Signed with the pipeline webhook signing secret, with
svix-id,svix-timestampandsvix-signatureheaders. Retries reuse the samesvix-id. If signing fails the result is still sent, without those headers.
- Name
- OTP channel events
- Type
- excluded for test transactions
- Description
- A transaction marked
isTest: truestill receives this verification-result callback, but it does not emit signedotp.channels.resolvedorotp.channels.updatedevents.
The delivery channel is in mainTransaction.resolvedChannel (the channel the code ended up on) and
mainTransaction.channels (every channel used). See
Where is the delivery channel? and the full
payload reference.
The verification-result callback contains the verification code
The legacy mainTransaction object includes an OTP field holding the actual code. Treat it as sensitive: deliver it over HTTPS, keep the raw body out of your logs and error trackers, and do not forward it to third-party tooling.
A callback your server misses is retried — six attempts over about 8½ hours, with the same body each time (the schedule). Because a retry can repeat a result you already handled, deduplicate on mainTransaction.transactionID + mainTransaction.status + transactionReq.verificationDate — the same bytes on every attempt. The svix-id header is a fast path for signed retries, not a complete key. Retries do not guarantee delivery, so still reconcile important state from the API rather than depending on the callback arriving.
The callback destination lives in the pipeline's Webhooks section. The verification-result callback is
signed with the pipeline's signing secret (svix-id, svix-timestamp, svix-signature headers) — verify the
raw body before acting on it. Signing is best effort: if a result ever arrives without those headers, confirm it
through the API instead of rejecting it. The same URL also receives signed channel events; route them using the
OTP channel event guide, and see Verify in your stack for the signature check.
To confirm a result that is late, missing or unsigned, use a transaction status lookup with the mainTransaction.transactionID you stored. The passkey ceremony poll, GET /api/v1.2/transactions/passkey/result, is a separate call authorized by the ceremony token. See the passkey endpoint reference. On a passkey sign-in the V1.2 webhook stamps its send time, so a date in a lookup can differ from the date in the webhook.
