VeroID

Embedded document form

Embed a document form in your web app. Your server creates a session; your frontend mounts an iframe. Document details never touch your DOM or servers. Results arrive by webhook.

Developer accounts only. Use sk_test_* on https://test-api.veroid.com.au and sk_live_* on https://api.veroid.com.au (see Authentication). For SMS links with optional ID photo and live selfie, see Hosted verify sessions.

When the user submits, the embed session ends. The iframe confirms that the details were submitted and does not show the issuer result. That result arrives only on your webhook, or on GET /v1/verify/sessions/:sessionId.

Overview: embed vs hosted SMS

Embedded formHosted verify sessions (SMS)
DeliveryIframe in your app via JS SDKSMS link to mobile verify site
Data entryTyped fields in iframe onlyTyped fields; optional ID photos + live selfie
Consent orderConsent always firstDocument type may appear before consent when not preset
BrandingAppearance API + dashboard form builderStandard VeroID verify UI
Create APIPOST /v1/verify/embed-sessionsPOST /v1/verify/sessions

Consent-first flow

The embedded form always shows consent before document type selection or detail fields. The checkbox is unchecked by default; Continue stays disabled until the user actively agrees.

The consent statement is VeroID-controlled (consent version 1.0, document-only). It uses the same wording as hosted document checks: the user confirms they are authorised to provide the details and consent to their information being checked with the document issuer or official record holder via third-party systems for identity confirmation. You cannot remove or reword this text with the appearance API or dashboard labels. Only styling (colours, fonts, spacing) around it may change.

When the user agrees, VeroID records consentAttestedAt (UTC) and consentVersion on the session. Those values are included on completion webhooks. See Consent & IVS obligations.

Prerequisites

  1. Configure a verify-session webhook (HTTPS) and save the signing secret:
    PUT /v1/settings/verify-session-webhook
    { "webhookUrl": "https://your.app/webhooks/veroid" }
    → { "webhookSigningSecret": "whsec_..." }
    You can also add webhook destinations per saved embed configuration in the dashboard builder (one URL per environment). Signing still uses your account secret from this endpoint.
  2. Register allowed origins (exact HTTPS origins, no path) on the embed config or per create request so the iframe can load on your pages.

Create an embed session

POST /v1/verify/embed-sessions runs on your server with your API key. Store sessionId in your database; pass clientSecret to the browser SDK only. Use reference (optional, max 128 characters) for your own metadata. It is echoed on every webhook.

Document selection rules

  • documentType: fix a single type; user skips the picker. Do not send allowedDocumentTypes.
  • allowedDocumentTypes: non-empty array; user picks from that list only. Do not send documentType.
  • Omit both: user may pick from all supported types (see Document types).
  • Sending both fields, or an empty allowedDocumentTypes array, returns 400 INVALID_DOCUMENT_SELECTION.

Saved configuration (embedConfigId)

Publish a configuration in the dashboard Embedded form builder to get an ecfg_… id. Create sessions with embedConfigId to apply saved document rules, appearance, allowed origins, and webhook destinations. Optional per-session overrides:

  • appearance: deep-merged over the config; cannot override consent legal text.
  • allowedOrigins: replaces the config list for this session when provided.
  • documentType / allowedDocumentTypes: must stay within the config bounds (cannot widen the allowlist).
  • webhookEnvironment, webhookEndpointId, or webhookUrl: pick which saved webhook destination receives events for this session (see below). Use only one of these fields.

Webhook destinations per environment

In the embedded form builder, add one HTTPS webhook URL per environment (for example Local, Staging, Production). Each label gets a stable ewhe_… id when you save the form. Reuse the same embedConfigId everywhere and choose the destination when you create the session. VeroID never delivers to a URL that is not saved on that form.

  • webhookEnvironment: matches the label you set in the dashboard (case insensitive).
  • webhookEndpointId: matches the ewhe_… id shown in the builder.
  • webhookUrl: must exactly match a URL saved on the form (useful when you already store the URL in your own config).
  • Omit all three to use the endpoint marked Default in the builder. If the form has only one destination, that URL is used automatically.

Example: one published form, staging vs production webhooks:

// Staging server (sk_test_* on test-api)
POST /v1/verify/embed-sessions
{
  "embedConfigId": "ecfg_01HXYZ...",
  "consentVersion": "1.0",
  "reference": "staging-user-42",
  "webhookEnvironment": "Staging"
}

// Production server (sk_live_* on api)
POST /v1/verify/embed-sessions
{
  "embedConfigId": "ecfg_01HXYZ...",
  "consentVersion": "1.0",
  "reference": "prod-user-42",
  "webhookEnvironment": "Production"
}

Legacy forms with a single webhook URL on the configuration keep working: that URL becomes the default destination. Account-level webhook settings still apply when the form has no destinations.

Optional identity prefill

Optionally send a prefill object when creating the session. Each of givenName, middleName, familyName, dateOfBirth (full date YYYY-MM-DD only: day, month, and year), and gender (passport only) is independent. Send none, some, or all. Prefilled fields are not shown in the iframe and cannot be edited by the user; they are merged server-side with whatever the user types before the document is checked with the document issuer or official record holder. The public embed session response includes hiddenFieldKeys (field names only, never values). Invalid prefill returns 400 with code INVALID_PREFILL and a field-specific message.

For givenName, familyName, and dateOfBirth, null or an empty string is treated as not provided. The field stays on the form. Only non-empty values hide the field.

Middle name (driver's licence only). Australian driver's licences are the only embedded document type with a separate middle name field. Omit middleName when you want the form to ask for it. Send "middleName": null or "middleName": "" when the person has no middle name: the middle name field is hidden and the check runs without a middle name sent to the issuer. Send a non-empty string to hide the field and supply that value at submit.

Gender (passport only). On passport sessions, omit gender to show the optional gender field. Send "gender": null or "gender": "" when you do not collect gender: the field is hidden and the check runs without gender sent to the document issuer or official record holder. Send "M", "F", or "X" (case insensitive) to hide the field and supply that value at submit. On other document types, gender in prefill is accepted but ignored (those forms do not use gender).

If you store all given names in one field, send the first given name as givenName and any others as middleName. If you cannot split them, leave middleName out and the form will ask for it. On passports, the form label "Given name(s)" prompts the user to enter all given names including middle names as they appear on the document.

Document-specific fields still apply: for example Medicare continues to require full-name lines on the form even when given or family names are prefilled (those names apply only on document types that use separate name fields).

Request and response examples

No prefill (default):

POST https://test-api.veroid.com.au/v1/verify/embed-sessions
X-API-Key: sk_test_...
Content-Type: application/json
Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000

{
  "embedConfigId": "ecfg_01HXYZ...",
  "consentVersion": "1.0",
  "reference": "crm-8821"
}

Partial prefill (user only types remaining fields, e.g. passport number):

{
  "consentVersion": "1.0",
  "documentType": "passport",
  "allowedOrigins": ["https://app.example.com"],
  "prefill": {
    "familyName": "Citizen",
    "dateOfBirth": "1990-01-15"
  }
}

Full name and date prefill:

{
  "consentVersion": "1.0",
  "documentType": "drivers_licence",
  "allowedOrigins": ["https://app.example.com"],
  "prefill": {
    "givenName": "Jane",
    "middleName": "Q",
    "familyName": "Public",
    "dateOfBirth": "1985-06-20"
  }
}

No middle name (driver's licence: field hidden, check runs without middle name):

{
  "consentVersion": "1.0",
  "documentType": "drivers_licence",
  "allowedOrigins": ["https://app.example.com"],
  "prefill": {
    "givenName": "Jane",
    "middleName": null,
    "familyName": "Public",
    "dateOfBirth": "1985-06-20"
  }
}

No gender (passport: field hidden, check runs without gender):

{
  "consentVersion": "1.0",
  "documentType": "passport",
  "allowedOrigins": ["https://app.example.com"],
  "prefill": {
    "familyName": "Citizen",
    "dateOfBirth": "1990-01-15",
    "gender": null
  }
}

Prefilled gender (passport: field hidden, value merged at submit):

{
  "consentVersion": "1.0",
  "documentType": "passport",
  "allowedOrigins": ["https://app.example.com"],
  "prefill": {
    "familyName": "Citizen",
    "dateOfBirth": "1990-01-15",
    "gender": "F"
  }
}
HTTP 201
{
  "success": true,
  "sessionId": "6f1c0e3a-1b2c-4d5e-8f90-a1b2c3d4e5f6",
  "clientSecret": "embed_cs_abc123...",
  "expiresAt": "2026-10-26T12:30:00.000Z",
  "reference": "crm-8821",
  "status": "created",
  "consentVersion": "1.0",
  "embedUrl": "https://dev-embed.veroid.com.au"
}

embedUrl is the iframe host for the API that created the session. A sk_test_* session returns https://dev-embed.veroid.com.au and must be finished on https://test-api.veroid.com.au (test verification environment, no credits). A sk_live_* session returns https://embed.veroid.com.au and must be finished on https://api.veroid.com.au (live verification, one credit). Pass embedUrl as embedBaseUrl when you mount the iframe. Loading a test client secret on the live embed host returns 403 WRONG_API_ENDPOINT and does not call the issuer.

Poll GET /v1/verify/sessions/:sessionId if needed (same shape as hosted sessions; no PII in the response). Prefer webhooks for completion.

Idempotency-Key (recommended)

Send an Idempotency-Key header on every POST /v1/verify/embed-sessions so network retries do not create duplicate sessions or duplicate verification charges. Use a unique value per attempted check (a UUID works well). Maximum length is 255 characters.

Keys are scoped to your account, to test or live (from the API key), and to this create endpoint. They are retained for 24 hours. Reusing the same key with the same JSON body within that window returns HTTP 201 with the original response body (same sessionId and the same usable clientSecret) and sets Idempotent-Replayed: true. Reusing the key with a different body returns 422 with code IDEMPOTENCY_KEY_REUSED_WITH_DIFFERENT_PARAMS.

If a completion webhook is slow or missing, call GET /v1/verify/sessions/:sessionId before you retry create. When you do retry create for the same check, reuse the same Idempotency-Key.

Match webhooks to your records

When the check finishes, VeroID POSTs to your webhook URL. The JSON field id is the same sessionId from create. Match it to the row you stored at create time together with reference.

{
  "event": "verify_session.completed",
  "id": "6f1c0e3a-1b2c-4d5e-8f90-a1b2c3d4e5f6",
  "reference": "crm-8821",
  "status": "completed",
  "timestamp": "2026-10-26T04:12:00.000Z",
  "data": {
    "verificationId": "ver_abc123",
    "consentVersion": "1.0",
    "consentAttestedAt": "2026-10-26T04:05:12.000Z",
    "dvs": {
      "match": true,
      "responseCode": "Y",
      "documentType": "passport"
    },
    "livenessScore": null,
    "faceMatchScore": null
  }
}

Failed checks use verify_session.failed with the same id, reference, consent fields, and data.error (human-readable message). Other events: verify_session.opened, verify_session.expired, verify_session.cancelled.

Signature verification

Each delivery is an HTTP POST with a JSON body. Read the raw request bytes before parsing JSON. Verify using:

  • VeroID-Webhook-Timestamp: Unix time in seconds (string)
  • VeroID-Webhook-Signature: v1=<hex> (HMAC-SHA256, lowercase hex)
  • VeroID-Webhook-Event: e.g. verify_session.completed

Signed payload string: v1.<timestamp>.<raw-body> (exact UTF-8 body, no re-serialization). Use the full signing secret returned from PUT /v1/settings/verify-session-webhook, including the whsec_ prefix. Reject requests when the timestamp is more than five minutes from your server clock. Each event is delivered with up to three HTTP POST attempts: immediately, then after 2 seconds, then after 5 seconds if the previous attempt failed. A failure is any non-2xx response, network error, or a request that times out after 30 seconds. Retries run in the same API process; if that process restarts mid-delivery, the in-flight retry sequence is not resumed. A row in verify_session_webhook_deliveries is written after all attempts finish.

import crypto from "node:crypto";

function verifyVeroidWebhook(rawBody, timestamp, signatureHeader, signingSecret) {
  const expected = "v1=" + crypto
    .createHmac("sha256", signingSecret)
    .update(`v1.${timestamp}.${rawBody}`)
    .digest("hex");
  const ok = signatureHeader.split(",").some((part) => {
    const a = Buffer.from(part.trim());
    const b = Buffer.from(expected);
    return a.length === b.length && crypto.timingSafeEqual(a, b);
  });
  const tsMs = Number(timestamp) * 1000;
  const fresh = Number.isFinite(tsMs) && Math.abs(Date.now() - tsMs) <= 5 * 60 * 1000;
  return ok && fresh;
}

Add the form to your page

Two pieces. Put the script in the page <head>. Put the form markup where the form should appear. VeroID hosts the script, so there is no file to upload.

1. Page head

Add this once in <head>. It is the same on every page.

Test sessions (sk_test_*):

<script src="https://dev-embed.veroid.com.au/v1.js" async></script>

Live sessions (sk_live_*):

<script src="https://embed.veroid.com.au/v1.js" async></script>

2. Where the form should appear

Your server creates the session and returns clientSecret and embedUrl. The page mounts the form with that secret. Keep the API key on the server. The secret changes for each person and is what ties the form to your account.

<div id="veroid-document"></div>
<script>
  document.addEventListener("DOMContentLoaded", async function () {
    const res = await fetch("/api/veroid/embed-session", { method: "POST" });
    const session = await res.json();
    const form = VeroID.createDocumentElement({
      clientSecret: session.clientSecret,
      embedBaseUrl: session.embedUrl,
    });
    form.mount("#veroid-document");
    form.on("complete", function (ev) { console.log("Done", ev.sessionId); });
    form.on("error", function (ev) { console.error(ev); });
  });
</script>

Hosted script (recommended)

Load the embed SDK from VeroID's CDN. Use production or test URLs to match your API keys:

  • Production: https://embed.veroid.com.au/v1.js
  • Test: https://dev-embed.veroid.com.au/v1.js

The global VeroID.createDocumentElement API is the same as in the browser example above. An npm package is not published today; use the hosted script unless you vendor the SDK from the open-source repo yourself.

postMessage events

The SDK relays iframe events to your page. Namespace veroid-embed. Events include:

  • ready: iframe loaded and form interactive
  • resize: suggested height for the container
  • complete: sent when the form is submitted. status is submitted. This event does not include the issuer result.
  • error: public errorCode / message, no field values

Document numbers, names, dates of birth, and images are never sent via postMessage or the parent DOM.

Styling and branding (appearance)

Set appearance on create, on a saved embedConfigId, or pass partial overrides per session (colours and labels only, not the logo). Supported in v1:

  • colorScheme: light | dark
  • colors.*: primary, background, surface, text, inputText, border, danger, etc. (hex).dropdownText is still accepted as an alias for inputText.
  • typography: font family (allowlisted), base size, weights
  • layout: border radius, spacing unit, max width
  • buttons: solid vs outline primary
  • labels: form title, submit button, optional consentPreamble above (not replacing) the legal checkbox text
"appearance": {
  "colorScheme": "dark",
  "colors": {
    "primary": "#6366F1",
    "primaryForeground": "#FFFFFF",
    "background": "#0F172A",
    "surface": "#1E293B",
    "text": "#F8FAFC",
    "inputText": "#F8FAFC",
    "border": "#334155"
  },
  "layout": { "borderRadius": "10px", "spacingUnit": "4px" },
  "labels": { "submitButton": "Continue" }
}

Cannot be changed: consent checkbox legal wording; field schemas and issuer check behaviour. Appearance is applied via CSS variables. Arbitrary CSS or HTML from the platform is rejected.

Logo and white-label

In the dashboard Embedded form builder, the Logo section controls branding for that form only (not per session):

  • No logo (default): white-label. No custom logo and no VeroID footer on the embedded form.
  • Upload your logo: upload PNG, JPG, WebP, or SVG (max 1 MB). Files are validated by content; SVG is converted to a safe raster image. The logo is stored in VeroID-hosted storage in Australia and served through our API, not via a third-party URL field. Alt text uses your company name.
  • VeroID branding: shows the small Checked by VeroID footer with no custom logo.

Save the draft after changing logo settings, then publish. Replacing or removing an upload deletes the previous file. Live embed sessions load the logo from GET /v1/verify/embed-hosted/:clientSecret/logo when upload mode is published.

Allowed origins and CSP

Each embed session is tied to an allowlist of parent origins. The iframe response sets Content-Security-Policy: frame-ancestors … to those origins. On your site, allow frame-src for the embedUrl on the session (https://dev-embed.veroid.com.au in test, https://embed.veroid.com.au in live).

If the parent origin is not allowlisted, the iframe refuses to load and the SDK emits error.

Dashboard: Embedded form builder

In the developer dashboard, open Embedded forms (under API / developer tools) to:

  1. Create a configuration: the title people see, logo (optional upload), the documents they can use, button, background, text, and dropdown text colours, and the websites allowed to embed it.
  2. Preview: the consent step is shown beside the editor and updates as you change the colour, title, and documents. A website address is not required to preview.
  3. Publish: increments version; new sessions use the latest published snapshot; in-flight sessions keep the version they started with.
  4. Integrate: copy embedConfigId, frontend script/npm snippet, and backend POST /v1/verify/embed-sessions example.
Embedded form builder split view with live preview

The builder combines draft settings (documents, appearance, allowed origins) with a live preview iframe. Publishing increments the version used by new sessions.

Themed embedded form on the consent step

Screenshots below show additional end-user embed steps (mock data, mobile width).

  • 1. Consent (always first)

    The checkbox starts unchecked. The legal consent text is fixed by VeroID and cannot be reworded by your theme. The user must agree before any document type or fields appear.

    1. Consent (always first)
  • 2. Document type (when not fixed at create)

    If you did not set documentType on the session, the user picks from your allowed list or from all supported types.

    2. Document type (when not fixed at create)
  • 3. Document details

    Typed fields only. No photo upload and no live selfie in the embedded form. Details are checked with the document issuer or official record holder.

    3. Document details
  • Validation errors

    Correctable problems keep the user on the form with a short message.

    Validation errors

Test and sandbox

Use sk_test_* keys against https://test-api.veroid.com.au and mount https://dev-embed.veroid.com.au. Use sk_live_* keys against https://api.veroid.com.au and mount https://embed.veroid.com.au. Both call the real issuer hub for that API. The test hub uses issuer test records. See Test environment. Embed sessions expire after a short TTL (typically 15 to 30 minutes). Configure a test webhook URL on the dashboard before creating sessions.

Errors and troubleshooting

Symptom / codeWhat to check
422 webhook not configuredRun PUT /v1/settings/verify-session-webhook or set URL on embed config.
INVALID_DOCUMENT_SELECTIONSend only one of documentType or allowedDocumentTypes; array non-empty.
Iframe blank / SDK errorParent origin in allowedOrigins; CSP frame-src; clientSecret not expired.
Fields rejected before submitUser must complete consent first; session expired.
403 WRONG_API_ENDPOINTTest keys and test embed host on test-api; live keys and live embed host on api (see Errors).
Webhook signature failsUse raw body; correct whsec; clock skew within tolerance.