Transaction status lookup
Ask Akedly for the current result of a verification by transactionID or attemptId, one at a time or up to 50 per call. Use it when a webhook is late, missing, or arrived unsigned.
Server-side only
Lookups use your API key. Call them from your backend, never from a browser or a mobile app, and never put the key in a URL. Manage keys in Account & Billing → API Keys.
Make a lookup
There are two calls. Both take JSON only, and both match ids exactly as you send them, with no trimming. The base URL is https://api.akedly.io.
| Call | Path | Body |
|---|---|---|
| Single lookup | POST /api/v1/transactions/status | Exactly one of transactionID or attemptId, a non-empty string |
| Batch lookup | POST /api/v1/transactions/status/batch | transactionIDs and/or attemptIds, arrays of non-empty strings, 1 to 50 positions in total. Duplicates count. |
Single. Send one transactionID or one attemptId, never both. Use attemptId for a V2 widget attempt. Use transactionID for V1 and V1.2 transactions. A passkey sign-in's requestID also goes in transactionID.
Batch. Send transactionIDs, attemptIds, or both. The call takes 1 to 50 positions in total, and a repeated id counts once per position.
A body that is not valid JSON gets a plain 400 that is not part of this contract. Send valid JSON.
Look up one transaction
curl -X POST https://api.akedly.io/api/v1/transactions/status \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "transactionID": "ae2eacaebe3ed78b105498d5d0cfe54f11b4130a0847d67b36927752148679e5" }'
Look up one attempt
curl -X POST https://api.akedly.io/api/v1/transactions/status \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "attemptId": "attempt_a1b2c3d4e5f67890" }'
Look up a batch
curl -X POST https://api.akedly.io/api/v1/transactions/status/batch \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"transactionIDs": [
"ae2eacaebe3ed78b105498d5d0cfe54f11b4130a0847d67b36927752148679e5",
"67d1b8d2f4a10c2b6e8f9a31c1d7e4b6a2f9c0d8e5b3a1f6c4d2e8b7a5c3d9e0"
],
"attemptIds": ["attempt_a1b2c3d4e5f67890"]
}'
Authentication
Send your API key in one of three places:
- the body field
APIKey(apiKeyalso works) - the header
Authorization: Bearer YOUR_API_KEY - the header
x-api-key
Never put the key in the URL. A request that carries two different keys answers 401. Use a current key: a revoked or expired key answers 401.
Two 403 answers can follow a valid key. ACCOUNT_NOT_ACTIVE means the account is deactivated. API_KEY_SCOPE_DENIED means the key is limited to some pipelines and the record sits outside them.
Limits and charging
Each account can look up 300 ids per minute. The limit is shared by both calls and by every key on the account. It resets in fixed 60-second windows. Past the limit, the call answers 429, and Retry-After gives the seconds until the next window.
| What happens | Counted against the 300? |
|---|---|
| A single lookup | Counts 1 |
| A batch | Counts every position, duplicates included. All or nothing. |
| An id that is not found | Counted. It returns 404. |
| A request that fails validation (400) | Not counted |
| A 429 | Nothing is charged. A smaller batch may still fit in the window. |
| A retryable 503 from the counter store | Unknown whether it was counted |
| An item you retry | Charged again |
Charges are never refunded.
Single and batch responses
A single lookup that works answers 200 with { "status": "success", "data": ... }. An error answers with { "status": "error", "code": ..., "message": ... }, and sometimes retryable.
A batch answers 200 with data.transactions and data.attempts. Order is kept within each array only, so match items by their own id and not by position across the two arrays. Within one array, a repeated id gets the same answer; on V2 answers that includes one shared timestamp. An id that appears in both arrays is looked up once for each array.
Each batch item is either status: "success" with data, or status: "error" with code and message, and sometimes retryable. A per-item error is the normal outcome and not a failed call. Rarely, the whole call fails with a 500 STATUS_LOOKUP_FAILED or a retryable 503 STATUS_LOOKUP_UNAVAILABLE. The charge is kept in both cases.
An item in a batch can answer a retryable 503 in two cases. The batch did not start it, because the batch halted or the 5000 ms start window passed. Or it was attempted but unavailable. Retry just those ids. The 5000 ms applies only to when a batch starts its lookups. It says nothing about how long the call takes.
Branch on code, never on message or on key order.
Error codes
Every error carries a code. retryable: true means the same request can succeed if you try again.
| Code | HTTP | What to do |
|---|---|---|
INVALID_API_KEY | 401 | Fix the key. A revoked or expired key answers 401, and so does a request carrying two different keys. |
API_KEY_INDEX_UNAVAILABLE | 503 | Retryable (retryable: true). Try again shortly. |
ACCOUNT_NOT_ACTIVE | 403 | The account is deactivated. Contact support. |
INVALID_LOOKUP_REQUEST | 400, not counted | Fix the body. A single call takes exactly one id. A batch takes 1 to 50 positions. |
STATUS_LOOKUP_RATE_LIMITED | 429 with Retry-After | Wait the number of seconds in Retry-After, then retry. Nothing was charged. |
TRANSACTION_NOT_FOUND, ATTEMPT_NOT_FOUND | 404 | No record this account can read. An id that belongs to another account answers the same. |
API_KEY_SCOPE_DENIED | 403 | The key's pipelines do not cover this record. Use a key whose scope covers it. |
TRANSACTION_REPLAY_UNAVAILABLE | 409, transactionID lookups only | retryable: false. Treat it as "cannot confirm now". |
STATUS_LOOKUP_UNAVAILABLE | 503 | Retryable. In a batch it can also mean the item was not attempted in this batch. Retry just those ids. |
STATUS_LOOKUP_FAILED | 500 | A generic message with no detail. Retry later. |
What data holds
The layout depends on the API version. Field meanings follow Webhooks → Verification result payloads.
V1, V1.2 OTP and V1.2 passkey return mainTransaction and transactionReq.
- Name
mainTransaction- Type
- object
- Description
The transaction record. Its
statusisPending,Successful,FailedorSkipped.
- Name
transactionReq- Type
- object | null
- Description
The code-check or sign-in record. It is
nullwhen there is no request. ItsstatusisPending,SuccessfulorFailed. Absent values are omitted. For OTP it includesinputOTP, the code the user typed. The code that was sent isOTPonmainTransaction(ontransactionfor V2). Keep the raw response body out of your logs.
V2 returns the same data shape as the V2 webhook, with no error object.
- Name
status- Type
- string
- Description
success,failedorpending. This is the result.
- Name
timestamp- Type
- string
- Description
Set again on every lookup. Do not use it as a key. Nothing promises it differs between lookups.
- Name
widgetAttempt- Type
- object
- Description
The attempt, with a masked phone number. Its
statusispending,captcha_solved,otp_requested,verified,failedorexpired. It shows progress and is never the result. Read the top-levelstatusinstead.
- Name
transaction- Type
- object | null
- Description
The transaction record.
- Name
transactionReq- Type
- object | null
- Description
The code-check or sign-in record.
- Name
publicMetadata, privateMetadata- Type
- object | null
- Description
The metadata you attached to the attempt.
verificationDate is the time of the last code check.
WhatsApp. On OTP layouts, whatsapp: { status } sits beside transactionReq. Its status is pending, sent, delivered, read or failed, or null. The status comes from the newest message linked to that exact transactionReq; it is null when there is no linked message or no request. A linked row with no stored value reads pending. null does not tell you the channel was unused. Passkey answers omit whatsapp.
Fields the webhook field list does not show. The send-record field list in Webhooks → Verification result payloads does not include these two. They can sit on both mainTransaction (or transaction on V2) and transactionReq.
- Name
verificationMethod- Type
- string
- Description
passkeyon passkey answers: on both objects for V1.2, ontransactionfor V2.
- Name
reason- Type
- string
- Description
On
transaction,expiredon an expired V1.2 or V2 passkey sign-in, ortemporarily_unavailableon V1.2 only. On V1.2,transactionReq.reasoncopiestransaction.reason. Otherwise both are absent.
Attempt lookups
An attempt lookup answers for a V2 widget attempt and returns entries: up to 10, newest first. Each entry has status, transaction, transactionReq and, on OTP entries, whatsapp. Entries carry no timestamp.
For V2 webhook handling, see Step 4: Handle Callbacks.
hasMoreEntries: false means every eligible record the lookup found is listed. Records left out under the best-effort rules below do not count. hasMoreEntries: true means more may exist, and fewer than 10 may be listed.
The top-level status is success if any listed entry is success, else failed if the newest listed entry is failed, else pending. With no entries it is pending. The top-level transaction, transactionReq and whatsapp come from the newest success entry, or else from the newest entry. A top-level whatsapp appears only when that entry is an OTP entry.
Attempt lookups are best effort. Records are not back-filled, and a record that cannot be safely tied to the attempt is left out. An empty pending answer does not tell you nothing happened. The records behind two entries can share a timestamp.
A passkey sign-in submitted twice can read failed while the winning submission is still pending. The losing submission is marked Failed, and when it is the newest entry the top-level status follows it. Treat failed on a passkey attempt as "not verified yet" and look again.
Attempt lookups never answer 409. While the attempt index is not ready, they answer a retryable 503 STATUS_LOOKUP_UNAVAILABLE.
Passkey sign-ins
Two reads exist, and they are separate. The lookup on this page uses your API key and runs on your backend. The ceremony poll, GET /api/v1.2/transactions/passkey/result, is authorized by the ceremony token, is called from the client, and is read-only. See the passkey endpoint reference for the poll.
A passkey requestID answers as a transaction, so put it in transactionID. On V1.2 it comes back in the V1 layout. On V2 it comes back in the V2 layout with transactionReq: null.
Successful appears only once the sign-in is verified and billed. While billing settles, it reads Pending. An expired sign-in reads Failed with reason: "expired".
Dates appear only when they are stored. The V1.2 passkey webhook stamps the time it was sent, so its date can differ from the stored date in a lookup. A V1.2 passkey transactionReq is built from status, verificationMethod and isTest, plus reason and verificationDate when they apply.
A pending sign-in that was abandoned answers 404 after about 5 minutes. Treat that 404 as "cannot confirm now".
What lookups do not cover
- Listing records by time window.
- Whether a webhook was delivered.
- Provider details.
- A flag that says a result will not change.
A lookup returns the current result. It does not show whether a webhook was sent or received.
Using lookups with webhooks
One function, two ways in. Write one function that applies a verification result to your records. Call it from your webhook route after the signature check, and from a reconciler that calls a lookup with your own API key. Never POST a lookup answer to your own webhook URL. Never run Svix verification on it. Never make up a signature, a svix-id or an event id. The reconciler runs on your server, so the key stays there.
Identity. Your business identity is the id you stored when you created the verification. For V2 that is widgetAttempt.attemptId. For V1 and V1.2 it is mainTransaction.transactionID, and for a V1.2 passkey sign-in it is the passkey requestID. Never key on timestamp, svix-id, updateDate or verificationDate.
What counts as success. For V2, success is data.status === "success": the top level of a transactionID or attempt lookup, or any entry you can see in entries. For V1 and V1.2, check data.mainTransaction.status === "Successful" || data.transactionReq?.status === "Successful". This is a reading rule for your code, not a field Akedly returns.
Success sticks on your record. Once you record a success for an attemptId or a transactionID, never let a later failed or pending result undo it on your record, whether it comes from a webhook or a lookup. Nothing in the API says a status cannot change, and a lookup can disagree later. A V1 parent transaction can read Failed after a success, an attempt can have more than 10 entries, and reads are not taken together as one moment.
Everything else is "not verified yet". Take no irreversible action on a result that is not a success. A V2 wrong code that did not lock the transaction is one example. Its webhook said failed, but a lookup reads pending, and the lookup has no error object. When you can see no success, a 404, 403, 409 or 503, empty entries, hasMoreEntries: true and whatsapp.status: null all mean "cannot confirm now".
A lookup is not a copy of a webhook. It is built from current records. On V2 answers, timestamp is set again on every lookup, so it is not a key. It never shows whether a webhook was sent or received.
Delivery dedupe stays separate. Your webhook handler still skips repeated deliveries. svix-id skips repeated deliveries of one message. It is not a business key, and failure delivery keys carry a random suffix. Key your records on the body fields above. See Webhooks → Retries.
A conditional update plus a side effect. Updating a record only when it is not yet verified, then sending an email, is not a single step. A crash between the update and the email can lose the email, and sending first can repeat it. If the side effect must survive a crash, write it in the same database transaction as the update, or write an outbox row there and send from that. In SQL, run an update limited to rows not yet verified and check the changed-row count. Akedly provides no library, queue or framework for this.
