Developers

Three calls. Or none.

Call the API, or drop files in your bucket. Same pipeline, same JSON.

  • api.unforged.sh
  • OpenAPI 3.1
  • RFC 9457 errors
  • Version 2026-09-27

nib.yaml platform.yaml

$ curl -X PUT api.unforged.sh/nib/subjects/cust-88/references -F [email protected]
200 · 1 reference enrolled for cust-88
$ curl api.unforged.sh/nib/checks -F [email protected] -F request=…
200 · APPROVE · 0.94 · APP-1042 · nib-2.2
$ curl "api.unforged.sh/nib/checks?view=items&reference=APP-1042"
200 · 1 item · APP-1042 · loan-application · APPROVE
$ aws s3 cp manifest.json s3://$UNFORGED_BUCKET/nib/inbox/batch-07/
nib/results/batch-07/summary.json · 2 checked
$ ▍

Quickstart

  1. 1Enrol a person’s reference signatures
  2. 2Check one document or a whole batch
  3. 3Read inline, by webhook, or as files
1 · enrol
# 1 · enrol: a reference signature for your customer cust-88
curl -X PUT https://api.unforged.sh/nib/subjects/cust-88/references \
  -H "Authorization: Bearer $UNFORGED_KEY" \
  -H "Unforged-Version: 2026-09-27" \
  -H "Idempotency-Key: enrol-cust-88-1" \
  -F [email protected]
2 · check
# 2 · check: a document, with your own references
curl -X POST https://api.unforged.sh/nib/checks \
  -H "Authorization: Bearer $UNFORGED_KEY" \
  -H "Unforged-Version: 2026-09-27" \
  -H "Idempotency-Key: app-1042" \
  -F 'request={"process":"loan-application","items":[{"file":"q","subject_reference":"cust-88","reference":"APP-1042"}]};type=application/json' \
  -F [email protected]
3 · read
# 3 · read: a queued job's status and results
curl https://api.unforged.sh/nib/checks/app-1042 \
  -H "Authorization: Bearer $UNFORGED_KEY" \
  -H "Unforged-Version: 2026-09-27"
find by your reference
# find a check again by your reference
curl "https://api.unforged.sh/nib/checks?view=items&reference=APP-1042" \
  -H "Authorization: Bearer $UNFORGED_KEY" \
  -H "Unforged-Version: 2026-09-27"

Drop files in nib/inbox/<job>/, write manifest.json last. The answer lands in nib/results/<job>/.

bucket drop
# any S3 tool, with your bucket's keys: the files first, the manifest last
aws s3 cp ./batch/ s3://$UNFORGED_BUCKET/nib/inbox/2026-10-batch-07/ --recursive
aws s3 cp manifest.json s3://$UNFORGED_BUCKET/nib/inbox/2026-10-batch-07/manifest.json

# nib/inbox/2026-10-batch-07/manifest.json
{
  "items": [
    {
      "file": "app-1041.pdf",
      "subject_reference": "cust-88",
      "template_id": "benefits-form-v3",
      "reference": "APP-1041"
    },
    {
      "file": "app-1042.jpg",
      "references": [
        "refs/extra-1042.png"
      ],
      "target": {
        "page": 1,
        "box": "applicant_signature"
      },
      "reference": "APP-1042",
      "metadata": {
        "branch": "leeds"
      }
    }
  ],
  "policy": "default",
  "process": "loan-application"
}

# the answer: nib/results/2026-10-batch-07/summary.json
# (one result per line in nib/results/2026-10-batch-07/results.ndjson)
{
  "job_id": "inv-2291",
  "status": "completed",
  "total": 2,
  "completed": 1,
  "failed": 1,
  "counts": {
    "APPROVE": 1
  },
  "errors": [
    {
      "item_index": 1,
      "file": "missing.png",
      "field": null,
      "type": "file_not_found",
      "detail": "missing.png is not in the job folder"
    }
  ],
  "metadata": {
    "batch": "2026-10"
  },
  "versions": {
    "model": "nib-2.2",
    "policy": "default@7",
    "api_version": "2026-09-27"
  },
  "started_at": "2026-09-25T10:15:30.123Z",
  "completed_at": "2026-09-25T10:15:32.456Z"
}

One result per item: the same JSON inline, from GET, streamed, and in your bucket. See CheckResult.

CheckResult
{
  "id": "ver_01j9x3m8v7k2q4r5s6t7u8v9w0",
  "item_index": 0,
  "file": "questioned",
  "subject_reference": "cust-88",
  "reference": "APP-1042",
  "process": "loan-application",
  "metadata": {
    "branch": "leeds"
  },
  "status": "completed",
  "error": null,
  "decision": "APPROVE",
  "input_type": "photo",
  "pages": 1,
  "notes": [],
  "signatures": [
    {
      "index": 0,
      "page": 1,
      "bbox": [
        412,
        1880,
        498,
        160
      ],
      "located_by": "template",
      "quality": {
        "ok": true,
        "effective_dpi": 214,
        "sharpness": 812.5,
        "contrast": 180,
        "issues": []
      },
      "match_probability": 0.94,
      "band": [
        0.88,
        0.97
      ],
      "reference_count": 2,
      "per_reference": [
        {
          "ref": "ref_01j9x3m8v7k2q4r5s6t7u8v9w1",
          "probability": 0.95
        },
        {
          "ref": "ref_01j9x3m8v7k2q4r5s6t7u8v9w2",
          "probability": 0.92
        }
      ],
      "flags": [],
      "decision": "APPROVE",
      "reasons": [
        "Compared against 2 references",
        "Overall appearance is within this writer's usual range",
        "Shape alignment is within this writer's usual range",
        "Likely genuine but below the auto-approve threshold"
      ],
      "best_match": true
    }
  ],
  "anchor_probability": 0.94,
  "versions": {
    "model": "nib-2.2",
    "policy": "default@7",
    "api_version": "2026-09-27"
  },
  "input_hashes": {
    "questioned": "sha256:8550d9b6affca27a5645d75b058479d2e081b16a135e2d4255e5e83714ebf987",
    "references": [
      "sha256:070a8e98f85afe04ba05542428664ec3684db24bc356133c11d41f9fab27f135",
      "sha256:1cf1da50e3f0be0b50197022be0eedf20fa688cf20ca1154b388098a9d2e29e1"
    ]
  },
  "timings_ms": {
    "total": 1840
  }
}

Signed with Unforged-Signature, rendered in your pinned version (Unforged-Version). Retried with backoff for 24 hours.

  • job.completed
  • job.failed
  • reference.enrolled
  • reference.rejected
a delivery
POST /your/webhook HTTP/1.1
Content-Type: application/json
Unforged-Signature: t=1790000000,v1=5f2c…
Unforged-Version: 2026-09-27
Unforged-Event-Id: evt_01j9x3m8v7k2q4r5s6t7u8v9w0

{
  "id": "evt_01j9x3m8v7k2q4r5s6t7u8v9w0",
  "type": "job.completed",
  "created_at": "2026-09-25T10:15:30.123Z",
  "data": {
    "job_id": "inv-2291",
    "status": "completed",
    "total": 1,
    "counts": {
      "APPROVE": 1
    },
    "failed": 0,
    "items": [
      {
        "id": "ver_01j9x3m8v7k2q4r5s6t7u8v9w0",
        "item_index": 0,
        "file": "questioned",
        "subject_reference": "cust-88",
        "reference": "APP-1042",
        "process": "loan-application",
        "metadata": {
          "branch": "leeds"
        },
        "status": "completed",
        "error": null,
        "decision": "APPROVE",
        "input_type": "photo",
        "pages": 1,
        "notes": [],
        "signatures": [],
        "anchor_probability": 0.94,
        "versions": {
          "model": "nib-2.2",
          "policy": "default@7",
          "api_version": "2026-09-27"
        },
        "input_hashes": null,
        "timings_ms": {
          "total": 1840
        }
      }
    ],
    "items_truncated": false,
    "versions": {
      "model": "nib-2.2",
      "policy": "default@7",
      "api_version": "2026-09-27"
    },
    "status_url": "/nib/checks/inv-2291"
  }
}

# verify: HMAC-SHA256 of "<t>.<raw body>" with your whsec_ secret equals v1

The whole integration

  1. PUT /nib/subjects/{subject_reference}/references

    Enrol references, with where each came from.

  2. POST /nib/checks

    One document answers inline. A batch returns a job.

  3. GET /nib/checks/{job_id}

    Status and results, paged or streamed as NDJSON.

  4. Webhooks

    Signed. Retried with backoff for 24 hours.

Versioning

Current version2026-09-27

  • Send Unforged-Version: 2026-09-27 to choose a version per request. Without it your organisation’s pin applies.
  • Additive changes (new endpoints, optional parameters, response fields) never make a version. Ignore fields you don’t know.
  • Breaking changes ship only in a new dated version.
  • A version stays supported for 24 months after a newer one replaces it.
  • Deprecated versions answer with Deprecation, Sunset and Link headers.

Access

API keys carry permissions: a subset of yours, chosen when you create the key. Each endpoint below names the one it needs.

  • org:read
  • org:members:manage
  • org:sso:manage
  • org:keys:manage
  • org:billing:manage
  • org:settings:manage
  • org:audit:read
  • org:children:access
  • org:children:manage
  • nib:checks:create
  • nib:checks:read
  • nib:subjects:read
  • nib:references:write
  • nib:references:delete
  • nib:settings:manage

Roles and permissions →

Headers

Headers every request may send
HeaderMeaning
AuthorizationBearer <API key> on every authenticated route.
Unforged-Version

The API version (YYYY-MM-DD) to use for this request, overriding the organisation's pinned version (it never changes the pin). An unknown or malformed version, or one past its sunset, is 400 invalid_version.

Unforged-Org

Act in a descendant organisation: its WorkOS organisation id (^[A-Za-z0-9_]{1,64}$, else 400 invalid_request). Needs org:children:access, and the organisation must be an active descendant of the caller's (and inside the caller's subtree when the caller's organisation requires SSO); otherwise 403 permission_denied, the same answer whether or not the organisation exists. The request then runs in the descendant with the caller's permissions minus org:keys:manage, org:billing:manage and org:children:manage, in the caller's API version. Every such request is audited in the descendant (org.act_as, with the caller's organisation as actor_org_id). Your own organisation's id is a no-op.

Idempotency-KeyOn POSTs: the same key and body replay the first answer for 24 hours.

API reference

Nib API

Checks

Check documents (nib:checks:create) and read results (nib:checks:read).

GET /nib/checks #

Search and list checks (jobs, or checked items), newest first

nib:checks:read Read checks and results

view=jobs (the default) lists jobs; view=items lists checked items with your references, for search: exact filters on reference, process, subject_reference, decision and input_type, and q, a prefix of an item's reference, its subject_reference, its file name or its check id (job_id, or job_id/index). metadata is never searched, and lists leave it out (read the item for it). Both views take since and until (on created_at) and page with an opaque cursor: pass the previous page's next_cursor (null on the last page). Cursors are stable: checks made while you page never shift a page. A filter of the other view (status and source are jobs-only) is 400 naming it; so is a cursor this route did not issue.

Parameters of GET /nib/checks
ParameterInType
view

jobs, items, or summary: the checked items the item filters select, counted per decision (ERROR: items without one), with the processes seen in the date range (no decision, cursor or limit).

query"jobs" | "items" | "summary"
cursor

The previous page's next_cursor (opaque).

querystring
limit queryinteger
since

Created at or after this instant (RFC 3339).

querystring
until

Created before this instant (RFC 3339).

querystring
status

view=jobs only.

query"queued" | "running" | "completed" | "failed" | "rejected"
source

view=jobs only.

query"api" | "bucket"
reference

view=items and view=summary: your check reference, exactly.

queryCheckReference
process

view=items and view=summary: the process, exactly.

queryProcess
subject_reference

view=items and view=summary: the subject reference, exactly.

queryId
decision

A comma list of decisions, plus ERROR for items without one (an item error). On GET /nib/checks, view=items only.

querystring
input_type

view=items and view=summary: e.g. photo, scan, form, signature_image.

querystring
q

view=items and view=summary: a prefix of the reference, the subject reference, the file name or the check id (job_id or job_id/index); case-sensitive, at most 128 characters.

querystring
Responses of GET /nib/checks
StatusBodyMeaning
200One page of jobs (view=jobs) or of checked items (view=items), or the counts (view=summary)
400Probleminvalid_request, invalid_version, idempotency_key_required or (Stripe's webhook) invalid_signature
401Problemunauthorized
403Problempermission_denied
421Problemwrong_region (421 Misdirected Request)
429Problemrate_limited (the per-tenant request rate) or quota_exceeded (the monthly quota)
defaultProbleminternal (the cause is logged, never returned)
POST /nib/checks #

Check one document or a batch

nib:checks:create Submit checks, sessions, live

A multipart body with one JSON part request (a CheckRequest) plus one file part per uploaded file, named as the items name them (file, references); or a JSON CheckRequest whose inputs are all URLs ({"url": "https://…"}, fetched once each from your allowlisted hosts and S3 buckets). All the URL fetches of one request share a 60 s deadline (then 504 timeout with reason: fetch_deadline); a body arriving slower than 64 KiB per 10 s fails with fetch_failed, reason: body_stalled.

The job id is request.job_id, else the Idempotency-Key. Exactly one item whose document has at most 10 pages is checked in the request: 200 with the check result. Anything larger is queued: 202 with job_id and status_url (also in Location). If no capacity frees up within the budget the job continues asynchronously and the answer is 503 busy with job_id and status_url; if the item exceeds its 30 s budget, 504 timeout with the same members.

Streaming. With Accept: text/event-stream (exactly one item) the answer is a Server-Sent Events stream (see x-sse-events on the 200 response). The stream closes after decision or error; a : keep-alive comment is sent every 10 s. A stream is not replayable: retrying its Idempotency-Key is 409 idempotency_not_replayable pointing at GET /nib/checks/{job_id}.

Session tokens may call this route only streamed, with one item whose subject_reference is the session's subject and no references, URL inputs, policy, target, template_id (403) or job_id (400). They send no Idempotency-Key.

Parameters of POST /nib/checks
ParameterInType
Idempotency-Keyrequired

1–255 visible ASCII characters, kept 24 h per tenant. On POST /nib/checks it is also the job id when the request names none.

headerstring
Accept

text/event-stream streams the check (Server-Sent Events).

headerstring

RequestCheckRequest

Responses of POST /nib/checks
StatusBodyMeaning
200CheckResultA synchronous check
202CheckAcceptedQueued; poll status_url
400Probleminvalid_request, invalid_version, idempotency_key_required or (Stripe's webhook) invalid_signature
401Problemunauthorized
402Problembilling_required
403Problempermission_denied
408Problemtimeout
409Problemconflict, idempotency_conflict (the key was used with another body, or the job id with other inputs), idempotency_in_progress (the first request still runs) or idempotency_not_replayable (the key's response was a stream)
413Problemtoo_large
415Problemunsupported_media
421Problemwrong_region (421 Misdirected Request)
422Problemfetch_failed (with host and reason) or not_allowlisted
429Problemrate_limited (the per-tenant request rate) or quota_exceeded (the monthly quota)
503Problembusy (no capacity; Retry-After; a check also carries job_id and status_url and continues asynchronously), not_ready, or org_moving (the organisation is being moved to another region; Retry-After)
504Problemtimeout
defaultProbleminternal (the cause is logged, never returned)
Request example
{
  "items": [
    {
      "file": {
        "url": "https://files.example.com/app-1041.png?sig=abc"
      },
      "subject_reference": "cust-88",
      "target": {
        "page": 1,
        "box": "applicant_signature"
      }
    }
  ],
  "policy": "default",
  "metadata": {
    "case_ref": "A-1234"
  }
}
200 response
{
  "id": "ver_01j9x3m8v7k2q4r5s6t7u8v9w0",
  "item_index": 0,
  "file": "questioned",
  "subject_reference": "cust-88",
  "reference": "APP-1042",
  "process": "loan-application",
  "metadata": {
    "branch": "leeds"
  },
  "status": "completed",
  "error": null,
  "decision": "APPROVE",
  "input_type": "photo",
  "pages": 1,
  "notes": [],
  "signatures": [
    {
      "index": 0,
      "page": 1,
      "bbox": [
        412,
        1880,
        498,
        160
      ],
      "located_by": "template",
      "quality": {
        "ok": true,
        "effective_dpi": 214,
        "sharpness": 812.5,
        "contrast": 180,
        "issues": []
      },
      "match_probability": 0.94,
      "band": [
        0.88,
        0.97
      ],
      "reference_count": 2,
      "per_reference": [
        {
          "ref": "ref_01j9x3m8v7k2q4r5s6t7u8v9w1",
          "probability": 0.95
        },
        {
          "ref": "ref_01j9x3m8v7k2q4r5s6t7u8v9w2",
          "probability": 0.92
        }
      ],
      "flags": [],
      "decision": "APPROVE",
      "reasons": [
        "Compared against 2 references",
        "Overall appearance is within this writer's usual range",
        "Shape alignment is within this writer's usual range",
        "Likely genuine but below the auto-approve threshold"
      ],
      "best_match": true
    }
  ],
  "anchor_probability": 0.94,
  "versions": {
    "model": "nib-2.2",
    "policy": "default@7",
    "api_version": "2026-09-27"
  },
  "input_hashes": {
    "questioned": "sha256:8550d9b6affca27a5645d75b058479d2e081b16a135e2d4255e5e83714ebf987",
    "references": [
      "sha256:070a8e98f85afe04ba05542428664ec3684db24bc356133c11d41f9fab27f135",
      "sha256:1cf1da50e3f0be0b50197022be0eedf20fa688cf20ca1154b388098a9d2e29e1"
    ]
  },
  "timings_ms": {
    "total": 1840
  }
}
GET /nib/checks/{job_id} #

A job's status, summary and results

nib:checks:read Read checks and results

Results are paginated by item_index: pass next_cursor as cursor. With format=ndjson a finished job's result lines stream as application/x-ndjson (409 conflict while it runs). decision keeps only the items with those decisions (ERROR: items without one); the pagination is unchanged.

Parameters of GET /nib/checks/{job_id}
ParameterInType
job_idrequired pathId
cursor

The previous page's next_cursor (the last item_index returned).

querystring
limit queryinteger
format query"json" | "ndjson"
decision

A comma list of decisions, plus ERROR for items without one (an item error). On GET /nib/checks, view=items only.

querystring
Responses of GET /nib/checks/{job_id}
StatusBodyMeaning
200JobThe job
400Probleminvalid_request, invalid_version, idempotency_key_required or (Stripe's webhook) invalid_signature
401Problemunauthorized
403Problempermission_denied
404Problemnot_found
409Problemconflict, idempotency_conflict (the key was used with another body, or the job id with other inputs), idempotency_in_progress (the first request still runs) or idempotency_not_replayable (the key's response was a stream)
421Problemwrong_region (421 Misdirected Request)
429Problemrate_limited (the per-tenant request rate) or quota_exceeded (the monthly quota)
defaultProbleminternal (the cause is logged, never returned)
200 response
{
  "job_id": "inv-2291",
  "status": "running",
  "source": "api",
  "items": 40,
  "created_at": "2026-09-25T10:15:30.123Z",
  "started_at": "2026-09-25T10:15:31.000Z",
  "completed_at": null,
  "status_url": "/nib/checks/inv-2291",
  "summary": null,
  "results": [],
  "next_cursor": null
}
GET /nib/checks/{job_id}/items/{index}/image #

The questioned signature, cropped (PNG)

nib:checks:read Read checks and results

A grey PNG of one located signature of the item: its box with 8% padding on each side, at most 640 px on the long side, rendered from the item's input. The crop is kept beside that input (nib/inbox/<job_id>/.crops/) and never outlives it: it expires with the inbox (inbox_ttl, 24 h by default) and with the job's history. 404 when the item has no located signature, or when its input has expired (detail: "the input has expired"). Images have their own rate limit (50/s, burst 200): they never use your API rate.

Parameters of GET /nib/checks/{job_id}/items/{index}/image
ParameterInType
job_idrequired pathId
indexrequired

The item's index in the request (decimal, no sign or leading zero).

pathinteger
signature

The signature's index (default the best match, else the first).

queryinteger
Responses of GET /nib/checks/{job_id}/items/{index}/image
StatusBodyMeaning
200The crop
400Probleminvalid_request, invalid_version, idempotency_key_required or (Stripe's webhook) invalid_signature
401Problemunauthorized
403Problempermission_denied
404Problemnot_found
421Problemwrong_region (421 Misdirected Request)
429Problemrate_limited (the per-tenant request rate) or quota_exceeded (the monthly quota)
503Problembusy (no capacity; Retry-After; a check also carries job_id and status_url and continues asynchronously), not_ready, or org_moving (the organisation is being moved to another region; Retry-After)
defaultProbleminternal (the cause is logged, never returned)
POST /nib/checks/{job_id}/items/{index}/confirm #

Confirm an item's signature as genuine

nib:references:write Enroll references, confirm-to-learn

Records a reviewer's confirmation (the first is kept; audited as check.confirm) and, unless your tenant's learning is off, queues the item for learning. The entry bar is applied later (parent §8.2). No body. See docs/api/learning.md.

Parameters of POST /nib/checks/{job_id}/items/{index}/confirm
ParameterInType
job_idrequired pathId
indexrequired

The item's index in the request (decimal, no sign or leading zero).

pathinteger
Idempotency-Keyrequired

1–255 visible ASCII characters, kept 24 h per tenant. On POST /nib/checks it is also the job id when the request names none.

headerstring
Responses of POST /nib/checks/{job_id}/items/{index}/confirm
StatusBodyMeaning
200ConfirmedRecorded
400Probleminvalid_request, invalid_version, idempotency_key_required or (Stripe's webhook) invalid_signature
401Problemunauthorized
402Problembilling_required
403Problempermission_denied
404Problemnot_found
409Problemconflict, idempotency_conflict (the key was used with another body, or the job id with other inputs), idempotency_in_progress (the first request still runs) or idempotency_not_replayable (the key's response was a stream)
421Problemwrong_region (421 Misdirected Request)
429Problemrate_limited (the per-tenant request rate) or quota_exceeded (the monthly quota)
defaultProbleminternal (the cause is logged, never returned)
200 response
{
  "job_id": "inv-2291",
  "item_index": 0,
  "confirmed_by": "api_key:key_01j9x3m8v7k2q4r5s6t7u8v9w0",
  "confirmed_at": "2026-09-25T11:00:00Z",
  "learning": "queued"
}

Subjects

Enrolled people and their reference signatures (nib:subjects:read, nib:references:*).

GET /nib/subjects #

List subjects, newest first

nib:subjects:read Read subjects/references metadata and thumbnails

Your subjects with their reference counts (live references; anchors are the ones you enrolled, learned the ones Nib learned) and their last check. q is a prefix of the subject reference. Page with cursor (the previous page's next_cursor).

Parameters of GET /nib/subjects
ParameterInType
cursor

The previous page's next_cursor (opaque).

querystring
limit queryinteger
q

A prefix of the subject reference.

querystring
Responses of GET /nib/subjects
StatusBodyMeaning
200SubjectListOne page of subjects
400Probleminvalid_request, invalid_version, idempotency_key_required or (Stripe's webhook) invalid_signature
401Problemunauthorized
403Problempermission_denied
421Problemwrong_region (421 Misdirected Request)
429Problemrate_limited (the per-tenant request rate) or quota_exceeded (the monthly quota)
defaultProbleminternal (the cause is logged, never returned)
200 response
{
  "subjects": [
    {
      "subject_reference": "cust-88",
      "created_at": "2026-09-25T10:15:30.123Z",
      "last_checked_at": "2026-09-27T14:22:05.120Z",
      "references": {
        "total": 3,
        "anchors": 2,
        "learned": 1
      }
    }
  ],
  "next_cursor": null
}
GET /nib/subjects/{subject_reference} #

A subject and its references, with provenance

nib:subjects:read Read subjects/references metadata and thumbnails

Also the subject's checks over time: checks.recent holds its newest 25 checked items (as in GET /nib/checks?view=items), checks.total counts them all, and checks.next lists them all.

Parameters of GET /nib/subjects/{subject_reference}
ParameterInType
subject_referencerequired

Your id for the person.

pathId
Responses of GET /nib/subjects/{subject_reference}
StatusBodyMeaning
200SubjectThe subject
401Problemunauthorized
403Problempermission_denied
404Problemnot_found
421Problemwrong_region (421 Misdirected Request)
429Problemrate_limited (the per-tenant request rate) or quota_exceeded (the monthly quota)
defaultProbleminternal (the cause is logged, never returned)
200 response
{
  "subject_reference": "cust-88",
  "created_at": "2026-09-25T10:15:30.123Z",
  "checks": {
    "recent": [
      {
        "job_id": "inv-2291",
        "index": 0,
        "reference": "APP-1042",
        "process": "loan-application",
        "subject_reference": "cust-88",
        "file": "questioned",
        "input_type": "photo",
        "decision": "APPROVE",
        "match_probability": 0.94,
        "created_at": "2026-09-27T14:22:05.120Z"
      }
    ],
    "total": 1,
    "next": "/nib/checks?view=items&subject_reference=cust-88"
  },
  "references": [
    {
      "ref_id": "card-2019",
      "ref": "ref_01j9x3m8v7k2q4r5s6t7u8v9w1",
      "source": "signature_card",
      "anchor": true,
      "learned_from": null,
      "confirmed_by": null,
      "content_hash": "sha256:070a8e98f85afe04ba05542428664ec3684db24bc356133c11d41f9fab27f135",
      "quality": {
        "ok": true
      },
      "versions": {
        "model": "nib-2.2"
      },
      "created_at": "2026-09-25T10:15:30.123Z",
      "unlearned_at": null
    }
  ]
}
DELETE /nib/subjects/{subject_reference} #

Hard-delete a subject, its references and their files

nib:references:delete Delete references and subjects, unlearn

Also revokes the subject's sessions. Audited.

Parameters of DELETE /nib/subjects/{subject_reference}
ParameterInType
subject_referencerequired

Your id for the person.

pathId
Responses of DELETE /nib/subjects/{subject_reference}
StatusBodyMeaning
204Done
401Problemunauthorized
403Problempermission_denied
404Problemnot_found
421Problemwrong_region (421 Misdirected Request)
429Problemrate_limited (the per-tenant request rate) or quota_exceeded (the monthly quota)
defaultProbleminternal (the cause is logged, never returned)
PUT /nib/subjects/{subject_reference}/references #

Enrol reference signatures

nib:references:write Enroll references, confirm-to-learn

Adds to the subject's references, creating the subject if new. Multipart: one file part per reference, named by its ref_id (PNG, JPEG, TIFF or PDF; at most 20 files and 20 MB in total), each optionally with a text field source.<ref_id>. Or JSON: references by URL. An upload replaces that ref_id. Up to 3 references are enrolled in the request (200); more are queued (202, each queued). ref_ids starting with learned- are reserved (400). A PUT needs no Idempotency-Key: it is idempotent.

Parameters of PUT /nib/subjects/{subject_reference}/references
ParameterInType
subject_referencerequired

Your id for the person.

pathId

RequestReferencesByUrl

Responses of PUT /nib/subjects/{subject_reference}/references
StatusBodyMeaning
200ReferencesResultEvery reference enrolled (or rejected) in the request
202ReferencesResultSome references were queued for enrolment
400Probleminvalid_request, invalid_version, idempotency_key_required or (Stripe's webhook) invalid_signature
401Problemunauthorized
403Problempermission_denied
413Problemtoo_large
415Problemunsupported_media
421Problemwrong_region (421 Misdirected Request)
422Problemfetch_failed (with host and reason) or not_allowlisted
429Problemrate_limited (the per-tenant request rate) or quota_exceeded (the monthly quota)
503Problembusy (no capacity; Retry-After; a check also carries job_id and status_url and continues asynchronously), not_ready, or org_moving (the organisation is being moved to another region; Retry-After)
504Problemtimeout
defaultProbleminternal (the cause is logged, never returned)
Request example
{
  "references": [
    {
      "ref_id": "card-2019",
      "url": "https://files.example.com/card.png",
      "source": "signature_card"
    }
  ]
}
200 response
{
  "subject_reference": "cust-88",
  "references": [
    {
      "ref_id": "card-2019",
      "source": "signature_card",
      "status": "enrolled",
      "ref": "ref_01j9x3m8v7k2q4r5s6t7u8v9w1"
    },
    {
      "ref_id": "blurry",
      "source": "unverified",
      "status": "rejected",
      "reason": "quality",
      "detail": "the image is too blurred"
    }
  ]
}
DELETE /nib/subjects/{subject_reference}/references/{ref_id} #

Hard-delete one reference and its files

nib:references:delete Delete references and subjects, unlearn

A learned reference (learned-…) is removed with unlearn instead (409).

Parameters of DELETE /nib/subjects/{subject_reference}/references/{ref_id}
ParameterInType
subject_referencerequired

Your id for the person.

pathId
ref_idrequired pathId
Responses of DELETE /nib/subjects/{subject_reference}/references/{ref_id}
StatusBodyMeaning
204Done
401Problemunauthorized
403Problempermission_denied
404Problemnot_found
409Problemconflict, idempotency_conflict (the key was used with another body, or the job id with other inputs), idempotency_in_progress (the first request still runs) or idempotency_not_replayable (the key's response was a stream)
421Problemwrong_region (421 Misdirected Request)
429Problemrate_limited (the per-tenant request rate) or quota_exceeded (the monthly quota)
defaultProbleminternal (the cause is logged, never returned)
GET /nib/subjects/{subject_reference}/references/{ref_id}/image #

A reference signature's thumbnail (PNG)

nib:subjects:read Read subjects/references metadata and thumbnails

A grey PNG of the stored reference (its first page), at most 512 px on the long side. 404 when the reference or its file is gone, or it was unlearned. Images have their own rate limit (50/s, burst 200): they never use your API rate.

Parameters of GET /nib/subjects/{subject_reference}/references/{ref_id}/image
ParameterInType
subject_referencerequired

Your id for the person.

pathId
ref_idrequired pathId
Responses of GET /nib/subjects/{subject_reference}/references/{ref_id}/image
StatusBodyMeaning
200The thumbnail
401Problemunauthorized
403Problempermission_denied
404Problemnot_found
421Problemwrong_region (421 Misdirected Request)
429Problemrate_limited (the per-tenant request rate) or quota_exceeded (the monthly quota)
503Problembusy (no capacity; Retry-After; a check also carries job_id and status_url and continues asynchronously), not_ready, or org_moving (the organisation is being moved to another region; Retry-After)
defaultProbleminternal (the cause is logged, never returned)
POST /nib/subjects/{subject_reference}/references/unlearn #

Remove learned references

nib:references:delete Delete references and subjects, unlearn

Removes learned references by time (since, by check time) or by source check, deletes their files and blocks those checks from ever being learned again. since blocks the checks recorded up to this call; a job still running then (submitted inside the window) records its checks later and is not blocked. Audited (reference.unlearn). See docs/api/learning.md.

Parameters of POST /nib/subjects/{subject_reference}/references/unlearn
ParameterInType
subject_referencerequired

Your id for the person.

pathId
Idempotency-Keyrequired

1–255 visible ASCII characters, kept 24 h per tenant. On POST /nib/checks it is also the job id when the request names none.

headerstring

RequestUnlearnRequest

Responses of POST /nib/subjects/{subject_reference}/references/unlearn
StatusBodyMeaning
200UnlearnedThe learned references removed (possibly none)
400Probleminvalid_request, invalid_version, idempotency_key_required or (Stripe's webhook) invalid_signature
401Problemunauthorized
403Problempermission_denied
404Problemnot_found
409Problemconflict, idempotency_conflict (the key was used with another body, or the job id with other inputs), idempotency_in_progress (the first request still runs) or idempotency_not_replayable (the key's response was a stream)
421Problemwrong_region (421 Misdirected Request)
429Problemrate_limited (the per-tenant request rate) or quota_exceeded (the monthly quota)
defaultProbleminternal (the cause is logged, never returned)
Request example
{
  "since": "2026-09-01T00:00:00Z"
}
200 response
{
  "subject_reference": "cust-88",
  "unlearned": [
    "learned-01j9x3m8v7k2q4r5s6t7u8v9w0"
  ]
}
DELETE /nib/subjects/{subject_reference}/references/unlearn #

Hard-delete the reference whose ref_id is `unlearn`

nib:references:delete Delete references and subjects, unlearn

The same as DELETE /nib/subjects/{subject_reference}/references/{ref_id} for the one ref_id that the static unlearn path shadows.

Parameters of DELETE /nib/subjects/{subject_reference}/references/unlearn
ParameterInType
subject_referencerequired

Your id for the person.

pathId
Responses of DELETE /nib/subjects/{subject_reference}/references/unlearn
StatusBodyMeaning
204Done
401Problemunauthorized
403Problempermission_denied
404Problemnot_found
421Problemwrong_region (421 Misdirected Request)
429Problemrate_limited (the per-tenant request rate) or quota_exceeded (the monthly quota)
defaultProbleminternal (the cause is logged, never returned)

Sessions

Short-lived client session tokens for end-user apps (nib:checks:create).

POST /nib/sessions #

Create a client session token

nib:checks:create Submit checks, sessions, live

For end-user apps, which never hold an API key. The token is limited to one subject and one streamed check, works only on GET /nib/live and streamed POST /nib/checks, and is shown once (Cache-Control: no-store). No Idempotency-Key (a stored response would keep the token): a retry mints another session. 404 for an unknown subject. While the organisation (or one above it) is suspended, its session tokens are refused with 401 unauthorized, like expired ones.

RequestSessionCreate

Responses of POST /nib/sessions
StatusBodyMeaning
201SessionCreated
400Probleminvalid_request, invalid_version, idempotency_key_required or (Stripe's webhook) invalid_signature
401Problemunauthorized
402Problembilling_required
403Problempermission_denied
404Problemnot_found
421Problemwrong_region (421 Misdirected Request)
429Problemrate_limited (the per-tenant request rate) or quota_exceeded (the monthly quota)
defaultProbleminternal (the cause is logged, never returned)
Request example
{
  "subject_reference": "cust-88",
  "scope": [
    "check"
  ],
  "ttl_seconds": 300
}
201 response
{
  "id": "ses_01j9x3m8v7k2q4r5s6t7u8v9w0",
  "token": "unf_ses_6H8mZ2kQ4rT7wY9aB1cD3eF5gJ7kL9mN0pQ2rS4tU6v",
  "expires_at": "2026-09-25T10:20:30Z",
  "max_checks": 1,
  "subject_reference": "cust-88"
}
DELETE /nib/sessions/{session_id} #

Revoke a session

nib:checks:create Submit checks, sessions, live

204 also when already revoked. Open live connections of it close within 30 s.

Parameters of DELETE /nib/sessions/{session_id}
ParameterInType
session_idrequired pathstring
Responses of DELETE /nib/sessions/{session_id}
StatusBodyMeaning
204Done
401Problemunauthorized
403Problempermission_denied
404Problemnot_found
421Problemwrong_region (421 Misdirected Request)
429Problemrate_limited (the per-tenant request rate) or quota_exceeded (the monthly quota)
defaultProbleminternal (the cause is logged, never returned)

Live

Server-side live capture guidance over a WebSocket.

GET /nib/live #

Live capture guidance (WebSocket)

nib:checks:create Submit checks, sessions, live

A WebSocket upgrade. Authenticate with an API key or dashboard token holding nib:checks:create, or a session token in Authorization: Bearer …; browsers, which cannot set that header, send Sec-WebSocket-Protocol: unforged.session, <session token> and the server selects unforged.session.

Send each downscaled preview frame as one binary message: a PNG or JPEG of at most 640 px on its long side and 512 KB, at most 5 per second. Each message gets exactly one JSON text reply, in order (see x-websocket-messages). Frames are analysed in memory and never stored.

A connection closes after 60 s without a data message, after 10 minutes, and when its credential is no longer valid (close code 1008, checked every 30 s): the session expires or is revoked, the API key is revoked, or the organisation (or the one acted in with Unforged-Org) is suspended. At most 20 open connections per tenant (429 before the upgrade) and 200 per instance (503).

Parameters of GET /nib/live
ParameterInType
Upgraderequired headerstring
Sec-WebSocket-Protocol

unforged.session, <session token> (browsers).

headerstring
Responses of GET /nib/live
StatusBodyMeaning
101Switching protocols; the WebSocket is open
400Probleminvalid_request, invalid_version, idempotency_key_required or (Stripe's webhook) invalid_signature
401Problemunauthorized
402Problembilling_required
403Problempermission_denied
421Problemwrong_region (421 Misdirected Request)
429Problemrate_limited (the per-tenant request rate) or quota_exceeded (the monthly quota)
503Problembusy (no capacity; Retry-After; a check also carries job_id and status_url and continues asynchronously), not_ready, or org_moving (the organisation is being moved to another region; Retry-After)
defaultProbleminternal (the cause is logged, never returned)

Admin

Policies, templates and bucket keys (reads org:read, changes nib:settings:manage).

GET /nib/policies #

The latest version of each policy

org:read See org, members, usage

Responses of GET /nib/policies
StatusBodyMeaning
200The policies
401Problemunauthorized
403Problempermission_denied
421Problemwrong_region (421 Misdirected Request)
429Problemrate_limited (the per-tenant request rate) or quota_exceeded (the monthly quota)
defaultProbleminternal (the cause is logged, never returned)
GET /nib/policies/{name} #

A policy (the latest version, or `?version=N`)

org:read See org, members, usage

Parameters of GET /nib/policies/{name}
ParameterInType
namerequired pathId
version queryinteger
Responses of GET /nib/policies/{name}
StatusBodyMeaning
200PolicyThe policy version
400Probleminvalid_request, invalid_version, idempotency_key_required or (Stripe's webhook) invalid_signature
401Problemunauthorized
403Problempermission_denied
404Problemnot_found
421Problemwrong_region (421 Misdirected Request)
429Problemrate_limited (the per-tenant request rate) or quota_exceeded (the monthly quota)
defaultProbleminternal (the cause is logged, never returned)
200 response
{
  "name": "strict",
  "version": 2,
  "id": "strict@2",
  "auto_approve_enabled": false,
  "auto_threshold": 0.97,
  "approve_threshold": 0.85,
  "fail_threshold": 0.3,
  "quality": null,
  "created_by": "api_key:key_01j9x3m8v7k2q4r5s6t7u8v9w0",
  "created_at": "2026-09-25T10:15:30.123Z"
}
PUT /nib/policies/{name} #

Store the next version of a policy

nib:settings:manage Policy, learning, templates, webhooks, bucket keys

201 with the new version; 200 with the latest one when the body equals it (a retried PUT does not mint versions). Versions are immutable; checks use the latest version of the policy they name and record name@version in versions.policy. Audited.

Parameters of PUT /nib/policies/{name}
ParameterInType
namerequired pathId

RequestPolicySpec

Responses of PUT /nib/policies/{name}
StatusBodyMeaning
200PolicyUnchanged; the latest version
201PolicyA new version
400Probleminvalid_request, invalid_version, idempotency_key_required or (Stripe's webhook) invalid_signature
401Problemunauthorized
403Problempermission_denied
413Problemtoo_large
421Problemwrong_region (421 Misdirected Request)
429Problemrate_limited (the per-tenant request rate) or quota_exceeded (the monthly quota)
defaultProbleminternal (the cause is logged, never returned)
Request example
{
  "auto_approve_enabled": false,
  "auto_threshold": 0.97,
  "approve_threshold": 0.85,
  "fail_threshold": 0.3
}
200 response
{
  "name": "strict",
  "version": 2,
  "id": "strict@2",
  "auto_approve_enabled": false,
  "auto_threshold": 0.97,
  "approve_threshold": 0.85,
  "fail_threshold": 0.3,
  "quality": null,
  "created_by": "api_key:key_01j9x3m8v7k2q4r5s6t7u8v9w0",
  "created_at": "2026-09-25T10:15:30.123Z"
}
GET /nib/templates #

Form templates

org:read See org, members, usage

Responses of GET /nib/templates
StatusBodyMeaning
200The templates
401Problemunauthorized
403Problempermission_denied
421Problemwrong_region (421 Misdirected Request)
429Problemrate_limited (the per-tenant request rate) or quota_exceeded (the monthly quota)
defaultProbleminternal (the cause is logged, never returned)
POST /nib/templates #

Upload a form template

nib:settings:manage Policy, learning, templates, webhooks, bucket keys

Multipart: a template JSON part (TemplateCreate) and a blank file part (PNG or PDF, at most 20 MB). 409 when the id exists (delete it first) or you have 200 templates. Audited.

Parameters of POST /nib/templates
ParameterInType
Idempotency-Keyrequired

1–255 visible ASCII characters, kept 24 h per tenant. On POST /nib/checks it is also the job id when the request names none.

headerstring
Responses of POST /nib/templates
StatusBodyMeaning
201TemplateCreated
400Probleminvalid_request, invalid_version, idempotency_key_required or (Stripe's webhook) invalid_signature
401Problemunauthorized
403Problempermission_denied
409Problemconflict, idempotency_conflict (the key was used with another body, or the job id with other inputs), idempotency_in_progress (the first request still runs) or idempotency_not_replayable (the key's response was a stream)
413Problemtoo_large
415Problemunsupported_media
421Problemwrong_region (421 Misdirected Request)
429Problemrate_limited (the per-tenant request rate) or quota_exceeded (the monthly quota)
503Problembusy (no capacity; Retry-After; a check also carries job_id and status_url and continues asynchronously), not_ready, or org_moving (the organisation is being moved to another region; Retry-After)
defaultProbleminternal (the cause is logged, never returned)
201 response
{
  "template_id": "benefits-form-v3",
  "name": "Benefits form",
  "boxes": [
    {
      "name": "applicant_signature",
      "x": 400,
      "y": 1860,
      "w": 520,
      "h": 200
    }
  ],
  "blank_type": "png",
  "feature_hash": "sha256:1cf1da50e3f0be0b50197022be0eedf20fa688cf20ca1154b388098a9d2e29e1",
  "created_at": "2026-09-25T10:15:30.123Z"
}
GET /nib/templates/{template_id} #

A form template

org:read See org, members, usage

Parameters of GET /nib/templates/{template_id}
ParameterInType
template_idrequired pathId
Responses of GET /nib/templates/{template_id}
StatusBodyMeaning
200TemplateThe template
401Problemunauthorized
403Problempermission_denied
404Problemnot_found
421Problemwrong_region (421 Misdirected Request)
429Problemrate_limited (the per-tenant request rate) or quota_exceeded (the monthly quota)
defaultProbleminternal (the cause is logged, never returned)
200 response
{
  "template_id": "benefits-form-v3",
  "name": "Benefits form",
  "boxes": [
    {
      "name": "applicant_signature",
      "x": 400,
      "y": 1860,
      "w": 520,
      "h": 200
    }
  ],
  "blank_type": "png",
  "feature_hash": "sha256:1cf1da50e3f0be0b50197022be0eedf20fa688cf20ca1154b388098a9d2e29e1",
  "created_at": "2026-09-25T10:15:30.123Z"
}
DELETE /nib/templates/{template_id} #

Delete a form template and its files

nib:settings:manage Policy, learning, templates, webhooks, bucket keys

Parameters of DELETE /nib/templates/{template_id}
ParameterInType
template_idrequired pathId
Responses of DELETE /nib/templates/{template_id}
StatusBodyMeaning
204Done
401Problemunauthorized
403Problempermission_denied
404Problemnot_found
421Problemwrong_region (421 Misdirected Request)
429Problemrate_limited (the per-tenant request rate) or quota_exceeded (the monthly quota)
defaultProbleminternal (the cause is logged, never returned)
GET /nib/bucket-keys #

The S3 access keys of your bucket's IAM user

nib:settings:manage Policy, learning, templates, webhooks, bucket keys

Responses of GET /nib/bucket-keys
StatusBodyMeaning
200BucketKeyListThe keys (from IAM)
401Problemunauthorized
403Problempermission_denied
421Problemwrong_region (421 Misdirected Request)
429Problemrate_limited (the per-tenant request rate) or quota_exceeded (the monthly quota)
501Problemnot_available_in_dev (development deployments) or not_implemented
503Problembusy (no capacity; Retry-After; a check also carries job_id and status_url and continues asynchronously), not_ready, or org_moving (the organisation is being moved to another region; Retry-After)
defaultProbleminternal (the cause is logged, never returned)
200 response
{
  "iam_user": "unf-prod-acme",
  "bucket_keys": [
    {
      "id": "bkt_01j9x3m8v7k2q4r5s6t7u8v9w0",
      "access_key_id": "AKIAIOSFODNN7EXAMPLE",
      "status": "active",
      "created_at": "2026-09-25T10:15:30Z",
      "reissue_required": false
    }
  ]
}
POST /nib/bucket-keys #

Issue an S3 access key for your bucket

nib:settings:manage Policy, learning, templates, webhooks, bucket keys

The secret is shown once (Cache-Control: no-store). At most 2 active keys (409). The body, if any, must be {}. No Idempotency-Key: a retry issues another key. Audited.

Responses of POST /nib/bucket-keys
StatusBodyMeaning
201BucketKeyCreatedCreated
400Probleminvalid_request, invalid_version, idempotency_key_required or (Stripe's webhook) invalid_signature
401Problemunauthorized
402Problembilling_required
403Problempermission_denied
409Problemconflict, idempotency_conflict (the key was used with another body, or the job id with other inputs), idempotency_in_progress (the first request still runs) or idempotency_not_replayable (the key's response was a stream)
421Problemwrong_region (421 Misdirected Request)
429Problemrate_limited (the per-tenant request rate) or quota_exceeded (the monthly quota)
501Problemnot_available_in_dev (development deployments) or not_implemented
503Problembusy (no capacity; Retry-After; a check also carries job_id and status_url and continues asynchronously), not_ready, or org_moving (the organisation is being moved to another region; Retry-After)
defaultProbleminternal (the cause is logged, never returned)
201 response
{
  "id": "bkt_01j9x3m8v7k2q4r5s6t7u8v9w0",
  "access_key_id": "AKIAIOSFODNN7EXAMPLE",
  "secret_access_key": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
  "iam_user": "unf-prod-acme",
  "created_at": "2026-09-25T10:15:30Z"
}
DELETE /nib/bucket-keys/{id} #

Delete an S3 access key

nib:settings:manage Policy, learning, templates, webhooks, bucket keys

id is a bkt_… id or an access key id of your IAM user. Audited.

Parameters of DELETE /nib/bucket-keys/{id}
ParameterInType
idrequired

A bkt_… id, or an access key id of your IAM user.

pathstring
Responses of DELETE /nib/bucket-keys/{id}
StatusBodyMeaning
204Done
401Problemunauthorized
403Problempermission_denied
404Problemnot_found
421Problemwrong_region (421 Misdirected Request)
429Problemrate_limited (the per-tenant request rate) or quota_exceeded (the monthly quota)
501Problemnot_available_in_dev (development deployments) or not_implemented
503Problembusy (no capacity; Retry-After; a check also carries job_id and status_url and continues asynchronously), not_ready, or org_moving (the organisation is being moved to another region; Retry-After)
defaultProbleminternal (the cause is logged, never returned)
GET /nib/settings/retention #

How long the job history is kept

org:read See org, members, usage

The tenant's history retention (default 90 days), the results/ lifecycle backstop derived from it, and the audit log's fixed retention. See docs/api/retention.md.

Responses of GET /nib/settings/retention
StatusBodyMeaning
200RetentionThe retention settings
401Problemunauthorized
403Problempermission_denied
421Problemwrong_region (421 Misdirected Request)
429Problemrate_limited (the per-tenant request rate) or quota_exceeded (the monthly quota)
defaultProbleminternal (the cause is logged, never returned)
200 response
{
  "history_retention": 90,
  "default": true,
  "results_lifecycle_days": 90,
  "audit_log_retention_days": 400
}
PUT /nib/settings/retention #

Set how long the job history is kept

org:settings:manage Retention, org-wide settings, the API version pin

Finished jobs (jobs, their result rows, nib/results/<job_id>/ and anything left in nib/inbox/<job_id>/) are purged once older than the retention, within about a minute. A job that a live learned reference came from is kept until that reference is unlearned or evicted. Usage records (billing) and the monthly quota count are never affected. A change is audited (settings.retention.set); setting the current value again is not.

Responses of PUT /nib/settings/retention
StatusBodyMeaning
200RetentionSet; the new retention settings
400Probleminvalid_request, invalid_version, idempotency_key_required or (Stripe's webhook) invalid_signature
401Problemunauthorized
403Problempermission_denied
413Problemtoo_large
421Problemwrong_region (421 Misdirected Request)
429Problemrate_limited (the per-tenant request rate) or quota_exceeded (the monthly quota)
defaultProbleminternal (the cause is logged, never returned)
Request example
{
  "history_retention": 30
}
200 response
{
  "history_retention": 90,
  "default": true,
  "results_lifecycle_days": 90,
  "audit_log_retention_days": 400
}
GET /nib/settings/learning #

Reference learning settings

org:read See org, members, usage

Whether accepted signatures can become extra references of their subject, and the bar they must reach. The defaults (learning off, 0.99, 10) until they are set. Also terms_accepted, the tenant's most recent biometric-retention terms acceptance (null if none was ever recorded; Task 9 fix round 1). See docs/api/learning.md.

Responses of GET /nib/settings/learning
StatusBodyMeaning
200LearningViewThe learning settings
401Problemunauthorized
403Problempermission_denied
421Problemwrong_region (421 Misdirected Request)
429Problemrate_limited (the per-tenant request rate) or quota_exceeded (the monthly quota)
defaultProbleminternal (the cause is logged, never returned)
200 response
{
  "reference_learning": "confirmed_only",
  "learn_min_band_lo": 0.99,
  "max_learned": 10,
  "terms_accepted": {
    "terms_version": "2026-09-27",
    "accepted_by": "user:user_01ABCDEF",
    "accepted_at": "2026-09-27T12:00:00.000Z"
  }
}
PUT /nib/settings/learning #

Set the reference learning settings

nib:settings:manage Policy, learning, templates, webhooks, bucket keys

Replaces the three settings (all are required). learn_min_band_lo is 0.5 to 1 and max_learned 0 to 50; a refusal is 400 naming the field. Turning learning on (from off) is refused with 409 learning_terms_required unless the tenant has accepted the current biometric-retention terms (POST /nib/settings/learning/terms); turning it off is always allowed. The next check uses the new settings. A change is audited (settings.learning); setting the current value again is not.

RequestLearningSettings

Responses of PUT /nib/settings/learning
StatusBodyMeaning
200LearningSettingsSet; the new learning settings
400Probleminvalid_request, invalid_version, idempotency_key_required or (Stripe's webhook) invalid_signature
401Problemunauthorized
403Problempermission_denied
409Problemconflict, idempotency_conflict (the key was used with another body, or the job id with other inputs), idempotency_in_progress (the first request still runs) or idempotency_not_replayable (the key's response was a stream)
413Problemtoo_large
421Problemwrong_region (421 Misdirected Request)
429Problemrate_limited (the per-tenant request rate) or quota_exceeded (the monthly quota)
defaultProbleminternal (the cause is logged, never returned)
Request example
{
  "reference_learning": "confirmed_only",
  "learn_min_band_lo": 0.99,
  "max_learned": 10
}
200 response
{
  "reference_learning": "confirmed_only",
  "learn_min_band_lo": 0.99,
  "max_learned": 10
}

Webhooks

Webhook endpoints and their delivery log (nib:settings:manage; listing org:read).

GET /nib/webhooks #

Webhook endpoints (never the secrets)

org:read See org, members, usage

Responses of GET /nib/webhooks
StatusBodyMeaning
200The endpoints
401Problemunauthorized
403Problempermission_denied
421Problemwrong_region (421 Misdirected Request)
429Problemrate_limited (the per-tenant request rate) or quota_exceeded (the monthly quota)
defaultProbleminternal (the cause is logged, never returned)
POST /nib/webhooks #

Create a webhook endpoint

nib:settings:manage Policy, learning, templates, webhooks, bucket keys

The URL must be https (port 443, or an explicit port ≥ 1024) and resolve to public addresses only. Before the endpoint is created, a webhook.test event signed with the new secret is posted to the URL: anything but a 2xx within 10 s is 400 webhook_test_failed and nothing is created. The secret (whsec_…) is shown once (Cache-Control: no-store). At most 20 endpoints (409). 503 not_ready while the service has no master key. No Idempotency-Key. See docs/api/webhooks.md.

RequestWebhookCreate

Responses of POST /nib/webhooks
StatusBodyMeaning
201WebhookCreatedCreated
400Probleminvalid_request, invalid_version, idempotency_key_required or (Stripe's webhook) invalid_signature
401Problemunauthorized
403Problempermission_denied
409Problemconflict, idempotency_conflict (the key was used with another body, or the job id with other inputs), idempotency_in_progress (the first request still runs) or idempotency_not_replayable (the key's response was a stream)
413Problemtoo_large
421Problemwrong_region (421 Misdirected Request)
429Problemrate_limited (the per-tenant request rate) or quota_exceeded (the monthly quota)
503Problembusy (no capacity; Retry-After; a check also carries job_id and status_url and continues asynchronously), not_ready, or org_moving (the organisation is being moved to another region; Retry-After)
defaultProbleminternal (the cause is logged, never returned)
Request example
{
  "url": "https://hooks.example.com/unforged",
  "events": [
    "job.completed",
    "reference.rejected"
  ]
}
201 response
{
  "id": "whk_01j9x3m8v7k2q4r5s6t7u8v9w0",
  "url": "https://hooks.example.com/unforged",
  "events": [
    "job.completed",
    "reference.rejected"
  ],
  "created_at": "2026-09-25T10:15:30.123Z",
  "secret": "whsec_6H8mZ2kQ4rT7wY9aB1cD3eF5gJ7kL9mN0pQ2rS4tU6v"
}
DELETE /nib/webhooks/{webhook_id} #

Delete a webhook endpoint

nib:settings:manage Policy, learning, templates, webhooks, bucket keys

Deliveries still queued for it are dropped. Audited.

Parameters of DELETE /nib/webhooks/{webhook_id}
ParameterInType
webhook_idrequired pathstring
Responses of DELETE /nib/webhooks/{webhook_id}
StatusBodyMeaning
204Done
401Problemunauthorized
403Problempermission_denied
404Problemnot_found
421Problemwrong_region (421 Misdirected Request)
429Problemrate_limited (the per-tenant request rate) or quota_exceeded (the monthly quota)
defaultProbleminternal (the cause is logged, never returned)
POST /nib/webhooks/{webhook_id}/secret/roll #

Replace a webhook's signing secret

nib:settings:manage Policy, learning, templates, webhooks, bucket keys

A new secret (whsec_…, shown once, Cache-Control: no-store) replaces the old one at once: every delivery from now on, including retries of earlier events, is signed with the new secret only. Update your endpoint's secret straight away. 503 not_ready while the service has no master key. No Idempotency-Key. Audited.

Parameters of POST /nib/webhooks/{webhook_id}/secret/roll
ParameterInType
webhook_idrequired pathstring
Responses of POST /nib/webhooks/{webhook_id}/secret/roll
StatusBodyMeaning
200WebhookCreatedThe endpoint and its new secret
401Problemunauthorized
403Problempermission_denied
404Problemnot_found
421Problemwrong_region (421 Misdirected Request)
429Problemrate_limited (the per-tenant request rate) or quota_exceeded (the monthly quota)
503Problembusy (no capacity; Retry-After; a check also carries job_id and status_url and continues asynchronously), not_ready, or org_moving (the organisation is being moved to another region; Retry-After)
defaultProbleminternal (the cause is logged, never returned)
200 response
{
  "id": "whk_01j9x3m8v7k2q4r5s6t7u8v9w0",
  "url": "https://hooks.example.com/unforged",
  "events": [
    "job.completed",
    "reference.rejected"
  ],
  "created_at": "2026-09-25T10:15:30.123Z",
  "secret": "whsec_6H8mZ2kQ4rT7wY9aB1cD3eF5gJ7kL9mN0pQ2rS4tU6v"
}
GET /nib/webhooks/{webhook_id}/deliveries #

The delivery log, one entry per attempt, newest first

nib:settings:manage Policy, learning, templates, webhooks, bucket keys

Parameters of GET /nib/webhooks/{webhook_id}/deliveries
ParameterInType
webhook_idrequired pathstring
limit queryinteger
cursor

The previous page's next_cursor.

querystring
Responses of GET /nib/webhooks/{webhook_id}/deliveries
StatusBodyMeaning
200WebhookDeliveryPageA page of attempts (kept 30 days)
400Probleminvalid_request, invalid_version, idempotency_key_required or (Stripe's webhook) invalid_signature
401Problemunauthorized
403Problempermission_denied
404Problemnot_found
421Problemwrong_region (421 Misdirected Request)
429Problemrate_limited (the per-tenant request rate) or quota_exceeded (the monthly quota)
defaultProbleminternal (the cause is logged, never returned)
POST /nib/webhooks/{webhook_id}/deliveries/{delivery_id}/redeliver #

Send an attempt's event again

nib:settings:manage Policy, learning, templates, webhooks, bucket keys

Queues a new delivery of the same event (same id and body). 503 not_ready while the service has no master key. Audited. The delivery log keeps attempts 30 days; an older delivery_id is 404.

Parameters of POST /nib/webhooks/{webhook_id}/deliveries/{delivery_id}/redeliver
ParameterInType
webhook_idrequired pathstring
delivery_idrequired pathstring
Idempotency-Keyrequired

1–255 visible ASCII characters, kept 24 h per tenant. On POST /nib/checks it is also the job id when the request names none.

headerstring
Responses of POST /nib/webhooks/{webhook_id}/deliveries/{delivery_id}/redeliver
StatusBodyMeaning
202RedeliveryQueued
400Probleminvalid_request, invalid_version, idempotency_key_required or (Stripe's webhook) invalid_signature
401Problemunauthorized
403Problempermission_denied
404Problemnot_found
409Problemconflict, idempotency_conflict (the key was used with another body, or the job id with other inputs), idempotency_in_progress (the first request still runs) or idempotency_not_replayable (the key's response was a stream)
421Problemwrong_region (421 Misdirected Request)
429Problemrate_limited (the per-tenant request rate) or quota_exceeded (the monthly quota)
503Problembusy (no capacity; Retry-After; a check also carries job_id and status_url and continues asynchronously), not_ready, or org_moving (the organisation is being moved to another region; Retry-After)
defaultProbleminternal (the cause is logged, never returned)
202 response
{
  "status": "queued",
  "redelivery_of": "whd_01j9x3m8v7k2q4r5s6t7u8v9w0"
}

Usage

The month's usage and estimated charge (org:read).

GET /nib/usage #

The month's usage and estimated charge

org:read See org, members, usage

One UTC calendar month (period: current, the default, or YYYY-MM). checks and billable_checks count the checked items and the billable ones (a decision of AUTO_APPROVE, APPROVE, FLAG or FAILED); not_charged and decisions count the month's checked items by decision (errors: items without one). The estimate is $0.50 per billable check. For an organisation billed through its parent (billing.mode: parent) the parent is invoiced; the amount is still shown. daily lists every day of the month that has begun.

Parameters of GET /nib/usage
ParameterInType
period querystring
Responses of GET /nib/usage
StatusBodyMeaning
200UsageThe month's usage
400Probleminvalid_request, invalid_version, idempotency_key_required or (Stripe's webhook) invalid_signature
401Problemunauthorized
403Problempermission_denied
421Problemwrong_region (421 Misdirected Request)
429Problemrate_limited (the per-tenant request rate) or quota_exceeded (the monthly quota)
defaultProbleminternal (the cause is logged, never returned)
200 response
{
  "period_start": "2026-09-01T00:00:00Z",
  "period_end": "2026-10-01T00:00:00Z",
  "checks": 1284,
  "billable_checks": 1247,
  "not_charged": {
    "UNREADABLE": 21,
    "NO_SIGNATURE": 16,
    "errors": 3
  },
  "decisions": {
    "AUTO_APPROVE": 512,
    "APPROVE": 474,
    "FLAG": 212,
    "FAILED": 49,
    "UNREADABLE": 21,
    "NO_SIGNATURE": 16
  },
  "unit_price_usd": "0.50",
  "estimated_charge_usd": "623.50",
  "daily": [
    {
      "date": "2026-09-01",
      "checks": 41,
      "billable": 40
    }
  ],
  "billing": {
    "mode": "stripe",
    "status": "active"
  }
}

Platform API

Admin

API keys (org:keys:manage) and the audit log (org:audit:read).

GET /keys #

API keys (never a key or its hash)

org:keys:manage Create/revoke API keys (within own permissions)

Responses of GET /keys
StatusBodyMeaning
200The keys
401Problemunauthorized
403Problempermission_denied
421Problemwrong_region (421 Misdirected Request)
429Problemrate_limited (the per-tenant request rate) or quota_exceeded (the monthly quota)
defaultProbleminternal (the cause is logged, never returned)
POST /keys #

Issue an API key

org:keys:manage Create/revoke API keys (within own permissions)

The key is shown once (Cache-Control: no-store); only its display prefix, its id and the hash of its secret are stored. It carries the organisation's home region as a routing hint (not a secret: the secret part is). No Idempotency-Key: a retry issues another key. Audited.

RequestApiKeyCreate

Responses of POST /keys
StatusBodyMeaning
201ApiKeyCreatedCreated
400Probleminvalid_request, invalid_version, idempotency_key_required or (Stripe's webhook) invalid_signature
401Problemunauthorized
402Problembilling_required
403Problempermission_denied
413Problemtoo_large
421Problemwrong_region (421 Misdirected Request)
429Problemrate_limited (the per-tenant request rate) or quota_exceeded (the monthly quota)
defaultProbleminternal (the cause is logged, never returned)
Request example
{
  "name": "backend",
  "permissions": [
    "nib:checks:create",
    "nib:checks:read",
    "nib:subjects:read"
  ]
}
201 response
{
  "id": "key_01j9x3m8v7k2q4r5s6t7u8v9w0",
  "display_prefix": "unf_live_01AbCdEf",
  "name": "backend",
  "permissions": [
    "nib:checks:create",
    "nib:checks:read",
    "nib:subjects:read"
  ],
  "created_at": "2026-09-25T10:15:30.123Z",
  "last_used_at": null,
  "revoked_at": null,
  "key": "unf_live_01AbCdEf…EXAMPLE"
}
DELETE /keys/{key_id} #

Revoke an API key

org:keys:manage Create/revoke API keys (within own permissions)

204 also when already revoked. As for creation, the key's permissions must be within the caller's own (else 403 permission_denied naming the excess; an owner holds every permission). An API-key caller cannot revoke the last active key holding org:keys:manage (409). Takes effect on the next request. Audited.

Parameters of DELETE /keys/{key_id}
ParameterInType
key_idrequired pathstring
Responses of DELETE /keys/{key_id}
StatusBodyMeaning
204Done
401Problemunauthorized
403Problempermission_denied
404Problemnot_found
409Problemconflict, idempotency_conflict (the key was used with another body, or the job id with other inputs), idempotency_in_progress (the first request still runs) or idempotency_not_replayable (the key's response was a stream)
421Problemwrong_region (421 Misdirected Request)
429Problemrate_limited (the per-tenant request rate) or quota_exceeded (the monthly quota)
defaultProbleminternal (the cause is logged, never returned)
GET /audit #

The audit log, newest first

org:audit:read Audit log

Parameters of GET /audit
ParameterInType
cursor

The previous page's next_cursor.

querystring
limit queryinteger
action

An exact action, or a prefix ending in * (api_key.*).

querystring
Responses of GET /audit
StatusBodyMeaning
200AuditPageA page of entries
400Probleminvalid_request, invalid_version, idempotency_key_required or (Stripe's webhook) invalid_signature
401Problemunauthorized
403Problempermission_denied
421Problemwrong_region (421 Misdirected Request)
429Problemrate_limited (the per-tenant request rate) or quota_exceeded (the monthly quota)
defaultProbleminternal (the cause is logged, never returned)
200 response
{
  "entries": [
    {
      "id": 42,
      "actor": "api_key:key_01j9x3m8v7k2q4r5s6t7u8v9w0",
      "action": "policy.create",
      "target": "strict@2",
      "detail": {},
      "at": "2026-09-25T10:15:30.123Z"
    }
  ],
  "next_cursor": "42"
}

Operations

Health, readiness, versions and metrics (no API key).

GET /health #

Liveness

No key needed

Responses of GET /health
StatusBodyMeaning
200The process is serving requests
200 response
{
  "status": "ok"
}
GET /ready #

Readiness

No key needed

Responses of GET /ready
StatusBodyMeaning
200ReadyReady
503ProblemNot ready (problem not_ready)
200 response
{
  "status": "ready"
}
GET /version #

The model version and the latest API version

No key needed

Responses of GET /version
StatusBodyMeaning
200VersionThe versions
200 response
{
  "model": "nib-2.2",
  "api_version": "2026-09-27",
  "region": "uk-london"
}
GET /metrics #

Prometheus metrics (operators only)

No key needed

Exists only when the operator configured a metrics token (UNF_METRICS_TOKEN); else 404 exactly like an unknown route. The token (not an API key) is required as a bearer token (401 otherwise).

Responses of GET /metrics
StatusBodyMeaning
200Prometheus text exposition
401ProblemThe metrics token is missing or wrong
404ProblemNo metrics token is configured

Schemas

Id

An id; neither . nor ...

Problem

An RFC 9457 problem document (parent §7.5).

Fields of Problem
FieldTypeDescription
type*string

https://unforged.sh/problems/<code>

title*string
status*integer
detail*string
code*"invalid_request" | "unsupported_media" | "too_large" | "fetch_failed" | "rate_limited" | "quota_exceeded" | "idempotency_conflict" | "internal" | "not_found" | "unauthorized" | "permission_denied" | "timeout" | "not_available_in_dev" | "method_not_allowed" | "conflict" | "busy" | "not_ready" | "idempotency_in_progress" | "idempotency_key_required" | "idempotency_not_replayable" | "not_implemented" | "not_allowlisted" | "tenant_not_provisioned" | "billing_required" | "invalid_signature" | "billing_already_active" | "invalid_version" | "org_suspended" | "wrong_region" | "org_moving" | "learning_terms_required" | "webhook_test_failed"

tenant_not_provisioned (403): a dashboard token whose organisation has no tenant. billing_required (402): the tenant has no active billing. invalid_signature (400): a webhook whose signature does not verify. billing_already_active (409): the tenant already has a subscription; manage it in the billing portal. invalid_version (400): an Unforged-Version header that is malformed, unknown or past its sunset. org_suspended (403): the caller's organisation, or an organisation above it, is suspended (API keys and dashboard users alike). wrong_region (421): the caller's organisation lives in another region (see region and api_host). org_moving (503): Unforged is moving the organisation to another region; every request for it answers this, with Retry-After, until the move ends. learning_terms_required (409): PUT /nib/settings/learning refuses to turn learning on until the tenant accepts the current biometric-retention terms (see terms_version; POST /nib/settings/learning/terms). webhook_test_failed (400): POST /nib/webhooks sent the new endpoint a signed webhook.test event and got no 2xx within 10 s; nothing was created. The detail says why (the HTTP status, a timeout, or a failed connection).

request_idstring
regionstring

On wrong_region, the caller's home region.

api_hoststring

On wrong_region, that region's API host.

terms_versionstring

On learning_terms_required, the terms version the tenant must accept.

job_idstring

On busy / timeout of a check that continues asynchronously.

status_urlstring
hoststring

On a failed URL fetch (fetch_failed, not_allowlisted, too_large, timeout).

reasonstring

On a failed URL fetch: a reason code such as http_status, connect_failed, body_stalled (the body arrived slower than 64 KiB per 10 s), timeout or fetch_deadline (a 504: all the URL fetches of one request share a 60 s deadline).

checksobject

On /ready's not_ready.

errorsany[]

On a synchronous check whose job failed.

Decision

Quality

Fields of Quality
FieldTypeDescription
ok*boolean
effective_dpi*number | null
sharpness*number
contrast*number
issues*string[]

Signature

Fields of Signature
FieldTypeDescription
index*integer
page*integer
bbox*integer[]

[x, y, w, h] in the page's input pixels.

located_by*"template" | "detector" | "target" | "whole_image"
quality*Quality
match_probability*number | null
band*number[] | null
reference_count*integer
per_reference*object[]

In a fixed order that never depends on enrolment order: the subject's anchors by ref_id, then the item's references by manifest path, then learned references by ref_id. input_hashes.references follows the same order.

flags*string[]

Integrity and forensic flag codes, then soft flags. Soft flags cap the decision at FLAG and never fail a signature on their own: different_instrument (reason "Pen, pressure or writing speed differs from the references"), unsteady_strokes (reason "Stroke smoothness differs from the references (tremor or hesitation)") and slow_uniform_strokes (reason "Strokes are slower and more uniform than the references (possible tracing)").

decision*Decision
reasons*string[]

Plain-language reasons for the decision.

best_match*boolean

Versions

What produced a result: the Unforged Nib model version, your policy and the API version of the document's shape.

Fields of Versions
FieldTypeDescription
model*string

The model version, e.g. nib-2.2 (Unforged Nib v2.2).

policy*string | null

name@version; null when no item reached the policy.

api_version*string

Metadata

Your own key/value pairs, echoed back and never indexed: at most 20 pairs, keys 1–40 characters, string values of at most 500 characters.

CheckReference

Your own id for a check, e.g. an application number: 1–128 printable characters (no control characters). Not required to be unique.

Process

Your business process, e.g. loan-application: 1–64 characters of [a-z0-9._-]. Used for filtering and reporting.

ItemError

Fields of ItemError
FieldTypeDescription
type*string
detail*string

CheckResult

One item's result, identical in API responses (sync, GET, NDJSON, SSE) and nib/results/<job_id>/results.ndjson. Every field is always present (null when absent). Your references (subject_reference, reference, process, metadata) are echoed from the item. anchor_probability is the best match against the subject's enrolled references alone whenever they were loaded; null when the subject has none.

Fields of CheckResult
FieldTypeDescription
id*string

ver_<ulid>

item_index*integer
file*string | null
subject_reference*string | null

Your id for the person whose enrolled references were compared.

reference*CheckReference | null
process*Process | null
metadata*Metadata | null
status*"completed" | "failed"
error*ItemError | null
decision*Decision | null
input_type*string | null
pages*integer | null
notes*string[]

Document notes: stable codes, code or code:detail, pages numbered from 1. Among them: colour_budget_exceeded:<page>: stamp removal was skipped on that page, because its colour plane did not fit the document's colour budget (the page was read in grey, as a grey scan would be).

signatures*Signature[]
anchor_probability*number | null
versions*Versions
input_hashes*object | null
timings_ms*object

JobSummary

nib/results/<job_id>/summary.json: counts per decision and item errors. A rejected bucket drop has one job error (item_index: null) of type manifest_changed or quota_exceeded. A manifest whose reference, process or metadata breaks its limits fails with one error of type invalid_request naming the item (item_index, null for a top-level default) and the field, like the API's 400.

Fields of JobSummary
FieldTypeDescription
job_id*string
status*"completed" | "failed" | "rejected"
total*integer
completed*integer
failed*integer
counts*object
errors*object[]
metadata*any

The request's top-level metadata ({} when none).

versions*Versions
started_at*string | null
completed_at*string | null

ManifestItem

Needs subject_reference, references, or both.

Fields of ManifestItem
FieldTypeDescription
file*string

The questioned document, relative to nib/inbox/<job_id>/.

subject_referenceId
referencesstring[]

Extra reference files, relative to nib/inbox/<job_id>/.

template_idId
targetTarget
referenceCheckReference
processProcess
metadataMetadata

Manifest

The bucket-drop manifest, nib/inbox/<job_id>/manifest.json, written last. At most 10 000 items; invalid items fail individually, except that a reference, process or metadata out of its limits rejects the whole manifest (a failed summary.json naming the item and the field). No path segment may be .api-submission (reserved). Objects outside nib/ are not read.

Monthly quota. A bucket drop counts its item count against the monthly check-item quota exactly like POST /nib/checks. A new job that would exceed it is not registered (no job, nothing checked or billed): nib/results/<job_id>/summary.json is written create-only with status: rejected and one job error of type: quota_exceeded. Writing the manifest again (a new object version) retries once the quota allows it; the checked job's own summary then replaces the rejection.

Fields of Manifest
FieldTypeDescription
items*ManifestItem[]
policystring

The tenant's default when absent.

processProcess

The default process of the items that name none.

metadataMetadata

The default metadata of the items that carry none; echoed on the summary.

Target

{page, box} (a template box) or {page, bbox} ([x, y, w, h]); pages from 1.

Fields of Target
FieldTypeDescription
page*integer
boxstring
bboxinteger[]

CheckInput

A multipart part name, or a URL fetched from your allowlisted hosts.

CheckItem

Fields of CheckItem
FieldTypeDescription
file*CheckInput
subject_referenceId

Your id for the person; their enrolled references are compared.

referencesCheckInput[]
template_idId
targetTarget
referenceCheckReference
processProcess

Overrides the request's process.

metadataMetadata

Overrides the request's metadata.

CheckRequest

The check request. A reference, process or metadata out of its limits is 400 invalid_request naming the item (item_index) and the field.

Fields of CheckRequest
FieldTypeDescription
items*CheckItem[]
policystring
processProcess

The default process of the items that name none.

metadataMetadata

The default metadata of the items that carry none.

job_idId

CheckAccepted

Fields of CheckAccepted
FieldTypeDescription
job_id*string
status_url*string

Job

Fields of Job
FieldTypeDescription
job_id*string
status*"queued" | "running" | "completed" | "failed" | "rejected"
source*"api" | "bucket"
items*integer
created_at*string
started_at*string | null
completed_at*string | null
status_url*string
summary*JobSummary | null
results*CheckResult[]
next_cursor*string | null

Pass as cursor for the next page; null on the last.

SseQuality

Fields of SseQuality
FieldTypeDescription
ok*boolean
issues*string[]
cue*string | null

A capture cue (move_closer, hold_steady, reduce_glare, …).

SseLocated

Fields of SseLocated
FieldTypeDescription
boxes*object[]

LiveMessage

One text reply per binary frame, in order.

SessionCreate

Fields of SessionCreate
FieldTypeDescription
subject_reference*Id
scope*any[]
ttl_secondsinteger
max_checksinteger

Session

Fields of Session
FieldTypeDescription
id*string
token*string

unf_ses_…, shown once.

expires_at*string
max_checks*integer
subject_reference*string

ReferencesByUrl

Fields of ReferencesByUrl
FieldTypeDescription
references*object[]

ReferencesResult

Fields of ReferencesResult
FieldTypeDescription
subject_reference*string
references*object[]

Reference

Fields of Reference
FieldTypeDescription
ref_id*string
ref*string
source*"signature_card" | "confirmed_past" | "unverified" | "learned"
anchor*boolean

Enrolled by you (never displaced by learned references).

learned_from*string | null

<job_id>/<index> for a learned reference.

confirmed_by*string | null
content_hash*string | null
quality*object | null
versions*object

The model version the reference was enrolled with.

created_at*string
unlearned_at*string | null

JobListEntry

Fields of JobListEntry
FieldTypeDescription
job_id*string
source*"api" | "bucket"
status*"queued" | "running" | "completed" | "failed" | "rejected"
total*integer
counts*object

Items per decision.

created_at*string
completed_at*string | null

JobList

Fields of JobList
FieldTypeDescription
jobs*JobListEntry[]
next_cursor*string | null

CheckItemEntry

A checked item in lists (no metadata; read the item for it).

Fields of CheckItemEntry
FieldTypeDescription
job_id*string
index*integer
reference*CheckReference | null
process*Process | null
subject_reference*string | null
file*string | null
input_type*string | null
decision*Decision | null
match_probability*number | null

The best match's probability.

created_at*string

CheckItemList

Fields of CheckItemList
FieldTypeDescription
items*CheckItemEntry[]
next_cursor*string | null

ChecksSummary

Fields of ChecksSummary
FieldTypeDescription
total*integer
counts*object

Checked items per decision; ERROR counts the items without one.

processes*string[]

The distinct processes of the checks in the date range (at most 200).

SubjectList

Fields of SubjectList
FieldTypeDescription
subjects*object[]
next_cursor*string | null

Usage

Fields of Usage
FieldTypeDescription
period_start*string
period_end*string

The start of the next month (exclusive).

checks*integer
billable_checks*integer
not_charged*object
decisions*object

The month's checked items per decision (every decision is present).

unit_price_usd*string
estimated_charge_usd*string

billable_checks × unit_price_usd, two decimals.

daily*object[]
billing*object

Subject

Fields of Subject
FieldTypeDescription
subject_reference*string
created_at*string
references*Reference[]
checks*object

The subject's checks over time.

Confirmed

Fields of Confirmed
FieldTypeDescription
job_id*string
item_index*integer
confirmed_by*string
confirmed_at*string
learning*"queued" | "off" | "ineligible"

UnlearnRequest

Exactly one of since or check.

Unlearned

Fields of Unlearned
FieldTypeDescription
subject_reference*string
unlearned*string[]

QualityPolicy

Fields of QualityPolicy
FieldTypeDescription
min_effective_dpi*number
min_sharpness*number
min_contrast*number

PolicySpec

Fields of PolicySpec
FieldTypeDescription
auto_approve_enabledboolean
auto_threshold*number
approve_threshold*number
fail_threshold*number
qualityQualityPolicy | null

Policy

Fields of Policy
FieldTypeDescription
name*string
version*integer
id*string

name@version

auto_approve_enabled*boolean
auto_threshold*number
approve_threshold*number
fail_threshold*number
quality*QualityPolicy | null
created_by*string
created_at*string

TemplateBox

Fields of TemplateBox
FieldTypeDescription
name*Id
x*integer
y*integer
w*integer
h*integer

TemplateCreate

Fields of TemplateCreate
FieldTypeDescription
template_id*Id
namestring
boxes*TemplateBox[]

Template

Fields of Template
FieldTypeDescription
template_id*string
name*string
boxes*TemplateBox[]
blank_type*"png" | "pdf"
feature_hash*string | null
created_at*string

BucketKey

Fields of BucketKey
FieldTypeDescription
id*string | null

bkt_…; null for keys created outside the API.

access_key_id*string
status*"active" | "inactive"
created_at*string | null
reissue_requiredboolean

Suspended when the organisation moved to another region: the key no longer works and must be replaced with a new one.

BucketKeyList

Fields of BucketKeyList
FieldTypeDescription
iam_user*string
bucket_keys*BucketKey[]

BucketKeyCreated

Fields of BucketKeyCreated
FieldTypeDescription
id*string
access_key_id*string
secret_access_key*string

Shown once; never stored.

iam_user*string
created_at*string

HistoryRetention

"30m" (half an hour), an integer number of days from 1 to 3650, or "indefinite" (never purged). No other values.

Retention

Fields of Retention
FieldTypeDescription
history_retention*HistoryRetention
default*boolean

True while the retention was never set (90 days).

results_lifecycle_days*integer | null

The bucket's results/ lifecycle backstop: max(retention, 1 day) in whole days, null (no expiry) for indefinite. The purge itself is authoritative.

audit_log_retention_days*integer

The audit log's fixed retention (400 days; not selectable).

LearningSettings

Fields of LearningSettings
FieldTypeDescription
reference_learning*"off" | "confirmed_only" | "confirmed_and_auto"

off (the default); confirmed_only: signatures a reviewer confirmed; and confirmed_and_auto: also AUTO_APPROVE items.

learn_min_band_lo*number

The lower band bound a candidate must reach against the enrolled references alone (default 0.99).

max_learned*integer

The most learned references kept per subject (default 10).

LearningView

Fields of LearningView
FieldTypeDescription
reference_learning*"off" | "confirmed_only" | "confirmed_and_auto"
learn_min_band_lo*number
max_learned*integer
terms_accepted*TermsAcceptance | null

The tenant's most recent biometric-retention terms acceptance, or null.

TermsAcceptanceRequest

Fields of TermsAcceptanceRequest
FieldTypeDescription
terms_version*string

Must be the current version (nib_service::learning::LEARNING_TERMS_VERSION).

TermsAcceptance

Fields of TermsAcceptance
FieldTypeDescription
terms_version*string
accepted_by*string

The audit actor (user:<sub>; a dashboard user only).

accepted_at*string

EventType

WebhookCreate

Fields of WebhookCreate
FieldTypeDescription
url*string
events*EventType[]

Webhook

Fields of Webhook
FieldTypeDescription
id*string
url*string
events*EventType[]
created_at*string

WebhookCreated

Fields of WebhookCreated
FieldTypeDescription
id*string
url*string
events*EventType[]
created_at*string
secret*string

whsec_…, shown once. The HMAC key is this full string.

WebhookDelivery

Fields of WebhookDelivery
FieldTypeDescription
id*string
event_id*string
event_type*EventType
attempt*integer
status*"succeeded" | "failed"
response_status*integer | null
latency_ms*integer | null
error*string | null

http_status, timeout, connect_failed, request_failed, blocked_address, invalid_url, …

created_at*string
delivered_at*string | null
api_versionstring | null

The API version the payload was rendered in (your pin when it was sent).

WebhookDeliveryPage

Fields of WebhookDeliveryPage
FieldTypeDescription
deliveries*WebhookDelivery[]
next_cursor*string | null

Redelivery

Fields of Redelivery
FieldTypeDescription
status*string
redelivery_of*string

WebhookEvent

Fields of WebhookEvent
FieldTypeDescription
id*string

evt_…; the same on every retry and redelivery.

type*EventType | string

An event you subscribed to, or webhook.test (the connection test sent once when the endpoint is created).

created_at*string
data*object

The event's data (see docs/api/webhooks.md). job.completed carries job_id, status, total, counts, failed, versions, status_url, items (the first 100 results, each exactly as CheckResult) and items_truncated; a job of more than 100 items also has items_url, the paged GET of the rest. The body is rendered in your pinned API version, sent as Unforged-Version.

Me

Fields of Me
FieldTypeDescription
billing*Billing
sso_enabled*boolean
sso_requiredboolean

The organisation has an active SSO connection (people who sign in with SSO stay inside it).

api_version*object
tenant*object
caller*object

Billing

Fields of Billing
FieldTypeDescription
plan_type*"payg" | "payg_discounted" | "contract_unlimited" | "contract_commit" | "parent"

The plan in effect today (see GET /billing/plan).

plan_state*"in_date" | "in_grace" | "lapsed" | null | null

A contract's state today (a sub-organisation's is its parent's); null otherwise.

billed_throughstring | null

For an organisation billed through its parent (mode: parent), the name of the organisation that pays; null otherwise. Only in GET /me.

promo_creditobject | null

The sign-up credit (see GET /billing/plan; null without one), as of the organisation's cached row (a few seconds old at most; GET /billing/plan reads it afresh). Only in GET /me.

mode*"stripe" | "invoiced" | "parent"

stripe: metered self-serve billing; invoiced: billed outside Stripe; parent (a child organisation): billed through its parent organisation.

status*"pending" | "active" | "past_due" | "canceled"

pending until the first Checkout completes (checks run on the sign-up credit while any is left); past_due while Stripe retries a payment (checks still run); canceled after the subscription ends. Invoiced tenants are always active.

BillingUrl

Fields of BillingUrl
FieldTypeDescription
url*string

The Stripe page to send the user to.

TenantPlan

Fields of TenantPlan
FieldTypeDescription
quote_idstring

The accepted quote this plan comes from (history source quote).

plan*BillingPlanVersion
sso_enabledboolean
sso_requiredboolean

An override of the SSO ringfence flag (normally synced from WorkOS).

BillingPlanVersion

A plan version. Contracts need ends_on (after starts_on) and contract_value_usd (staff reporting only); payg_discounted needs unit_price_usd and its customer-specific stripe_price_id; contract_commit needs committed_amount_usd, overage_unit_price_usd and exactly one of included_checks or unit_price_usd; contract_unlimited needs meter_mode. Fields that do not belong to the type are refused. Prices and amounts are positive decimal strings.

Fields of BillingPlanVersion
FieldTypeDescription
idinteger
type*"payg" | "payg_discounted" | "contract_unlimited" | "contract_commit" | "parent"
starts_on*string
ends_onstring
grace_daysinteger
contract_value_usdstring

Reporting only (at most 2 decimal places).

external_refstring

A quote or invoice id.

unit_price_usdstring

USD per check (at most 4 decimal places).

overage_unit_price_usdstring

USD per check past the credit.

committed_amount_usdstring
included_checksinteger
meter_mode"included" | "none"

included: metered against the $0 included-checks price; none: recorded only.

fair_use_monthly_checksinteger

A notice past it; never blocks.

stripe_price_idstring

BillingPlanView

Fields of BillingPlanView
FieldTypeDescription
type*"payg" | "payg_discounted" | "contract_unlimited" | "contract_commit" | "parent"
state*"in_date" | "in_grace" | "lapsed" | null | null
starts_on*string
ends_on*string | null
grace_ends_on*string | null
external_ref*string | null
unit_price_usd*string | null
overage_unit_price_usd*string | null
included_checks*integer | null
credit_remaining*object | null

{checks} for an allowance, {usd} for a money credit.

fair_use_monthly_checks*integer | null
this_month*object
promo_credit*object | null

The $10.00 sign-up credit every new organisation gets, with no card needed, read afresh (null without one: a sub-organisation, a contract). Each check with a verdict draws $0.50 from it (unreadable and unsigned documents are free). While it is not converted and anything is left, checks run without a card; a batch costing more than is left is refused (402 billing_required, naming the cost and the balance). Once a card is added (converted), nothing more is drawn: the balance left then is applied to the first invoices when the plan is plain pay as you go (payg); on any other plan it is forfeited (remaining_usd 0.00).

BillingCredits

Fields of BillingCredits
FieldTypeDescription
items*object[]

One item per credit with anything left (money as US-dollar decimal strings).

MyOrgs

Fields of MyOrgs
FieldTypeDescription
orgs*object[]

ChildOrg

Fields of ChildOrg
FieldTypeDescription
org_id*string | null
tenant_id*string
name*string
status*"active" | "suspended" | "deregistered"
billing_mode*"stripe" | "invoiced" | "parent"
sso_required*boolean
region*string

The child's home region.

created_at*string

TeamMembers

Fields of TeamMembers
FieldTypeDescription
members*object[]

TeamRoles

Fields of TeamRoles
FieldTypeDescription
roles*object[]
custom_source*"workos" | "members"

AdminPortalRequest

Fields of AdminPortalRequest
FieldTypeDescription
intent*"sso" | "dsync"

ChildOrgList

Fields of ChildOrgList
FieldTypeDescription
children*ChildOrg[]

ChildOrgCreate

Fields of ChildOrgCreate
FieldTypeDescription
name*string
regionstring

The child's home region; default the parent's. A live region served here.

ChildOrgCreated

Fields of ChildOrgCreated
FieldTypeDescription
org_id*string
tenant_id*string
name*string
region*string

ChildOrgStatus

Fields of ChildOrgStatus
FieldTypeDescription
status*"active" | "suspended"

TenantPlanned

Fields of TenantPlanned
FieldTypeDescription
tenant_id*string
plan*BillingPlanVersion
effective*BillingPlanVersion
history_id*integer
billing*object
sso_enabled*boolean
sso_required*boolean

StripeEvent

A Stripe event object (only the members Unforged reads are listed).

Fields of StripeEvent
FieldTypeDescription
id*string
type*string
created*integer

Unix seconds.

data*object

TenantProvision

Fields of TenantProvision
FieldTypeDescription
org_id*string
name*string
region*string

The home region (a live region of config/regions.json, served by this host).

emailstring

The signing-up user's email, for the sign-up credit's Stripe customer.

TenantProvisioned

Fields of TenantProvisioned
FieldTypeDescription
tenant_id*string
slug*string
region*string

The tenant's home region.

created*boolean

ContactRequest

Fields of ContactRequest
FieldTypeDescription
name*string
email*string

local@domain

companystring | null
topic*"volume" | "government" | "dedicated" | "sso" | "other"
message*string
websitestring | null

A honeypot; leave empty.

Permission

A permission of the catalogue (docs/api/permissions.md).

ApiKeyCreate

Fields of ApiKeyCreate
FieldTypeDescription
name*string
permissions*Permission[]

The key's permissions: within the caller's own (else 403 permission_denied); an unknown name is 400 invalid_request. The presets are checks and nib-admin (docs/api/permissions.md).

ApiKey

Fields of ApiKey
FieldTypeDescription
id*string
display_prefix*string

unf_<env>_ and the key's first 8 characters, to recognise it (keys of one region share their first few characters); never used to look a key up.

name*string
permissions*Permission[]
created_at*string
last_used_at*string | null
revoked_at*string | null

ApiKeyCreated

Fields of ApiKeyCreated
FieldTypeDescription
id*string
display_prefix*string

unf_<env>_ and the key's first 8 characters, to recognise it (keys of one region share their first few characters); never used to look a key up.

name*string
permissions*Permission[]
created_at*string
last_used_at*string | null
revoked_at*string | null
key*string

The key, shown once.

AuditPage

Fields of AuditPage
FieldTypeDescription
entries*object[]
next_cursor*string | null

Ready

ready, or degraded while the service runs with reduced capability (checks still complete). Which component is affected is never shown.

Fields of Ready
FieldTypeDescription
status*"ready" | "degraded"

Version

Fields of Version
FieldTypeDescription
model*string

The current model version (Unforged Nib v2.2 is nib-2.2).

api_version*string

The latest API version.

region*string

The region this host serves (uk-london, …).

ApiVersionState

The organisation's API version pin. previous and rollback_until are set for 72 hours after a change, while a rollback is possible.

Fields of ApiVersionState
FieldTypeDescription
pinned*string

The pinned version (set when the organisation was created).

latest*string
previous*string | null
changed_at*string | null
rollback_until*string | null
versions*object[]

ApiVersionUpgrade

Fields of ApiVersionUpgrade
FieldTypeDescription
version*string

A published version newer than the pin and not past its sunset.