Server + redirect

Soliton Identity Verification

Verify a live human in three calls.

Your server creates a verification, your user completes it on our hosted page, your server fetches the verdict. You never handle video, biometrics, or capture UI.

01

How it works

Base URL https://api.soliton.id. Every server-to-server call carries your API key.

1

Create

Your backend calls POST /api/v1/verifications with your API key and chosen liveness_tier, and receives a verification_id and a one-time hosted url.

2

Hand off

Redirect your user to the url. (Prefer to keep them on your page? The Web SDK embeds the same flow instead.)

3

Capture

The user completes a short camera capture on the hosted page. Medium adds sound, and High adds sound and light. Up to three attempts within the link's lifetime.

4

Verdict

Your backend fetches GET /api/v1/verifications/{id}. The verdict is only ever released server-to-server against your API key.

Authentication header
Authorization: Bearer sk_live_...

02

Create a verification

One POST mints a one-time hosted URL for your user.

Request
curl -X POST https://api.soliton.id/api/v1/verifications \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{
        "liveness_tier": "high",
        "client_reference_id": "user-8412",
        "redirect_url": "https://app.example.com/verify/done"
      }'
Response
{
  "verification_id": "1de9a648-...",
  "liveness_tier": "high",
  "url": "https://soliton.id/v/6f2c9be1...",
  "expires_at": "2026-07-16T18:30:00Z",
  "environment": "production",
  "result_source": "biometric",
  "test_fixture_scenario": null
}
  • liveness_tier (optional, defaults to high): standard, medium, or high. Choose on your server for this verification; all three work with both key types and both integration methods. The response echoes your choice. Create a new verification to change it.
  • client_reference_id (optional, max 256 chars): your identifier for the user or case; echoed back on status reads.
  • redirect_url (optional): where we send the user afterwards. Its host must be equal to a redirect domain registered for the environment of the key you are using, or be its subdomain (ask us to register yours). Redirect domains and Web SDK iframe origins are separate allowlists; registering one does not register the other. If omitted, the user sees a “you may close this window” screen instead.
  • test_fixture_scenario (sandbox only, optional): pass, fail, or partial. Omit it and a sandbox key creates an ordinary capture; rejected with an sk_live_ key.
  • environment: sandbox or production, decided by your key's prefix. Echoed on every status read and webhook.
  • result_source: biometric for a real capture, synthetic_test_fixture for a test fixture. Present here, on every status read, and on every webhook, so a synthetic result can never be mistaken for a real one. It is independent of environment: a sandbox verification is biometric unless you asked for a fixture.
  • The url is single-purpose and expires after 1 hour. Create a fresh verification per attempt; do not reuse links across users.

Choose the liveness tier

The same endpoint selects liveness for server + redirect and Web SDK integrations. Set liveness_tier in the JSON creation body, then redirect to the returned URL or pass it to open({ url, onComplete }). Do not add a tier to the URL or SDK options. Existing SDK installations already open every tier.

Send {"liveness_tier":"standard"} for a camera-only verification with no sound or light emitted. Use medium to add sound reflection to the camera check. The default, high, uses sound and light reflection.

Both sandbox and production keys accept all three tiers. See the metrics comparison to choose one. Hosted links remain /v/<token> and open /capture. Company demos open /sandbox/<company>/capture; first-party demos open /demo_v1/capture. A copied demo capture link with no saved setup returns to its liveness chooser. The tier is carried in session state and enforced by the backend, without a tier query parameter in the capture link.

All tiers use positioning, anti-spoof and deepfake checks, and capture-integrity checks. Standard requests only the camera, uploads no audio, and uses targeted visual processing. Medium and High also request microphone access and provide speaker-volume guidance.

Standard returns either verified or not verified, with partial_verification: false in every completed result. Passing its required model and capture-integrity checks with sufficient footage gives a verified result; unavailable or inconclusive camera-control evidence does not downgrade it.

Missing or failed required models, fewer than 30 decoded frames, less than two seconds for Standard (1.8 seconds for Medium and High), and fraud vetoes block verification in every tier. Medium and High retain their liveness rules and partial outcomes, including High’s camera-only passes. Those partial results use verified: true, partial_verification: true and remain your risk-policy decision.

The stored tier applies to every retake and appears on create, claim, status, results, fixtures and webhooks. Conflicting browser inputs receive 409; invalid values receive 422. Hosted redirects and SDK messages are unchanged. Usage is reported by tier and environment.

Store the requested tier with the verification id on your backend. Before accepting a completed result, require its liveness_tier to match that stored value; reject missing or mismatched tiers. Read it from the authenticated status endpoint or a signature-verified completion webhook. The backend refuses to complete a hosted verification with a different capture tier.

03

Handle the return redirect

On completion (pass or final fail) we send the user to {redirect_url}?vid={verification_id}.

The redirect carries no verdict, only the id. Treat it purely as a “check now” signal: anyone could type that URL, so never grant access based on the redirect alone. In Web SDK embed mode there is no redirect; the onComplete callback is the equivalent signal, under the same rules.

04

Fetch the verdict

Poll from your backend, typically after the return redirect. Cheap and rate-limited per key at 60 requests/minute.

Request
curl https://api.soliton.id/api/v1/verifications/1de9a648-... \
  -H "Authorization: Bearer sk_live_..."
Response
{
  "verification_id": "1de9a648-...",
  "client_reference_id": "user-8412",
  "status": "completed",
  "liveness_tier": "high",
  "verified": true,
  "partial_verification": false,
  "created_at": "2026-07-16T17:30:00",
  "completed_at": "2026-07-16T17:32:41",
  "environment": "production",
  "result_source": "biometric",
  "test_fixture_scenario": null,
  "portrait_available": true
}
created

The link has not been opened yet.

in_progress

The user is in the flow.

completed

Terminal. verified and partial_verification are now set.

expired

The hour passed without completion.

verified: true, partial_verification: false

The tier’s liveness rule and required model checks passed, with no fraud veto.

verified: true, partial_verification: true

Some liveness evidence passed, but the result did not meet the selected tier’s full verification criteria. Treat partial results according to your risk policy.

verified: false

Could not verify a live user. Includes detected spoofs and injection attacks; the reason is deliberately not disclosed.

portrait_available: whether a portrait of the user can be fetched (below). false until status is complete, and stays false when no acceptable portrait frame existed. In the first seconds after completion it can read false while extraction is still running; once status is complete you can simply try the portrait endpoint (it finishes the extraction on demand) and treat a 404 after a short retry as permanently unavailable.

05

Fetch the portrait (optional)

One JPEG frame of the user, selected server-side for quality, for your own records or manual review.

Request
curl https://api.soliton.id/api/v1/verifications/1de9a648-.../portrait \
  -H "Authorization: Bearer sk_live_..." \
  -o portrait.jpg

Returns image/jpeg: one portrait frame of the user, selected server-side under neutral lighting (before the light challenge starts). The exact pipeline, so you can calibrate against it:

  • Selection. The search window is strictly pre-challenge, bounded by a client-authoritative frame index, so the frame is always neutral-light and cannot be hand-picked by an attacker. Candidates are scored on face geometry (size, centering) and sharpness (Gaussian pre-blur, then variance of Laplacian).
  • Crop geometry: none. The response is the full camera frame at its captured aspect ratio — not a face crop. Face geometry only chooses the frame.
  • Resize: none. Delivered at the captured resolution, never downscaled or upscaled.
  • Encoding. libjpeg via OpenCV, quality 95, baseline, 4:4:4 chroma (no subsampling).
  • Colour. Encoded from BGR with no ICC profile and no EXIF. Treat it as untagged sRGB.

Resolution is whatever the user’s browser and camera delivered, typically 1280×720. It can be lower on constrained devices; it is not higher, because the capture requests 720p. Expect roughly 60–160 KB per frame depending on scene detail.

One thing to weigh if you run forensic analysis on these frames: the portrait is extracted from the recorded video, so it has already been through the client’s H.264 encode before this stage. The pipeline is deliberately near-lossless on top of that — measured against the decoded frame it retains ~99% of high-frequency energy — but it cannot recover detail the video codec removed. Also usable simply for your own records or manual review, e.g. matching against an ID document.

200

The JPEG body.

404

Portrait not available: the verification is not yet complete, or extraction found no acceptable face frame. Once complete, a fetch is always safe; a 404 that persists after one short retry means no portrait exists for this verification.

401 / 429

Same auth and rate-limit behavior as the status endpoint.

06

Completion webhooks (optional)

Be told the moment a verification reaches its final verdict, instead of polling.

Ask us to enable webhooks for your account: we generate a signing secret, show it to you once, and activate delivery only after you confirm your receiver accepts it. Your endpoint must be https with a publicly valid certificate.

What we send
POST /your/webhook/path HTTP/1.1
Content-Type: application/json
User-Agent: Soliton-Webhooks/1.0
X-Soliton-Event: verification.completed
X-Soliton-Event-Id: 8f14e45f-...
X-Soliton-Timestamp: 1785636000
X-Soliton-Signature: v1=6b3a...

{
  "event_id": "8f14e45f-...",
  "type": "verification.completed",
  "occurred_at": "2026-07-16T17:32:41Z",
  "data": {
    "verification": {
      "verification_id": "1de9a648-...",
      "client_reference_id": "user-8412",
      "status": "completed",
      "liveness_tier": "high",
      "verified": true,
      "partial_verification": false,
      "created_at": "2026-07-16T17:30:00Z",
      "completed_at": "2026-07-16T17:32:41Z",
      "environment": "production",
      "result_source": "biometric",
      "test_fixture_scenario": null
    }
  }
}
  • Top-level fields describe the event; data.verification describes the verification. The only top-level keys are event_id, type, occurred_at, and data.
  • event_id is the same value as the X-Soliton-Event-Id header, and is what you deduplicate on. The verification id lives one level down, at data.verification.verification_id.
  • occurred_at is when the event happened, which is not the same as data.verification.created_at (when the verification was created).
  • client_reference_id is echoed back from your create call, so it is usually the cleanest way to join an event to your own record without a second lookup.

verification.completed is currently the only event type. Ignore types you do not recognise rather than erroring, so we can add events without breaking you.

Verify the signature

X-Soliton-Signature is v1= followed by the hex HMAC-SHA256 of {timestamp}.{raw request body}, keyed with your whsec_ secret. Sign the raw bytes, before any JSON parsing: re-serializing the parsed body changes key order and whitespace and will not match. Reject anything that fails — unsigned or wrongly-signed requests are not from us.

Node.js
const crypto = require("crypto");

// rawBody must be the unparsed request body. Re-serializing the parsed
// JSON changes key order and whitespace, and will never match.
function verify(secret, rawBody, timestamp, signature) {
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
  const expected =
    "v1=" +
    crypto.createHmac("sha256", secret)
      .update(timestamp + ".").update(rawBody)
      .digest("hex");
  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
}
Python
import hashlib, hmac, time

# raw_body must be the unparsed request body.
def verify(secret: str, raw_body: bytes, timestamp: str, signature: str) -> bool:
    if abs(time.time() - int(timestamp)) > 300:      # reject replays
        return False
    expected = "v1=" + hmac.new(
        secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, signature)  # constant time

Responding, retries, and duplicates

  • Respond 2xx quickly. Anything else — or no response within 5 seconds — is a failure and we retry. Acknowledge first, do your work after.
  • Retries use exponential backoff (about 1 s, 2 s, 4 s … capped at 1 hour) for up to 24 hours, then stop. The body and X-Soliton-Event-Id are byte-identical on every attempt.
  • Delivery is at-least-once, so you will occasionally see duplicates — after a network timeout where your receiver actually succeeded, or briefly after a secret rotation. Deduplicate on X-Soliton-Event-Id and make your handler idempotent. Do not use verification_id as the dedupe key.
  • Order is not guaranteed. A retried event can arrive after a newer one.
  • We follow no redirects: a 3xx counts as a failure. Register the final URL.

Rotating and disabling

Ask us to rotate the signing secret at any time. We show you the new secret first and activate it only once you confirm — but keep accepting the previous secret for a few minutes afterwards, because a delivery already in flight can still arrive signed with the old one. Undelivered events are re-queued under the new secret with their original event ids. Disabling cancels every event that has not been delivered, including ones that already exhausted their 24-hour window, so re-enabling later never floods you with stale completions.

07

Sandbox

A full, separate copy of the product pointed at your staging configuration.

sk_test_ credentials run the sandbox environment. A create call with no test_fixture_scenario behaves exactly like a production one — camera capture, liveness and anti-deepfake processing, a real portrait — but validated against your sandbox redirect domains and sandbox iframe origins, and delivered to your sandbox webhook endpoint.

Real sandbox capture
curl -X POST https://api.soliton.id/api/v1/verifications \
  -H "Authorization: Bearer sk_test_..." \
  -H "Content-Type: application/json" \
  -d '{
        "liveness_tier": "standard",
        "client_reference_id": "user-8412",
        "redirect_url": "https://dev.example.com/verify/done"
      }'

This example selects Standard. Replace it with medium or high to test those tiers. The response echoes the tier with environment: "sandbox". To go live, use your production key and registered production origins while keeping the same tier and integration method.

Sandbox is also the only place you can ask for a fixed outcome, which is how you regression-test the branches that are awkward to trigger with a real capture.

Deterministic fixture
curl -X POST https://api.soliton.id/api/v1/verifications \
  -H "Authorization: Bearer sk_test_..." \
  -H "Content-Type: application/json" \
  -d '{
        "liveness_tier": "medium",
        "client_reference_id": "user-8412",
        "test_fixture_scenario": "partial"
      }'
pass

verified: true, partial_verification: false

partial

verified: true; partial_verification: true for Medium / High, false for Standard

fail

verified: false, partial_verification: false

A fixture's url opens a hosted page that never requests camera or microphone permission, never records anything, and completes to the outcome you asked for. It is clearly marked as synthetic on screen and carries synthetic_test_fixture everywhere. Redirects, Web SDK embedding, onComplete, status reads, portraits, and webhooks all behave exactly as they do for a real capture, so you can exercise your whole integration — including the partial branch, which is awkward to trigger with a real capture. The portrait endpoint returns a fixed, visibly synthetic image, never a person.

The separation is enforced by the API and fails closed: sk_live_ with a scenario returns 403, because fixtures are sandbox-only; a production key reading a sandbox verification or a sandbox key reading a production one returns 404 even for the same account; a fixture link cannot enter the capture pipeline; and a redirect_url valid in one environment is rejected in the other unless it is registered there too.

08

Errors

Uniform across the v1 API.

401

Missing or invalid API key.

404

Unknown verification id (or one belonging to another customer, or to your other environment).

400

On create: invalid redirect_url — it must be https and on a domain registered for this key's environment.

403

On create: an sk_live_ key asked for a test_fixture_scenario. Fixtures are sandbox-only; production credentials cannot produce synthetic results.

422

On create: request body failed validation (invalid liveness_tier, malformed JSON, client_reference_id over 256 characters, redirect_url over 2048).

429

Rate limit exceeded; back off and retry.

09

Security model, in short

What keeps the verdict trustworthy.

The entry URL is a capability

Single verification, 1-hour expiry, dead after completion. Create a fresh verification per attempt; never reuse links across users.

Verdicts only against your API key

Never to your page, the SDK callbacks, or the redirect. The hosted screen tells the end user how their own attempt went, with no detail about which checks ran or why; your integration must treat only the server-to-server verdict as real.

The capture page always runs on soliton.id

Anti-injection and anti-deepfake defenses update continuously without any deployment on your side.