# Veil Developer API

Production reference for AI agents and backend developers.

- API version: `v1`
- Base URL: `https://veilyourfirm.com`
- Content type: `application/json`
- Last verified against the implementation: 2026-07-19

> This document describes the supported server-to-server API under `/api/v1`.
> Veil's `/api/identity`, `/api/passport`, `/api/pay`, and `/api/business`
> routes are browser or dashboard internals. Do not build integrations against
> those internal routes.

## Agent rules

When using this document to implement an integration:

1. Call Veil only from a trusted backend. Never expose the API key or webhook
   secret to browser code.
2. Use only the seven `/api/v1` method/path combinations documented here.
3. Do not invent request fields, response fields, pagination, cancellation, or
   idempotency behavior.
4. Send users to the hosted `url` returned by Veil. Do not recreate Veil's
   verification or payment UI.
5. Treat browser query parameters as untrusted until the return code or HMAC
   has been verified.
6. Verify webhooks against the exact raw body bytes before parsing JSON.
7. Store Veil's developer-scoped `subject` as the durable user identifier.
   Do not use a Minecraft username or Discord username as a primary key.
8. Store money as integer cents internally. Veil returns money as two-decimal
   dollar strings.

## Endpoint summary

| Method | Path | Plan | Purpose |
| --- | --- | --- | --- |
| `POST` | `/api/v1/verifications` | Paid | Create an identity authorization request |
| `GET` | `/api/v1/verifications/{id}` | Paid | Read one identity request |
| `GET` | `/api/v1/verifications` | Paid | List the 50 newest identity requests |
| `POST` | `/api/v1/verifications/exchange` | Paid | Exchange a one-time browser return code |
| `POST` | `/api/v1/checkouts` | Free or Paid | Create a hosted payment checkout |
| `GET` | `/api/v1/checkouts/{id}` | Free or Paid | Read one checkout |
| `GET` | `/api/v1/checkouts` | Free or Paid | List the 50 newest API checkouts |

## Getting credentials

1. Open [veilyourfirm.com](https://veilyourfirm.com) and sign in with Discord.
2. Complete the firm-owner onboarding flow and link a Treasury-resolved firm.
3. Open the **Developers** area of the Veil dashboard.
4. Generate an API key. The full key is shown once.
5. Copy the separate webhook signing secret.
6. Configure HTTPS redirect and webhook URLs in Developer settings.

API keys begin with `vl_`. A generated replacement key invalidates the old
key immediately.

Identity verification endpoints require an active Paid plan. Checkout
endpoints are available on both plans.

Use separate environment variables:

```dotenv
VEIL_API_KEY=vl_replace_with_your_key
VEIL_WEBHOOK_SECRET=replace_with_your_webhook_secret
VEIL_EXPECTED_AUDIENCE=your-linked-firm-name
VEIL_BASE_URL=https://veilyourfirm.com
```

## Authentication

Every `/api/v1` request requires a Bearer API key:

```http
Authorization: Bearer vl_replace_with_your_key
Content-Type: application/json
```

Example:

```bash
curl https://veilyourfirm.com/api/v1/verifications \
  -H "Authorization: Bearer $VEIL_API_KEY"
```

Missing, invalid, revoked, or suspended-account keys return:

```http
HTTP/1.1 401 Unauthorized
Content-Type: application/json
```

```json
{
  "error": "UNAUTHORIZED",
  "message": "Pass your veil API key as a Bearer token."
}
```

## Common conventions

### Transport

- Production requests use HTTPS.
- The API is intended for server-to-server calls and does not provide a
  browser CORS contract.
- JSON request bodies are limited to 1 MB.
- Responses include `Cache-Control: no-store`.

### Money

- Request amounts are decimal dollars. Send them as strings, for example
  `"25.00"`.
- Response amounts are strings with exactly two decimal places.
- Convert dollar strings to integer cents before accounting or comparison.
- Do not use binary floating-point values for ledger calculations.

### Time

- Resource timestamps are ISO-8601 UTC strings.
- JWT `iat` and `exp` values are Unix seconds.

### Rate and resource limits

- API rate limit: 60 requests per API key per minute.
- Active identity requests: 50 per business.
- Active payment links: 20 per business.
- Payment links created: 50 per business per UTC day.
- List endpoints return newest first with `limit` (1-100, default 25) and an
  opaque `cursor`. Responses include `nextCursor` and `hasMore`.
- Rate-limit responses do not currently provide a documented `Retry-After`
  contract.

### Idempotency

`POST /api/v1/checkouts` and `POST /api/v1/verifications` accept an optional
`Idempotency-Key` header (8-128 URL-safe characters). Veil stores the first
successful response for 24 hours per API key and endpoint; a replay returns
the same response with `Idempotent-Replay: true`. A `reference` remains a
correlation field, not an idempotency key.

## Identity authorization quickstart

The identity API behaves like a hosted OAuth authorization flow:

1. Your backend creates a verification request.
2. Your app redirects the user to Veil's returned `url`.
3. Veil authenticates the user and shows the exact requested claims.
4. The user chooses an identity and approves optional fields.
5. Veil performs any required Minecraft payment proof or Discord OAuth step.
6. Veil returns the browser to your HTTPS callback with a one-time code.
7. Your backend validates `state`, exchanges the code, validates the result,
   and stores `subject` plus the approved claims.

### 1. Create the request

```js
import { randomBytes } from "node:crypto";

const state = randomBytes(32).toString("base64url");

// Store this in the user's server-side session before redirecting.
session.veilState = state;

const response = await fetch(
  "https://veilyourfirm.com/api/v1/verifications",
  {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.VEIL_API_KEY}`,
      "Content-Type": "application/json"
    },
    body: JSON.stringify({
      reference: `user:${localUser.id}`,
      state,
      expiresInMinutes: 15,
      requirements: {
        minecraft: {
          mode: "required",
          claims: ["username", "uuid"],
          fresh: false
        },
        discord: {
          mode: "optional",
          claims: ["id", "username"]
        }
      },
      allowPassport: true,
      duplicateProtection: ["minecraft", "discord"],
      redirectUrl: "https://app.example.com/veil/callback"
    })
  }
);

const request = await response.json();
if (!response.ok) throw new Error(request.message || request.error);

// Redirect the user's browser, not the backend request.
return redirect(request.url);
```

### 2. Receive the browser return

Veil returns the browser to the exact configured HTTPS callback. Example:

```text
https://app.example.com/veil/callback
  ?veil_code=vc_8fe2_example
  &veil_state=4azm_example
  &veil_status=verified
  &veil_verification_id=9f8e7d6c5b4a
  &veil_ref=user%3A4821
```

The fields are:

| Parameter | Meaning |
| --- | --- |
| `veil_code` | Five-minute, single-use authorization code |
| `veil_state` | Your opaque state value, returned unchanged |
| `veil_status` | `verified` on a successful return |
| `veil_verification_id` | Veil verification ID |
| `veil_ref` | Your original `reference` |

No Minecraft UUID, Discord ID, Passport ID, claims, proof, or receipt is put in
the URL.

Validate `veil_state` against the initiating server-side session before using
the code. Do not trust `veil_status`, `veil_ref`, or `veil_verification_id`
without a successful exchange.

### 3. Exchange the code

```js
const callbackUrl = new URL(request.url, "https://app.example.com");

const returnedState = callbackUrl.searchParams.get("veil_state");
if (!session.veilState || returnedState !== session.veilState) {
  throw new Error("Invalid Veil state");
}

delete session.veilState;

const code = callbackUrl.searchParams.get("veil_code");
if (!code) throw new Error("Missing Veil authorization code");

const response = await fetch(
  "https://veilyourfirm.com/api/v1/verifications/exchange",
  {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.VEIL_API_KEY}`,
      "Content-Type": "application/json"
    },
    body: JSON.stringify({ code })
  }
);

const result = await response.json();
if (!response.ok) throw new Error(result.message || result.error);
if (result.status !== "verified" || !result.subject) {
  throw new Error("Veil authorization did not verify");
}

// Use result.subject as the durable Veil identity for this developer.
await identities.upsert({
  veilSubject: result.subject,
  claims: result.claims,
  relationship: result.relationship,
  verifiedAt: result.verifiedAt
});
```

## Identity request reference

### `POST /api/v1/verifications`

Creates a hosted identity authorization request.

Requires an active Paid plan.

#### Request body

```json
{
  "reference": "customer_4821",
  "state": "csrf-value-from-your-session",
  "redirectUrl": "https://app.example.com/veil/callback",
  "expiresInMinutes": 15,
  "allowPassport": true,
  "duplicateProtection": ["minecraft", "discord"],
  "requirements": {
    "minecraft": {
      "mode": "required",
      "claims": ["username", "uuid", "verifiedAt"],
      "fresh": false
    },
    "discord": {
      "mode": "optional",
      "claims": ["id", "username", "verifiedAt"]
    }
  }
}
```

#### Fields

| Field | Type | Required | Rules |
| --- | --- | --- | --- |
| `reference` | string | No | Maximum 80 characters; your correlation value |
| `state` | string | Strongly recommended | Maximum 200 characters; returned unchanged |
| `redirectUrl` | string | No | HTTPS, maximum 300 characters; otherwise uses the account default |
| `expiresInMinutes` | integer | No | Clamped to 5–60; default 15 |
| `allowPassport` | boolean | No | Default `true`; `false` implies a fresh Minecraft proof when Minecraft is requested |
| `duplicateProtection` | string[] | No | May contain `minecraft`, `discord`, or both |
| `minecraftUsername` | string | No | A preassigned Minecraft IGN (maximum 16 characters). Veil resolves it, shows it to the user, and still requires a fresh refundable proof before sharing it. Requires a Minecraft claim request. |
| `requirements` | object | No | Defaults to required Minecraft and optional Discord |

If `minecraftUsername` is omitted, the user selects a saved Passport identity
or enters an IGN on Veil. A preassigned identity is never treated as verified
until its owner completes the refundable proof.

#### Requirement modes

Each identity type uses one of these modes:

| Mode | Consent behavior |
| --- | --- |
| `required` | The user must share that identity to complete authorization |
| `optional` | The user may share or decline that identity |
| `none` | Veil does not offer or return that identity |

Canonical object form:

```json
{
  "mode": "required",
  "claims": ["username", "uuid"],
  "fresh": false
}
```

Veil also accepts mode strings and legacy booleans, but new integrations should
use the object form above.

#### Supported claims

| Identity | Claim | Meaning |
| --- | --- | --- |
| Minecraft | `username` | Treasury-resolved current Minecraft username |
| Minecraft | `uuid` | Treasury-resolved player UUID |
| Minecraft | `verifiedAt` | When that identity was first verified into the Passport |
| Discord | `id` | Discord user ID |
| Discord | `username` | Discord username returned by OAuth |
| Discord | `verifiedAt` | When that Discord identity was verified into the Passport |

Defaults:

```json
{
  "minecraft": {
    "mode": "required",
    "claims": ["username", "uuid"],
    "fresh": false
  },
  "discord": {
    "mode": "optional",
    "claims": ["id", "username"]
  }
}
```

At least one identity must have a mode other than `none`. Every requested
identity must contain at least one supported claim. Unsupported claim names are
filtered; if that leaves a requested identity with no valid claims, creation
returns `400 INVALID_REQUEST`.

Set `requirements.minecraft.fresh` to `true` when a new Treasury payment proof
is required even if the user has a saved Passport.

#### Discord-only request

```json
{
  "reference": "discord-link-4821",
  "state": "opaque-session-state",
  "requirements": {
    "minecraft": {
      "mode": "none",
      "claims": []
    },
    "discord": {
      "mode": "required",
      "claims": ["id", "username"]
    }
  },
  "redirectUrl": "https://app.example.com/veil/callback"
}
```

#### Fresh Minecraft request

```json
{
  "reference": "high-risk-action-4821",
  "state": "opaque-session-state",
  "requirements": {
    "minecraft": {
      "mode": "required",
      "claims": ["username", "uuid"],
      "fresh": true
    },
    "discord": {
      "mode": "none",
      "claims": []
    }
  },
  "redirectUrl": "https://app.example.com/veil/callback"
}
```

#### Success response

Creation returns `200 OK`, not `201 Created`.

```json
{
  "id": "9f8e7d6c5b4a",
  "status": "awaiting_username",
  "reference": "customer_4821",
  "url": "https://veilyourfirm.com/verify/9f8e7d6c5b4a",
  "createdAt": "2026-07-19T20:45:00.000Z",
  "expiresAt": "2026-07-19T21:00:00.000Z",
  "verifiedAt": null,
  "subject": null,
  "requestedClaims": {
    "minecraft": {
      "mode": "required",
      "claims": ["username", "uuid"],
      "fresh": false
    },
    "discord": {
      "mode": "optional",
      "claims": ["id", "username"]
    }
  },
  "sharedClaims": [],
  "claims": {},
  "relationship": null,
  "minecraftUsername": null,
  "minecraftUuid": null,
  "discordId": null,
  "discordUsername": null,
  "verificationMethod": null,
  "refundStatus": null,
  "proof": null,
  "proofExpiresIn": null,
  "redirect": null,
  "receipt": null,
  "duplicateConflict": null
}
```

### `GET /api/v1/verifications/{id}`

Returns one verification owned by the authenticated developer account.

```bash
curl "https://veilyourfirm.com/api/v1/verifications/9f8e7d6c5b4a" \
  -H "Authorization: Bearer $VEIL_API_KEY"
```

Returns `404 NOT_FOUND` if the ID does not exist or belongs to another
developer.

### `GET /api/v1/verifications`

Returns verification requests, newest first. Use `?limit=25` and pass the
returned `nextCursor` as `?cursor=...` for the next page.

```json
{
  "items": [
    {
      "id": "9f8e7d6c5b4a",
      "status": "verified"
    }
  ]
}
```

Each item is a complete verification response object. The response also
contains `nextCursor` (or `null`) and `hasMore`.

### Verified response object

```json
{
  "id": "9f8e7d6c5b4a",
  "status": "verified",
  "reference": "customer_4821",
  "url": "https://veilyourfirm.com/verify/9f8e7d6c5b4a",
  "createdAt": "2026-07-19T20:45:00.000Z",
  "expiresAt": "2026-07-19T21:00:00.000Z",
  "verifiedAt": "2026-07-19T20:47:13.000Z",
  "subject": "vps_7c9f_example",
  "requestedClaims": {
    "minecraft": {
      "mode": "required",
      "claims": ["username", "uuid"],
      "fresh": false
    },
    "discord": {
      "mode": "optional",
      "claims": ["id", "username"]
    }
  },
  "sharedClaims": ["minecraft", "discord"],
  "claims": {
    "minecraft": {
      "username": "Cascadia",
      "uuid": "uuid-example"
    },
    "discord": {
      "id": "123456789012345678",
      "username": "cascadia"
    }
  },
  "relationship": {
    "returning": true,
    "existingConnection": false,
    "status": "returning",
    "firstAuthorizedAt": "2026-07-01T18:00:00.000Z",
    "authorizationCount": 3
  },
  "minecraftUsername": "Cascadia",
  "minecraftUuid": "uuid-example",
  "discordId": "123456789012345678",
  "discordUsername": "cascadia",
  "verificationMethod": "passport",
  "refundStatus": null,
  "proof": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.example.signature",
  "proofExpiresIn": 900,
  "redirect": "https://app.example.com/veil/callback?veil_code=vc_example&veil_state=state&veil_status=verified&veil_verification_id=9f8e7d6c5b4a&veil_ref=customer_4821",
  "receipt": {
    "receiptId": "vr_9f8e7d6c5b4a",
    "seal": "opaque-example-seal",
    "developer": "Example Firm",
    "reference": "customer_4821",
    "subject": "vps_7c9f_example",
    "sharedClaims": ["minecraft", "discord"],
    "claims": {
      "minecraft": {
        "username": "Cascadia",
        "uuid": "uuid-example"
      },
      "discord": {
        "id": "123456789012345678",
        "username": "cascadia"
      }
    },
    "minecraftUsername": "Cascadia",
    "minecraftUuid": "uuid-example",
    "discordUsername": "cascadia",
    "discordId": "123456789012345678",
    "verificationMethod": "passport",
    "verifiedAt": "2026-07-19T20:47:13.000Z",
    "originalVerifiedAt": "2026-07-01T18:00:00.000Z",
    "refundStatus": null
  },
  "duplicateConflict": null
}
```

Only requested and user-approved claim fields appear in `claims`. Unapproved
fields are omitted rather than returned as hidden values.

Convenience fields such as `minecraftUsername` duplicate approved values from
`claims`. New integrations should treat `claims` as canonical.

The receipt `seal` is opaque and is not an externally verifiable signature.
Trust an authenticated API response, successful code exchange, verified JWT
proof, or verified webhook instead.

## Verification statuses

| Status | Terminal | Meaning |
| --- | --- | --- |
| `awaiting_username` | No | Waiting for sign-in, consent, Passport choice, or Minecraft username |
| `awaiting_payment` | No | Waiting for a fresh refundable Minecraft proof |
| `awaiting_discord` | No | Waiting for required Discord OAuth |
| `verified` | Yes | Authorization completed and approved claims are available |
| `expired` | Yes | Overall request expired |
| `duplicate` | Yes | Developer-scoped duplicate protection rejected an identity |
| `superseded` | Yes | A newer verified request replaced the same nonempty reference |
| `passport_conflict` | Yes | Identity belongs to a different Passport and was not merged |
| `failed` | Yes | Authorization failed |
| `cancelled` | Yes | Authorization was cancelled |

A two-minute Minecraft challenge may expire while the overall authorization
request remains valid. In that case, the status returns to
`awaiting_username`, and the user may start a new random challenge.

Stop polling on every terminal status, not only `verified`.

## Passport, subject, and relationship semantics

Veil Passport is a server-side identity wallet controlled by the user.

- A Passport can contain multiple Minecraft and Discord identities.
- Minecraft identities are proven by Treasury-observed payments.
- Discord identities are proven with Discord OAuth.
- Passport browser sessions use signed, HttpOnly, Secure cookies.
- Passport cookies last up to 30 days.
- Users explicitly choose which identity and optional claims to share.
- Veil never returns its internal Passport ID to developers.
- An identity owned by another Passport is never silently merged. Passport
  owners can explicitly merge two Passports only after signing in to both on
  the same device; the merge screen never reveals the identities attached to
  either Passport.

### Stable subject

`subject` is the durable identity key for your integration.

- Format: `vps_...`
- Stable for the same Passport and your developer account.
- Different for every developer account.
- Not correlatable across unrelated developers.
- Available only after successful authorization.

Store `subject` and associate it with your local user. Treat usernames as
mutable display data.

### Relationship

```json
{
  "returning": true,
  "existingConnection": false,
  "status": "returning",
  "firstAuthorizedAt": "2026-07-01T18:00:00.000Z",
  "authorizationCount": 3
}
```

| Field | Meaning |
| --- | --- |
| `returning` | This developer has authorized the Passport before |
| `existingConnection` | An active connection already used the same `reference` |
| `status` | `new`, `returning`, or `existing_connection` |
| `firstAuthorizedAt` | First authorization time for this developer-scoped subject |
| `authorizationCount` | Number of completed authorizations for this subject and developer |

When a new request with a nonempty `reference` verifies successfully, older
verified requests for that same developer and reference become `superseded`.

### Duplicate protection

`duplicateProtection` may include `minecraft`, `discord`, or both.

Duplicate checks are scoped to your developer account. Veil does not reveal
the conflicting user, Passport, subject, or reference. A fresh Minecraft proof
that is rejected as a duplicate is still refunded.

## `POST /api/v1/verifications/exchange`

Exchanges the five-minute browser `veil_code` for the complete verified
response.

Requires an active Paid plan.

```http
POST /api/v1/verifications/exchange
Authorization: Bearer vl_...
Content-Type: application/json
```

```json
{
  "code": "vc_8fe2_example"
}
```

Success returns the complete verification object with `redirect` forced to
`null`.

Codes are:

- scoped to the developer account that created the request;
- usable only when the request is `verified`;
- valid for five minutes;
- single-use.

Errors:

| HTTP | Code | Meaning |
| --- | --- | --- |
| `400` | `INVALID_REQUEST` | `code` was omitted |
| `404` | `INVALID_CODE` | Wrong code, wrong developer, or request not verified |
| `409` | `CODE_USED` | Code was already exchanged |
| `410` | `CODE_EXPIRED` | Code is older than five minutes |

After code expiry, the authenticated developer can still read the verification
by ID while the result remains retained.

## Verification proof JWT

Successful verification responses include a 15-minute HS256 JWT in `proof`.
It is signed with the business webhook secret, not the API key.

### Header

```json
{
  "alg": "HS256",
  "typ": "JWT",
  "kid": "last-four-api-key-characters"
}
```

### Payload

```json
{
  "iss": "veil",
  "aud": "your-linked-firm-name",
  "iat": 1784494033,
  "exp": 1784494933,
  "sub": "vps_7c9f_example",
  "proof_version": 2,
  "verification_id": "9f8e7d6c5b4a",
  "reference": "customer_4821",
  "status": "verified",
  "verification_method": "passport",
  "verified_at": "2026-07-19T20:47:13.000Z",
  "original_verified_at": "2026-07-01T18:00:00.000Z",
  "consent_at": "2026-07-19T20:47:10.000Z",
  "shared_claims": ["minecraft", "discord"],
  "claims": {
    "minecraft": {
      "username": "Cascadia",
      "uuid": "uuid-example"
    },
    "discord": {
      "id": "123456789012345678",
      "username": "cascadia"
    }
  },
  "relationship": {
    "returning": true,
    "existingConnection": false,
    "status": "returning",
    "firstAuthorizedAt": "2026-07-01T18:00:00.000Z",
    "authorizationCount": 3
  },
  "minecraft_username": "Cascadia",
  "minecraft_uuid": "uuid-example",
  "discord_id": "123456789012345678",
  "discord_username": "cascadia"
}
```

The flattened identity fields are compatibility aliases. The nested `claims`
object is canonical.

### Verification code

```js
import { createHmac, timingSafeEqual } from "node:crypto";

export function verifyVeilProof(proof, expected) {
  const parts = String(proof || "").split(".");
  if (parts.length !== 3) throw new Error("Malformed Veil proof");

  const [encodedHeader, encodedPayload, encodedSignature] = parts;
  const header = JSON.parse(
    Buffer.from(encodedHeader, "base64url").toString("utf8")
  );

  if (header.alg !== "HS256" || header.typ !== "JWT") {
    throw new Error("Unexpected Veil JWT algorithm");
  }

  const expectedSignature = createHmac(
    "sha256",
    process.env.VEIL_WEBHOOK_SECRET
  )
    .update(`${encodedHeader}.${encodedPayload}`)
    .digest();

  const suppliedSignature = Buffer.from(encodedSignature, "base64url");
  if (
    suppliedSignature.length !== expectedSignature.length ||
    !timingSafeEqual(suppliedSignature, expectedSignature)
  ) {
    throw new Error("Invalid Veil proof signature");
  }

  const payload = JSON.parse(
    Buffer.from(encodedPayload, "base64url").toString("utf8")
  );

  const now = Math.floor(Date.now() / 1000);
  if (payload.iss !== "veil") throw new Error("Invalid Veil issuer");
  if (payload.aud !== expected.audience) throw new Error("Invalid Veil audience");
  if (payload.proof_version !== 2) throw new Error("Unsupported Veil proof");
  if (payload.status !== "verified") throw new Error("Veil identity not verified");
  if (!Number.isInteger(payload.exp) || payload.exp <= now) {
    throw new Error("Expired Veil proof");
  }
  if (payload.iat > now + 60) throw new Error("Veil proof issued in the future");
  if (payload.verification_id !== expected.verificationId) {
    throw new Error("Wrong Veil verification");
  }
  if (payload.reference !== expected.reference) {
    throw new Error("Wrong Veil reference");
  }
  if (!payload.sub || !payload.sub.startsWith("vps_")) {
    throw new Error("Invalid Veil subject");
  }
  if (expected.subject && payload.sub !== expected.subject) {
    throw new Error("Wrong Veil subject");
  }

  return payload;
}
```

The expected audience is the exact linked Treasury firm key shown in Veil's
Developer settings, not a display label chosen by the user.

## Verification webhooks

Identity webhooks use the account-level HTTPS webhook URL configured in the
Developer dashboard.

Events:

| Event | Meaning |
| --- | --- |
| `identity.authorized` | Initial authorization completed |
| `identity.connection_updated` | User changed a connected identity or claim projection |
| `identity.duplicate_rejected` | Developer-scoped duplicate protection rejected the authorization |
| `identity.passport_conflict` | Identity belongs to another Passport and was not merged |

No webhook is currently emitted for ordinary verification expiry.

### Headers

```http
Content-Type: application/json
X-Veil-Event: identity.authorized
X-Veil-Signature: sha256=<lowercase-hex-hmac>
```

### Authorized payload

```json
{
  "event": "identity.authorized",
  "id": "9f8e7d6c5b4a",
  "reference": "customer_4821",
  "status": "verified",
  "subject": "vps_7c9f_example",
  "sharedClaims": ["minecraft"],
  "claims": {
    "minecraft": {
      "username": "Cascadia",
      "uuid": "uuid-example"
    }
  },
  "relationship": {
    "returning": false,
    "existingConnection": false,
    "status": "new",
    "firstAuthorizedAt": "2026-07-19T20:47:13.000Z",
    "authorizationCount": 1
  },
  "minecraftUsername": "Cascadia",
  "minecraftUuid": "uuid-example",
  "discordId": null,
  "discordUsername": null,
  "verificationMethod": "payment",
  "verifiedAt": "2026-07-19T20:47:13.000Z",
  "originalVerifiedAt": "2026-07-19T20:47:13.000Z",
  "refundStatus": "pending",
  "proof": "eyJ...",
  "duplicateConflict": null,
  "receipt": {
    "receiptId": "vr_9f8e7d6c5b4a"
  }
}
```

Nonverified identity events contain no approved `subject` or claim values.

## Polling identity status

If no webhook URL or redirect URL is configured, poll the authenticated
verification endpoint every five seconds.

Use nonoverlapping `setTimeout`, not `setInterval`:

```js
const terminal = new Set([
  "verified",
  "expired",
  "duplicate",
  "superseded",
  "passport_conflict",
  "failed",
  "cancelled"
]);

async function waitForVeilVerification(id) {
  for (;;) {
    const response = await fetch(
      `https://veilyourfirm.com/api/v1/verifications/${id}`,
      {
        headers: {
          Authorization: `Bearer ${process.env.VEIL_API_KEY}`
        }
      }
    );

    const body = await response.json();
    if (!response.ok) throw new Error(body.message || body.error);
    if (terminal.has(body.status)) return body;

    await new Promise(resolve => setTimeout(resolve, 5000));
  }
}
```

Five-second polling uses 12 calls per minute for one active request. Use
webhooks or coordinate polling when tracking several simultaneous requests so
the API key remains below 60 calls per minute.

## Payment checkout quickstart

All API checkouts use Veil's hosted payer-verification flow.

1. Your backend creates a checkout.
2. Your backend stores `id`, `reference`, and `verificationCode`.
3. Your app gives the four-digit `verificationCode` to the intended payer.
4. Redirect the payer to the returned `url`.
5. The payer verifies a Minecraft account with a refundable random amount.
6. The payer types the four-digit code. Veil also displays two decoys.
7. Veil reveals the final exact payment amount.
8. The payer sends the payment to Veil.
9. Veil emits `payment.received`, queues the firm payout, then emits
   `payment.settled` after delivery.

```js
const response = await fetch(
  "https://veilyourfirm.com/api/v1/checkouts",
  {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.VEIL_API_KEY}`,
      "Content-Type": "application/json"
    },
    body: JSON.stringify({
      amount: "149.99",
      reference: "order_1024",
      payerName: "Cascadia",
      expiresInMinutes: 60,
      redirectUrl: "https://shop.example.com/veil/return?order=1024"
    })
  }
);

const checkout = await response.json();
if (!response.ok) throw new Error(checkout.message || checkout.error);

await orders.update("order_1024", {
  veilId: checkout.id,
  payerCode: checkout.verificationCode,
  status: checkout.status
});

return redirect(checkout.url);
```

## Checkout endpoint reference

### `POST /api/v1/checkouts`

Creates a hosted payment checkout.

Available on Free and Paid plans.

#### Request

```json
{
  "amount": "25.00",
  "reference": "order_1024",
  "payerName": "Cascadia",
  "redirectUrl": "https://shop.example.com/veil/return?order=1024",
  "expiresInMinutes": 60
}
```

| Field | Type | Required | Rules |
| --- | --- | --- | --- |
| `amount` | string or number | Yes | $0.25–$100,000 by default |
| `reference` | string | No | Maximum 80 characters |
| `payerName` | string | No | Minecraft IGN resolved through Treasury |
| `openAmount` | boolean | No | Omit `amount` and set `true` for a customer-chosen amount |
| `crowdfunding` | boolean | No | Requires `openAmount`; lets any independently verified Minecraft user contribute until expiry |
| `redirectUrl` | string | No | HTTPS URL; otherwise the account default is used |
| `expiresInMinutes` | integer | No | Clamped to 5–1,440; default 60 |

If `payerName` is supplied, Veil resolves it before creating the checkout and
the payer does not type their Minecraft username. The payer must still prove
control with the refundable payment challenge.

If `payerName` is omitted, the payer enters a Minecraft username on Veil.

For a crowdfunding checkout, omit `amount` and `payerName`, set
`"openAmount": true` and `"crowdfunding": true`. The response has no payer
code: each visitor completes their own refundable Minecraft proof before they
can contribute.

An invalid nonempty checkout `redirectUrl` is currently ignored rather than
rejected. Always validate it in your application and send an HTTPS URL.

#### Response

```json
{
  "id": "a1b2c3d4e5f6",
  "url": "https://veilyourfirm.com/pay/a1b2c3d4e5f6",
  "amount": "25.00",
  "amountRequested": "25.00",
  "reference": "order_1024",
  "expiresAt": "2026-07-19T21:45:00.000Z",
  "status": "active",
  "verificationCode": "4821",
  "payerName": "Cascadia"
}
```

`payerName` is `null` when it was omitted.

### Open-amount checkout

Set `openAmount` to `true` and omit both `amount` and `payerName` to create a
firm-bound open link. The customer sees only Veil, verifies a Minecraft UUID
with the refundable proof, then may send any positive amount. Veil matches the
first payment from that verified UUID and forwards it to the link's firm; the
hosted page does not disclose the firm name. `amount` and `amountRequested`
are `null` until the payment is matched.

Set `crowdfunding` to `true` with `openAmount` to create a shared link. Any
Minecraft user can complete their own refundable proof on the same link, then
contribute any amount until `expiresInMinutes` (5–1440 for API checkouts).
Veil keeps every contributor isolated, never exposes the payout firm on the
hosted page, and creates its own payout plus `payment.received` /
`payment.settled` webhook events for each contribution. `repeatPayments` is a
backwards-compatible alias for `crowdfunding`.

The hosted flow may increase the final payment amount by up to $1.99 after
Minecraft and payer-code verification to keep simultaneous payments from the
same UUID unambiguous. Do not bypass the hosted page or tell the payer to send
the original requested amount from your own UI. The hosted page is the source
of truth for the final exact amount.

After a payment is received, the hosted confirmation page includes a private
receipt URL. It contains only the amount, reference, status, time, and an
opaque seal—never the payout firm—and follows the business's transaction-history
setting.

### `GET /api/v1/checkouts/{id}`

Returns a webhook-shaped checkout status object.

```bash
curl "https://veilyourfirm.com/api/v1/checkouts/a1b2c3d4e5f6" \
  -H "Authorization: Bearer $VEIL_API_KEY"
```

```json
{
  "event": "checkout.status",
  "id": "a1b2c3d4e5f6",
  "reference": "order_1024",
  "amountRequested": "25.00",
  "amountPaid": "25.00",
  "fee": "1.25",
  "net": "23.75",
  "status": "settled",
  "payerName": "Cascadia",
  "payerType": "player",
  "createdAt": "2026-07-19T20:45:00.000Z",
  "receivedAt": "2026-07-19T20:48:00.000Z",
  "settledAt": "2026-07-19T20:48:03.000Z"
}
```

`amountPaid` is `null` before a Treasury payment is matched.

The create response and status response intentionally use different schemas.

### `GET /api/v1/checkouts`

Returns API-created checkouts, newest first. Use `?limit=25` and pass the
returned `nextCursor` as `?cursor=...` for the next page.

```json
{
  "items": [
    {
      "event": "checkout.status",
      "id": "a1b2c3d4e5f6",
      "status": "active"
    }
  ]
}
```

Each item is a complete checkout status object. The response also contains
`nextCursor` (or `null`) and `hasMore`.

## Checkout statuses

| Status | Terminal | Meaning |
| --- | --- | --- |
| `active` | No | Checkout is awaiting payer flow or payment |
| `received` | No | Payment matched; firm payout or example refund queued |
| `settled` | Yes | Firm payout or example refund completed |
| `expired` | Yes | Checkout expired unpaid |
| `cancelled` | Yes | Checkout was cancelled from the dashboard |

### `POST /api/v1/checkouts/{id}/cancel`

Cancels an active API checkout. The response is the normal checkout status
object and Veil emits `payment.cancelled` when a webhook is configured.

For fulfillment that depends on the recipient actually receiving funds, wait
for `settled`. `received` confirms that Veil matched the incoming payment but
the outbound payout may still be pending.

## Fees

Free plan routing fee:

| Paid amount | Fee |
| --- | --- |
| Up to and including $100 | 5% |
| Above $100 through $1,000 | 4% |
| Above $1,000 | 3% |

The fee is rounded up to the next cent with a one-cent minimum. Paid plans use
a 0% routing fee while active.

`amountPaid` is the matched incoming amount, `fee` is Veil's fee, and `net` is
the amount queued or delivered to the linked firm.

## Payment browser return

After the hosted payment page observes `received` or `settled`, it may redirect
the browser to the checkout-specific URL or the account default.

Example:

```text
https://shop.example.com/veil/return?order=1024
  &veil_id=a1b2c3d4e5f6
  &veil_status=received
  &veil_ref=order_1024
  &veil_sig=3f6a_example_hex_signature
```

Signature input:

```text
veil_id + "." + veil_status + "." + veil_ref
```

Signature algorithm:

```text
HMAC-SHA256(webhookSecret, signatureInput)
```

The `veil_sig` value is lowercase hexadecimal.

### Redirect verification code

```js
import { createHmac, timingSafeEqual } from "node:crypto";

export function verifyVeilPaymentReturn(searchParams) {
  const id = searchParams.get("veil_id") || "";
  const status = searchParams.get("veil_status") || "";
  const reference = searchParams.get("veil_ref") || "";
  const supplied = searchParams.get("veil_sig") || "";

  if (!id || !["received", "settled"].includes(status)) {
    throw new Error("Invalid Veil payment return");
  }

  const expected = createHmac(
    "sha256",
    process.env.VEIL_WEBHOOK_SECRET
  )
    .update(`${id}.${status}.${reference}`)
    .digest("hex");

  const a = Buffer.from(supplied, "utf8");
  const b = Buffer.from(expected, "utf8");
  if (a.length !== b.length || !timingSafeEqual(a, b)) {
    throw new Error("Invalid Veil payment signature");
  }

  return { id, status, reference };
}
```

The payment return does not include a separate `state` parameter. Put your own
opaque order/session value in the original `redirectUrl`, then validate both
that value and Veil's signature.

## Payment webhooks

Events:

| Event | Meaning |
| --- | --- |
| `payment.received` | Incoming payment matched; payout queued |
| `payment.settled` | Outbound firm payout or example refund completed |

No webhook is currently emitted for payment expiry or dashboard cancellation.

### Payload

```json
{
  "event": "payment.settled",
  "id": "a1b2c3d4e5f6",
  "reference": "order_1024",
  "amountRequested": "25.00",
  "amountPaid": "25.00",
  "fee": "1.25",
  "net": "23.75",
  "status": "settled",
  "payerName": "Cascadia",
  "payerType": "player",
  "createdAt": "2026-07-19T20:45:00.000Z",
  "receivedAt": "2026-07-19T20:48:00.000Z",
  "settledAt": "2026-07-19T20:48:03.000Z"
}
```

## Webhook signature verification

All Veil webhooks use the same signature scheme.

```http
Content-Type: application/json
X-Veil-Event: payment.settled
X-Veil-Signature: sha256=<lowercase-hex-hmac>
```

The signature is:

```text
"sha256=" + hex(HMAC-SHA256(webhookSecret, exactRawRequestBody))
```

Do not parse and reserialize the body before verifying it.

### Express example

Register a raw-body handler for this route before any global JSON parser:

```js
import express from "express";
import { createHmac, timingSafeEqual } from "node:crypto";

const app = express();

app.post(
  "/webhooks/veil",
  express.raw({ type: "application/json", limit: "1mb" }),
  async (req, res) => {
    const rawBody = req.body;
    const supplied = String(req.get("X-Veil-Signature") || "");
    const expected =
      "sha256=" +
      createHmac("sha256", process.env.VEIL_WEBHOOK_SECRET)
        .update(rawBody)
        .digest("hex");

    const a = Buffer.from(supplied, "utf8");
    const b = Buffer.from(expected, "utf8");
    if (a.length !== b.length || !timingSafeEqual(a, b)) {
      return res.status(401).send("invalid signature");
    }

    const event = JSON.parse(rawBody.toString("utf8"));

    // Make this operation idempotent. Retries contain the same body.
    await processVeilEventOnce({
      dedupeKey: `${event.event}:${event.id}:${event.status}`,
      event
    });

    return res.sendStatus(204);
  }
);
```

### Delivery behavior

- Any 2xx response is treated as success.
- Receiver redirects are rejected.
- Request timeout is 10 seconds.
- Veil makes six total attempts.
- Backoff after failures: approximately 1 minute, 5 minutes, 30 minutes,
  2 hours, then 6 hours.
- The delivery worker runs every 30 seconds, so actual timing can be slightly
  later.
- Retried deliveries use the same event body.
- Handlers must be idempotent.

The cumulative retry schedule can span roughly 8 hours 36 minutes plus worker
delay, not merely six hours.

## History and link deletion

### Public payment link

After the hosted public payment page successfully receives its first terminal
`received` or `settled` response, Veil marks that public link deleted. Later
public page reads return 404.

This does not prevent authenticated checkout polling while the business keeps
transaction history.

### Transaction-history setting

When **Store settled transaction history** is disabled:

- settled payment records are removed after operational work completes;
- associated payout, notification, and payment audit records are removed;
- delivered or failed webhook delivery records are removed;
- pending webhook deliveries remain until resolved;
- settled checkouts can disappear from list results and return 404 by ID;
- aggregate counters and processed Treasury posting IDs remain;
- identity-authorization history is independent of this payment-history
  setting.

Therefore, an integration with history disabled must durably store verified
webhook or redirect results in its own database.

Terminal developer verification records are retained for approximately seven
days. Developer-scoped Passport relationships and subjects persist separately.

## Errors

Most stable API errors use:

```json
{
  "error": "MACHINE_CODE",
  "message": "Human-readable explanation."
}
```

Some generic server errors may contain only `error`.

| HTTP | Typical code | Meaning |
| --- | --- | --- |
| `400` | `INVALID_REQUEST` | Invalid body, amount, URL, claim request, payer, or resource limit |
| `401` | `UNAUTHORIZED` | Missing, invalid, revoked, or suspended-account API key |
| `402` | `PAID_PLAN_REQUIRED` | Identity API requires an active Paid plan |
| `404` | `NOT_FOUND` | Resource absent or owned by another developer |
| `404` | `INVALID_CODE` | Authorization code is invalid for this developer |
| `409` | `CODE_USED` | Authorization code was already exchanged |
| `410` | `CODE_EXPIRED` | Authorization code expired |
| `429` | `RATE_LIMITED` | More than 60 API calls in the current minute |
| `429` | `LIMIT_REACHED` | 50 active identity requests already exist |
| `500` | generic | Unexpected server error |

Rate-limit response:

```json
{
  "error": "RATE_LIMITED",
  "message": "60 requests per minute."
}
```

Paid-plan response:

```json
{
  "error": "PAID_PLAN_REQUIRED",
  "message": "Developer identity verification is available only on an active Paid plan."
}
```

Malformed JSON and oversized request bodies currently fall through to a generic
`500` response rather than a documented `400` or `413`. Always serialize valid
JSON and keep request bodies far below 1 MB.

## Hosted Veil URLs

These URLs are part of the user journey, but they are not backend JSON API
contracts.

| URL | Purpose |
| --- | --- |
| `https://veilyourfirm.com/verify/{id}` | Hosted identity authorization and consent |
| `https://veilyourfirm.com/pay/{id}` | Hosted payer verification and checkout |
| `https://veilyourfirm.com/passport` | User-managed Veil Passport |

Always use the exact absolute `url` returned by the create endpoint.

Veil's Discord application uses this callback:

```text
https://veilyourfirm.com/auth/discord/callback
```

That is Veil's OAuth callback, not the developer application's authorization
return URL. Developer return URLs are supplied through `redirectUrl` or the
Developer dashboard.

## Unsupported assumptions

An AI agent must not assume any of the following exist:

- per-request webhook URLs;
- expiry webhooks;
- public access to Passport contents;
- cross-developer subject correlation;
- claims in browser callback URLs;
- an externally verifiable receipt `seal`;
- permanent checkout retrieval when transaction history is disabled.

## Production integration checklist

- [ ] API key is stored only in a backend secret manager.
- [ ] Webhook secret is stored separately from the API key.
- [ ] HTTPS callback and webhook URLs are configured.
- [ ] A cryptographically random `state` is stored in the initiating session.
- [ ] `veil_state` is checked before code exchange.
- [ ] Authorization codes are exchanged only on the backend.
- [ ] The stable `subject` is used as the Veil identity key.
- [ ] Only approved fields in `claims` are stored.
- [ ] JWT signature, algorithm, issuer, audience, expiry, proof version,
      verification ID, reference, and subject are validated.
- [ ] Webhook signatures are checked against exact raw bytes.
- [ ] Webhook handlers are idempotent.
- [ ] Payment fulfillment distinguishes `received` from `settled`.
- [ ] Money is converted to integer cents.
- [ ] All terminal verification statuses stop polling.
- [ ] Polling stays below 60 requests per minute per API key.
- [ ] The integration tolerates 404 after history-free settlement.
- [ ] The hosted Veil URL is used for the payer or verification journey.
