# VenueGo integration pack

Give this entire file to a coding agent that already has your SaaS repository. The first section is the task. The sections after it are the API reference. Implement only what is written here.

The check reads a passport or driving licence in the camera. It does not look up a government database and it does not read a chip. A clear result is not proof a document is genuine.

# Add VenueGo to your application

> Instructions for a coding agent adding VenueGo age and identity checks to an existing signup, membership, or checkout.

Canonical page: https://venuego.co.uk/docs/implement

Give this page to the coding agent that already has your repository. It should change your signup or checkout. It should not rebuild VenueGo, and it should not put an API key in the browser.

The file is one markdown document: this task, then the API reference (authentication, sessions, webhooks, results, and errors). Paste it into the agent, or save it in the repo as `VENUEGO.md` and tell the agent to follow it.

## Task

You are adding VenueGo to an existing SaaS application. VenueGo checks a guest's age and identity on their phone when they sign up or buy. Your job is to start that check from the server you already have, send the guest to the URL VenueGo returns, and grant access only when a signed webhook or a GET says `approved`.

Read this task, then [Authentication](https://venuego.co.uk/docs/authentication), [Sessions](https://venuego.co.uk/docs/sessions), [Webhooks](https://venuego.co.uk/docs/webhooks), [Results](https://venuego.co.uk/docs/results), and [Errors](https://venuego.co.uk/docs/errors), before you edit code. In the downloaded pack those pages follow this task. Use only the endpoints, fields, and status values written there. If something is not specified, leave it out.

The check reads a passport or driving licence in the camera. It does not look up DVLA, the passport office, or any other government database, and it does not read a passport chip. Do not write copy that says it does. A clear result is not proof a document is genuine. Documents in scope are United Kingdom (`GB`), Ireland (`IE`), and United States (`US`) passports (photo page) and driving licences (front and back).

## Where it sits

Find the server-side place that creates a signup, membership, or order. That record needs a stable id you control. After that record exists, create one VenueGo session with `sessionId` set to that id.

Do this on the server. The guest's browser only receives the `url` from the create response. On a desktop, VenueGo shows a QR code so the guest continues on their phone. Do not embed a camera in your app. Do not upload images to the VenueGo API.

Base URL: `https://verify.venuego.co.uk/api/v1`. The same API is on `verify.venueora.com`.

## What to store

On the signup, membership, or order, store:

- `sessionId` you sent (your own id)
- `url` from the response, so you can send the guest back to the same check
- `status` from the latest webhook or GET
- when that status last changed

Keep the API key and the webhook secret in the server environment. Suggested names: `VENUEGO_API_KEY` and `VENUEGO_WEBHOOK_SECRET`. Never ship either to the client, a mobile app, or a ticket email.

## What to build

1. **Create.** `POST /api/v1/verification-sessions` with `Authorization: Bearer` and a JSON body. `sessionId` is required, 1–191 printable ASCII characters, no spaces. Prefix it if your id would not fit, and store the value you sent. Pass `minimumAge` when this signup has its own age. Otherwise the account default is used. A guest under that age is declined and cannot retry.
2. **Send the guest to `url`.** A button or redirect is enough. Tell them the check takes about two minutes on their phone, and that they need a UK, Irish, or US passport or driving licence.
3. **Webhook.** Add a public HTTPS endpoint. Read the raw body bytes before you parse JSON. Verify `X-VerifyMe-Signature` with the endpoint secret (`whsec_…`) if you registered the URL in Developers, or with the account's per-session secret if you passed `webhookUrl` on the session. Reject timestamps more than 5 minutes off. Reply `2xx` within 10 seconds. Use the event `id` so a retry does not grant access twice.
4. **Grant access only on `approved`.** Update your stored status from `data.status`. `needs_review` means a person still has to decide. Do not grant access, and do not tell the guest they failed. `declined` means do not grant access. `decision.retryableStep` means the guest can try that step again while attempts remain.
5. **Revoke a later decline.** `verification.decision_changed` can arrive after an approval. If a later event declines, remove the access you granted.
6. **Confirm before you trust the browser.** `successUrl` and `failureUrl` are optional pages for the guest. Anyone with the link can open them. Before you unlock a paid feature, read the webhook you verified, or `GET /api/v1/verification-sessions/:sessionId`.
7. **Same `sessionId` is the retry key.** A new session returns `201` and `"created": true`. Repeating an open id returns `200` and `"created": false` with the current payload. A finished id returns `409` `session_closed`: create a new id. A `5xx` or a timeout on create is safe to retry with the same id. `402` means the VenueGo account has used its allowance. Show that to the operator, not as a guest failure.
8. **Cancel** an open session if the signup or order is abandoned: `POST /api/v1/verification-sessions/:sessionId/cancel`. A finished session returns `409` `not_open`.

## What not to do

- Do not call any URL that is not in this reference.
- Do not send the API key from the guest's phone.
- Do not treat `successUrl` as the result.
- Do not approve the guest locally while status is `needs_review`, `processing`, `in_progress`, or `created`.
- Do not describe the check as a government database lookup, a chip read, or proof the document is genuine.
- Do not put document numbers in `metadata`. Metadata comes back on the webhook. It is not shown to the guest.

## Done when

- Creating a signup or order on the server creates or reuses one VenueGo session and stores `url` and `status`.
- The guest can open `url` from your product. Your UI does not ask for the camera.
- The webhook verifies the signature, ignores duplicate event ids, and updates status.
- Access is granted only for `approved`, and removed if a later event declines.
- The API key and webhook secret are read from the server environment.
- A repeated create with the same open `sessionId` does not create a second check.
- Guest-facing copy names the document types and does not claim a government lookup.

---

# Start a verification

> Create a check from your signup or ticketing system, send the guest to their phone, and take the result from a signed webhook.

Canonical page: https://venuego.co.uk/docs

VenueGo reads a passport or driving licence in the camera. It does not look up DVLA, the passport office, or any other government database, and it does not read a passport chip. Documents in scope are UK, Irish and US passports (photo page) and driving licences (front and back).

The API base URL is `https://verify.venuego.co.uk/api/v1`. The same API is served on `verify.venueora.com`. Call it from your server. Never put an API key in a browser or app.

## How a check moves

1. Your server creates a session with your own `sessionId`. The response includes a `url`.
2. You send the guest to that URL. On a desktop they see a QR code and continue on their phone. The check takes about two minutes: face capture, the document, then a photo holding it.
3. VenueGo posts a signed webhook as the status changes. The same body is available from `GET /verification-sessions/:sessionId`.
4. A clear result is `approved` or `declined`. Anything uncertain is `needs_review` until your team, or VenueOra, decides.

A session stays open for 24 hours and allows 3 attempts. Repeating the same `sessionId` while it is still open returns that session. Once it has finished, that id returns `409` and `session_closed`, and you need a new `sessionId`.

## Create one

```bash
curl https://verify.venuego.co.uk/api/v1/verification-sessions \
  -H "Authorization: Bearer $VERIFYME_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "sessionId": "order_8412",
    "minimumAge": 18,
    "webhookUrl": "https://example.com/webhooks/venuego",
    "successUrl": "https://example.com/checkout/verified",
    "failureUrl": "https://example.com/checkout/not-verified",
    "metadata": { "customerId": "cus_1043", "ticketType": "VIP" }
  }'
```

A new session returns `201` and `"created": true`. Repeating the same `sessionId` returns `200` and `"created": false` with the current payload.

```js
const res = await fetch("https://verify.venuego.co.uk/api/v1/verification-sessions", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.VERIFYME_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    sessionId: order.id,
    minimumAge: 18,
    successUrl: `https://example.com/orders/${order.id}/verified`,
    failureUrl: `https://example.com/orders/${order.id}/not-verified`,
    metadata: { customerId: order.customerId },
  }),
});
const session = await res.json();
// Send the guest to session.url. Do not trust a later browser redirect on its own.
```

## What to build next

- [Add to your app](https://venuego.co.uk/docs/implement) if a coding agent is wiring this into an existing signup or checkout.
- [Authentication](https://venuego.co.uk/docs/authentication) for API keys.
- [Sessions](https://venuego.co.uk/docs/sessions) for every field, idempotency, and cancel.
- [Webhooks](https://venuego.co.uk/docs/webhooks) for the signature and retries.
- [Results](https://venuego.co.uk/docs/results) for status, identity, and reason codes.

> Confirm the outcome from the webhook or a GET. `successUrl` and `failureUrl` are conveniences for the guest's browser. They can be opened by anyone who has the link.

---

# Authentication

> Server requests use a bearer token created in the VenueGo portal. The token is shown once.

Canonical page: https://venuego.co.uk/docs/authentication

Send the key on every request:

```http
Authorization: Bearer vm_live_...
```

Keys look like `vm_live_…` or `vm_test_…`. Both call the same production API. The prefix is there so you can tell a key's purpose in your own systems. VenueGo stores only a hash. If you lose a key, revoke it and create another.

Create and revoke keys under Developers in the portal. Treat the key like a password: keep it on your server, not in a mobile app, a front-end bundle, or a ticket email.

## A missing or revoked key

The response is `401` with this body:

```json
{
  "error": {
    "code": "unauthenticated",
    "message": "This API token is invalid or has been revoked."
  }
}
```

There is no session cookie on `/api/v1`. Browser logins are a different surface and cannot call these routes.

## Where the key is allowed

- Your backend, when creating, reading, or cancelling a session.
- A secret store or environment variable on that server.

Do not send the key from the guest's phone. The guest uses the `url` from the create response, which is already scoped to that one session.

---

# Sessions

> Create a verification, read it back, or cancel it while it is still open. Your sessionId is the idempotency key.

Canonical page: https://venuego.co.uk/docs/sessions

## Create or return

`POST /api/v1/verification-sessions`

Creating a session past the plan allowance returns `402`. There is no metered overage. See [Errors](https://venuego.co.uk/docs/errors).

| Field | Required | Meaning |
| --- | --- | --- |
| `sessionId` | Yes | Your reference, 1–191 printable ASCII characters, no spaces. One verification per id. While it is still open, sending it again returns that session. A finished id returns `409` and `session_closed`. |
| `minimumAge` | No | Integer from 13 to 99. Otherwise the account default is used. A guest under this age is declined and cannot retry. |
| `webhookUrl` | No | Public HTTPS URL for this session only. Signed with the per-session secret from Developers, not an endpoint's secret. |
| `metadata` | No | Up to 50 keys. Values are strings up to 500 characters, finite numbers, booleans, or null. Keys are 1–40 characters: letters, digits, `_ . : -`. The object must be 8 KB or less. It is returned unchanged and is never shown to the guest. Do not put document numbers in it. |
| `successUrl` | No | Where the guest's browser goes after approval. HTTPS, or localhost in development. No embedded username or password. |
| `failureUrl` | No | Where the browser goes after a decline with no retry left, or when the session is expired or cancelled. |

When a redirect is used, VenueGo adds `verifyme_session_id` and `verifyme_status` to the query string. Still confirm the result with the API or a webhook.

## Response

`201` when the session is new, `200` when that `sessionId` already exists. The body is the verification, plus `created`.

```json
{
  "created": true,
  "sessionId": "order_8412",
  "publicId": "a1b2c3…",
  "url": "https://verify.venuego.co.uk/v/a1b2c3…",
  "status": "created",
  "step": "face",
  "stage": "waiting_for_phone",
  "attempt": 1,
  "minimumAge": 18,
  "metadata": { "customerId": "cus_1043", "ticketType": "VIP" },
  "successUrl": "https://example.com/checkout/verified",
  "failureUrl": "https://example.com/checkout/not-verified",
  "country": null,
  "documentType": null,
  "decision": null,
  "identity": null,
  "checks": [],
  "createdAt": "2026-10-07T16:00:00.000Z",
  "updatedAt": "2026-10-07T16:00:00.000Z",
  "expiresAt": "2026-10-08T16:00:00.000Z"
}
```

Send the guest to `url`. You do not upload images. The phone uploads straight to storage. Video is up to 40 MB (`video/webm` or `video/mp4`). Images are up to 8 MB JPEG.

## Read

`GET /api/v1/verification-sessions/:sessionId`

Returns the same payload without `created`. Unknown ids return `404` and `not_found`. Identity fields are present once a result exists. URL-encode `sessionId` if it contains characters such as `/` or `+`.

```bash
curl https://verify.venuego.co.uk/api/v1/verification-sessions/order_8412 \
  -H "Authorization: Bearer $VERIFYME_API_KEY"
```

## Cancel

`POST /api/v1/verification-sessions/:sessionId/cancel`

Cancels a session that is still open: `created`, `in_progress`, `processing`, or `declined` while a retry is still available. A finished session returns `409` and `not_open`. The response is the payload with `status: "cancelled"`, and a `verification.cancelled` webhook is sent.

```bash
curl -X POST https://verify.venuego.co.uk/api/v1/verification-sessions/order_8412/cancel \
  -H "Authorization: Bearer $VERIFYME_API_KEY"
```

---

# Webhooks

> Every status change is POSTed as JSON and signed. Reply 2xx within 10 seconds. Use the event id to ignore retries.

Canonical page: https://venuego.co.uk/docs/webhooks

Add HTTPS endpoints in Developers, or pass `webhookUrl` on a single session. Endpoint events are signed with that endpoint's secret (`whsec_…`), shown once when you add it. A per-session `webhookUrl` is signed with the account's per-session secret, which you can reveal and rotate in Developers. A rotation applies to the next send, including retries.

The delivery is stored before it is queued. A lost queue message delays the event. It does not drop it.

## Request

```http
POST /your/endpoint HTTP/1.1
Content-Type: application/json
User-Agent: VerifyMe-Webhooks/1.0
X-VerifyMe-Signature: t=1710000000,v1=hex…
X-VerifyMe-Delivery: 01J…
X-VerifyMe-Attempt: 1
```

`X-VerifyMe-Signature` is `t=<unix seconds>,v1=<hex hmac-sha256 of "t.body">`. The body is the raw request bytes, not a re-serialised object. Compare the hex with a constant-time check, and reject timestamps more than 5 minutes from now.

```js
import { createHmac, timingSafeEqual } from "node:crypto";

function verify(rawBody, header, secret) {
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return false;
  const expected = createHmac("sha256", secret).update(`${parts.t}.${rawBody}`).digest("hex");
  const a = Buffer.from(expected);
  const b = Buffer.from(parts.v1 ?? "");
  return a.length === b.length && timingSafeEqual(a, b);
}
```

```python
import hashlib, hmac, time

def verify(raw_body: bytes, header: str, secret: str) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    if abs(time.time() - int(parts["t"])) > 300:
        return False
    expected = hmac.new(secret.encode(), f"{parts['t']}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts.get("v1", ""))
```

## Body

```json
{
  "id": "01JEVENT…",
  "type": "verification.approved",
  "createdAt": "2026-10-07T16:04:11.000Z",
  "data": {}
}
```

`data` is the full verification payload from [Sessions](https://venuego.co.uk/docs/sessions), including `decision`, `identity`, `checks`, and your `metadata`. The event `id` stays the same across retries. `X-VerifyMe-Delivery` is the delivery row and changes if you resend from the portal.

## Events

| Type | When |
| --- | --- |
| `verification.created` | The session was created. |
| `verification.started` | The guest opened the link. |
| `verification.face_captured` | The face capture was uploaded. |
| `verification.document_captured` | The document photos were uploaded. |
| `verification.processing` | Automated checks started. |
| `verification.retry_started` | The guest started another attempt after a retryable decline. |
| `verification.needs_review` | Nothing declined, but a person needs to decide. Status stays `needs_review` until then. |
| `verification.approved` | Approved, by the checks or by a person. `decision.source` says which. |
| `verification.declined` | Declined, by the checks or by a person. `decision.retryableStep` is set when the guest can try again. |
| `verification.decision_changed` | A person changed the outcome, including after it was final. Sent as well as `approved` or `declined`. If you granted access on an earlier approval, revoke it when a later event declines. |
| `verification.cancelled` | You cancelled the session. |
| `verification.expired` | The guest did not finish within 24 hours. |

## Retries

Reply with any `2xx` within 10 seconds. Redirects are not followed. Anything else is retried after 1, 5, 10, 30, 60, 120, 240, 480, 720, then 1440 minutes (11 attempts, then `dead`). Each attempt has a new timestamp and signature. A `410 Gone` stops retries for that delivery.

Stored bodies are removed after 30 days. The delivery row stays 90 days, and a wiped body cannot be sent again. Owners and admins are emailed if a delivery dies, at most once a day per endpoint. You can retry or cancel a delivery from Developers.

The endpoint must be public HTTPS. Private addresses, link-local hosts, and credentials in the URL are rejected.

---

# Results

> Status is the outcome. Identity is included once a result exists. A later manual decision can change an approval.

Canonical page: https://venuego.co.uk/docs/results

## Status

| Status | Meaning |
| --- | --- |
| `created` | Waiting for the guest to open the link. |
| `in_progress` | The guest is in the face or document steps. |
| `processing` | Automated checks are running. |
| `needs_review` | A person has to approve or decline. This is not a decline. |
| `approved` | Accepted. |
| `declined` | Rejected. The guest may still have attempts left when `decision.retryableStep` is set. |
| `cancelled` | You cancelled it. |
| `expired` | The 24 hours ran out. |

`stage` is the screen the desktop watcher shows: `waiting_for_phone`, `face`, `document`, `reviewing`, then the final status. `attempt` runs from 1 to 3.

## Decision

`decision` is null until there is an outcome. A decline reason declines the check. A review flag does not. A check that errors does not approve on its own.

```json
"decision": {
  "outcome": "needs_review",
  "source": "automated",
  "reasons": [],
  "reviewFlags": [
    { "code": "layout_mismatch", "title": "Layout doesn't match the genuine document" }
  ],
  "retryableStep": null,
  "decidedAt": "2026-10-07T16:04:11.000Z"
}
```

After a person decides, `source` is `manual` and these fields are added:

- `previousOutcome` — what it was before this decision.
- `reasonCode` — the reviewer's reason. The free-text note is not included.
- `decidedBy` — `customer` or `venueora`. You do not receive the reviewer's email.

Manual approve codes: `manual_documents_verified`, `false_positive_layout`, `false_positive_template`, `false_positive_tamper`, `false_positive_recapture`, `false_positive_face`, `customer_known_in_person`, `other`.

Manual decline codes: `document_not_authentic`, `document_tampered`, `document_recaptured`, `face_mismatch`, `not_live`, `under_age`, `expired_document`, `suspected_fraud`, `other`.

## Automated decline reasons

These appear in `decision.reasons[].code` when the checks decline. `retryableStep` is `face`, `document`, or null when the guest cannot try again.

| Code | Retry |
| --- | --- |
| `face_not_live` | face |
| `face_mismatch` | face |
| `document_unreadable` | document |
| `mrz_invalid` | document |
| `barcode_missing` | document |
| `data_mismatch` | document |
| `document_expired` | document |
| `id_not_held` | document |
| `under_age` | none |
| `synthetic_media` | none |
| `document_not_authentic` | none |
| `declined_after_review` | none |
| `processing_error` | face |

## Review flags

These do not decline. Status becomes `needs_review`.

| Code | Why it waited |
| --- | --- |
| `layout_mismatch` | The layout does not match the genuine document. |
| `template_mismatch` | The printed design differs from genuine examples. |
| `possible_tampering` | Signs of a replaced photo, duplicated areas, or edited text. |
| `possible_recapture` | Signs of a photo of a screen or a print. |
| `provenance_unavailable` | The AI-image check could not run. |
| `automated_checks_incomplete` | The checks did not finish after several tries. |
| `authenticity_incomplete` | The document could not be straightened, or an authenticity check failed to run. |
| `face_compare_unavailable` | The face comparison did not complete. |
| `barcode_partial_match` | The licence front only partly matches the barcode. |
| `licence_dates_conflict` | More than one date of birth was read from the licence. |
| `held_document_unconfirmed` | The number on the held document could not be read. |
| `apparent_age_mismatch` | The face looks younger than the minimum age. Estimated age is never itself a decline. |

## Identity

`identity` is null until a result is stored. Fields can still be null when a value could not be read. `dateOfBirth` is `YYYY-MM-DD`. `age` is the age in whole years at decision time. `issuingCountry` is `GB`, `IE`, or `US` when known.

```json
"identity": {
  "firstName": "Alex",
  "lastName": "Morgan",
  "fullName": "Alex Morgan",
  "dateOfBirth": "1998-04-12",
  "age": 28,
  "sex": "F",
  "nationality": "GBR",
  "documentNumber": "123456789",
  "issuingCountry": "GB",
  "issuingRegion": null,
  "expiryDate": "2030-04-11",
  "issueDate": "2020-04-12",
  "address": null
}
```

`documentType` is `passport` or `driving_licence`. ID images and the extracted identity are deleted after 30 days, unless the check is still `needs_review`. A manual decision starts that clock again. The outcome, reason codes, and checks stay.

> A clear automated approval is not a government database match. A good real-time face swap, a lookalike with a genuine document, or a forged card whose number matches the forged name and date of birth can still pass the automated declines. Authenticity checks then raise review flags rather than decline.

---

# Errors

> Failures use one JSON shape. The code is stable. The message is for a person.

Canonical page: https://venuego.co.uk/docs/errors

```json
{
  "error": {
    "code": "invalid_request",
    "message": "sessionId: must be printable ASCII without spaces"
  }
}
```

Responses send `Cache-Control: no-store`. Do not retry a `4xx` as if it might succeed unchanged. A `402` means the account needs a plan or has used its included checks. A `409` `session_closed` on create means that `sessionId` is finished: use a new one. Repeating an open `sessionId` returns `200`, not an error.

| HTTP | Code | When |
| --- | --- | --- |
| 400 | `invalid_json` | The body is not JSON. |
| 400 | `invalid_request` | A field failed validation. `message` names the field. |
| 400 | `invalid_webhook_url` | The webhook URL is not public HTTPS on port 443, or it contains credentials. |
| 400 | `webhook_host_unresolved` | The webhook host did not resolve. |
| 401 | `unauthenticated` | The bearer token is missing, unknown, or revoked. |
| 402 | `trial_exhausted` | The free allowance is used up. |
| 402 | `allowance_exhausted` | This billing period's included checks are used up. |
| 404 | `not_found` | No session exists for that `sessionId` on this account. |
| 409 | `session_closed` | Create was called with a `sessionId` that has already finished. |
| 409 | `not_open` | Cancel was called on a session that is already finished. |
| 413 | `too_large` | The JSON body is over 256 KB. Images and video are not sent to the API. |
| 500 | `internal_error` | Something failed on our side. Retry create with the same `sessionId`. |

A `5xx` or a network timeout on create is safe to retry with the same `sessionId`. If the first call succeeded, the retry returns the existing session.

## What the guest can use

Passports and driving licences issued in the United Kingdom (`GB`), Ireland (`IE`), and the United States (`US`). A passport is the photo page only. A driving licence is the front and the back. The guest also holds that same document beside their face.
