Card Scanner API
Create a card-scan session for your users, then receive a webhook when
verification completes. This reference covers the two integration
points external clients need: POST /v1/sessions and the
session.completed webhook payload.
Overview
Integrating the card scanner is a two-step flow:
- Your backend calls
POST /v1/sessionsto mint a short-lived scan session for a givenflowId(the scan flow/branding configuration your SONIO contact provisions for you). - You hand the returned
continuationTokento your client (mobile app / web view), which loads the SONIO scanner UI and completes the capture. - When the session reaches a terminal state, SONIO POSTs a
session.completedwebhook to the URL configured for your flow, carrying the verification result.
There is no polling endpoint for session status in this version of the API — the webhook is the source of truth for the outcome. Use clientReferenceId (set at session creation) to correlate the webhook back to your own records.
Authentication
Every request to POST /v1/sessions must include an
X-API-Key header. Keys are issued per-integration by
SONIO and may optionally be scoped to a single flowId.
X-API-Key: <your-api-key>
flowId are rejected with 403 Forbidden. Unscoped keys may create sessions for any flow you've been granted access to.
Quickstart
Create a session and inspect the response:
curl -X POST https://api.card-scanner.sonio-group.com/v1/sessions \
-H "X-API-Key: sk_live_your_key_here" \
-H "Content-Type: application/json" \
-d '{
"flowId": "b6f1e9f0-2b7a-4b43-9b7a-4f1a2c9d3e10",
"clientReferenceId": "order_48213",
"locale": "en-US"
}'
{
"id": "0f3c2a1b-9d4e-4a7b-8c2d-1e5f6a7b8c9d",
"continuationToken": "6f1e9f02b7a4b439b7a4f1a2c9d3e10a1b2c3d4e5f60718293a4b5c6d7e8f90",
"flowId": "b6f1e9f0-2b7a-4b43-9b7a-4f1a2c9d3e10",
"status": "pending",
"expiresAt": "2026-10-07T23:40:00.000Z",
"startUrl": "https://card-scanner.sonio-group.com/?flow_id=b6f1e9f0-2b7a-4b43-9b7a-4f1a2c9d3e10&locale=en-US&sid=0f3c2a1b-9d4e-4a7b-8c2d-1e5f6a7b8c9d&tok=6f1e9f02b7a4b439b7a4f1a2c9d3e10a1b2c3d4e5f60718293a4b5c6d7e8f90"
}
Hand startUrl straight to a QR code or redirect to resume the scan session in the SONIO scanner UI — no need to assemble the URL yourself. Alternatively, use continuationToken directly if your client builds its own URL. Then listen for the webhook described below to get the result.
POST /v1/sessions 201
Creates a new card-scan session scoped to a flow.
Request body
| Field | Type | Required | Notes |
|---|---|---|---|
flowId | string (UUID) | Yes | The scan flow to run. Provided by SONIO when your integration is provisioned. |
clientReferenceId | string | No | Your own identifier (order id, user id, etc). Echoed back in the webhook payload so you can correlate results. |
locale | string | No | Locale hint for the scanner UI copy. |
Response fields
| Field | Type | Notes |
|---|---|---|
id | string (UUID) | Session identifier. |
continuationToken | string | Opaque token your client uses to resume/open the scan UI for this session. Treat as sensitive — it grants access to continue the session. |
flowId | string (UUID) | Echoes the request's flowId. |
status | string | Initial status (pending immediately after creation). |
expiresAt | string (ISO 8601) | Session expiry — the scan must complete before this time. |
startUrl | string (URL), nullable | Ready-to-use URL for the scanner web app — hand it directly to a QR code or a redirect. Carries flow_id/locale/sid/tok query params; locale is always resolved (your request's locale, or the flow's default — never blank). Does not carry clientReferenceId. null only if the server has no scanner web app base URL configured (should not happen in any deployed environment). |
Error codes
| Status | Body | Meaning |
|---|---|---|
| 400 | { "error": "flowId must be a valid UUID" } |
Request body failed validation (bad/missing flowId, clientReferenceId, or locale), or the flow itself is unknown/inactive on SONIO's side. |
| 401 | { "error": "Missing X-API-Key header" } / { "error": "Invalid API key" } |
No X-API-Key header was sent, or the key doesn't match any active key. |
| 403 | { "error": "API key is not authorized for this flow" } |
Your key is valid but scoped to a different flowId than the one requested. |
| 429 | { "error": "Too many requests", "message": "..." } |
Rate limit exceeded — see Rate limits. |
| 500 | { "error": "Failed to create session" } / { "error": "Session API key authentication unavailable" } |
Unexpected server-side failure. Safe to retry with backoff; contact SONIO support if persistent. |
Rate limits
POST /v1/sessions is rate-limited per source IP, on top of
the general API rate limit applied to all endpoints. Limits are
configured per environment and may change without notice; a
429 response includes RateLimit-* headers
indicating the window and remaining budget. Back off and retry after
the window resets rather than retrying immediately.
Webhook: session.completed
When a session reaches a terminal state (succeeded,
failed, document_not_accepted,
cancelled, expired, or error),
SONIO sends a single POST request with a JSON body to the
webhook URL configured for your flow.
Request
POST <your configured webhook URL>
Content-Type: application/json
{
"cardVerification": [
{
"provider": "SONIO",
"status": "COMPLETED",
"validation": "PASS",
"cardActive": true,
"score": "100.00",
"reason": "",
"reasonDetails": "",
"externalIds": { "interviewId": "pm_1Oa2b3C4d5E6f7G8" },
"attempts": 1,
"processedData": {
"creditcard": {
"ocr": {
"cardType": "personal",
"cardNumber": "424242******4242",
"expireAt": "12",
"expireYear": "2030",
"issuingNetwork": "Visa",
"cardHolder": "Jane Doe"
},
"imagesUrl": {
"frontCard": "https://.../front.jpg",
"backCard": "https://.../back.jpg"
}
}
}
}
]
}
{
"cardVerification": [
{
"provider": "SONIO",
"status": "COMPLETED",
"validation": "FAIL",
"cardActive": false,
"score": "0.00",
"reason": "document_not_accepted",
"reasonDetails": "Submitted card image was not accepted",
"externalIds": { "interviewId": "0f3c2a1b-9d4e-4a7b-8c2d-1e5f6a7b8c9d" },
"attempts": 1,
"processedData": {
"creditcard": {
"ocr": {
"cardType": "",
"cardNumber": "",
"expireAt": "",
"expireYear": "",
"issuingNetwork": "",
"cardHolder": ""
},
"imagesUrl": { "frontCard": "", "backCard": "" }
}
}
}
]
}
Field reference
| Field | Notes |
|---|---|
validation | "PASS" only when the session succeeded and card verification was confirmed; otherwise "FAIL". |
cardActive | The raw Stripe SetupIntent-verified boolean, independent of session status. A card Stripe verified fine on a session the user later abandoned/cancelled reports cardActive: true alongside validation: "FAIL" — use validation for the overall outcome and cardActive specifically for the card/Stripe result. |
reason / reasonDetails | Empty strings on a pass. On a fail, reason is a short code (session status or cancellation reason) and reasonDetails is a human-readable description. |
externalIds.interviewId | The Stripe payment method id when available, otherwise your session id. Use this (or your own session-id mapping) to match the webhook to the session you created — clientReferenceId is not present in this payload. |
processedData.creditcard.ocr.cardNumber | Truncated PAN: first six digits (BIN) + six literal asterisks + last four digits, e.g. "424242******4242" — the PCI DSS v4.0.1 Req 3.3.1-permitted truncation format. Empty string whenever either half wasn't captured (rejected scan, pre-SPWN-866 historical session, or a purged record). The full PAN is never included in the webhook payload — SONIO never persists or transmits the plaintext card number. |
processedData.creditcard.imagesUrl | URLs to the redacted (PAN-masked) front/back card images, if captured for your flow. |
Expected response
Your endpoint should respond with any 2xx status to
acknowledge receipt. Non-2xx responses or timeouts are retried up to
3 attempts total (roughly 1s then 5s apart), after
which the delivery is marked failed and not retried further. Make your
webhook handler idempotent — retries can resend the same payload.
Webhook security notes
- Only accepting requests on the exact URL you registered for your flow (treat the URL path as a shared secret — use a long, unguessable path segment).
- Allowlisting SONIO's outbound IP range at your firewall/load balancer, if provided by your SONIO contact.
- Validating that the
externalIds.interviewId/ session id in the payload matches a session you actually created viaPOST /v1/sessions, and ignoring anything that doesn't.
If your integration requires cryptographic payload signing (e.g. an HMAC signature header), contact your SONIO integration contact — this is on the roadmap but not yet available on this endpoint.