Authentication
idcheck.now uses two credential types: scoped API keys for your backend, and short-lived client tokens for the end-user's browser. Webhook deliveries are signed with HMAC-SHA256 so you can verify they came from us.
API keys
API keys authenticate server-to-server calls. Send the key in the X-Api-Key header:
curl https://idcheck.now/v1/verifications -H "X-Api-Key: idc_9fK2mVxQ8pLrT3nWsYbZcJhGdAeRuEiO7qN5sX0vM1k"Keys are generated with the prefix idc_ followed by 32 bytes of URL-safe randomness.
Only the SHA-256 hash is stored server-side — the plaintext key is shown exactly once, at
creation time, and can never be retrieved again. A missing, unknown or revoked key returns
401 invalid API key; failed attempts are audit-logged.
Scopes
Each key carries a list of scopes. A request whose key lacks the required scope returns
403 missing scope: <scope>. The wildcard scope * grants everything.
| Scope | Grants |
|---|---|
verifications:write | POST /v1/verifications |
verifications:read | GET /v1/verifications, GET /v1/verifications/{id}, GET /v1/verifications/{id}/media |
screen:ofac | POST /v1/screen/ofac, POST /v1/screen/wallet, GET /v1/screen/ofac/stats |
keys:write | POST /v1/keys, POST /v1/keys/{id}/revoke |
keys:read | GET /v1/keys |
webhooks:write | POST /v1/webhooks, DELETE /v1/webhooks/{id} |
webhooks:read | GET /v1/webhooks |
* | All of the above. |
Give your capture service only verifications:write, your dashboard backend only verifications:read, and keep keys:write on a single bootstrap key stored in a secrets manager. Session endpoints (/v1/session/*), the free tier (/v1/free/*) and signal collection (/v1/signals/collect) do not use API keys at all.
Creating keys
Create keys in the dashboard, or programmatically. To create a key, POST /v1/keys
with a label and a scopes array (requires keys:write):
curl -X POST https://idcheck.now/v1/keys -H "X-Api-Key: $BOOTSTRAP_KEY" -H "Content-Type: application/json" -d '{"label": "staging", "scopes": ["verifications:write", "verifications:read"]}'const res = await fetch("https://idcheck.now/v1/keys", {
method: "POST",
headers: { "X-Api-Key": process.env.BOOTSTRAP_KEY, "Content-Type": "application/json" },
body: JSON.stringify({ label: "staging", scopes: ["verifications:write", "verifications:read"] }),
});
const { api_key } = await res.json(); // shown once — store it nowimport os, requests
res = requests.post(
"https://idcheck.now/v1/keys",
headers={"X-Api-Key": os.environ["BOOTSTRAP_KEY"]},
json={"label": "staging", "scopes": ["verifications:write", "verifications:read"]},
)
api_key = res.json()["api_key"] # shown once — store it now{
"id": 12,
"label": "staging",
"scopes": ["verifications:write", "verifications:read"],
"api_key": "idc_9fK2mVxQ8pLrT3nWsYbZcJhGdAeRuEiO7qN5sX0vM1k"
}List keys with GET /v1/keys (requires keys:read) and revoke with
POST /v1/keys/{key_id}/revoke (requires keys:write). Neither ever returns
key material:
[
{
"id": 12,
"label": "staging",
"scopes": ["verifications:write", "verifications:read"],
"created_at": "2026-07-26T09:12:44.012331",
"revoked_at": null,
"last_used_at": "2026-07-26T14:22:03.554201"
}
]Revoke returns the same public object with revoked_at set; revoked keys immediately
fail authentication. Revoking an already-revoked key is idempotent.
Client tokens
A client token is an opaque, single-verification credential that powers the hosted capture flow.
It is created for you by POST /v1/verifications and returned as
client_token (also embedded in the hosted url).
- TTL: 30 minutes (1800 seconds). After expiry the user must be given a fresh verification.
- Scope: exactly one verification — a token can never see another user's data.
- Headers: send as
X-Client-Token, or asAuthorization: Bearer <token>.
curl https://idcheck.now/v1/session/state -H "X-Client-Token: x7Qp9m2LwR4tY8uZcVbN1sDfGhJkL0oP3eA5qW6rT2y"Invalid or expired tokens return 401 invalid or expired client token. Never put an
API key in browser code — the client token is the only credential the browser ever needs.
Webhook signatures
Every webhook delivery carries an X-IdCheck-Signature header:
X-IdCheck-Signature: t=1753104000,v1=5b7c9e1f3a8d2c4b6e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2dThe value is t=<unix_timestamp>,v1=<hmac_hex>, where
hmac_hex is HMAC-SHA256 over the string "<t>.<raw_request_body>"
using your endpoint's secret (returned once at webhook creation) as the key.
Verify with a constant-time comparison, and reject timestamps that are too old to defend against
replay:
const crypto = require("crypto");
function verifyIdCheckSignature(rawBody, header, secret, toleranceSec = 300) {
const parts = Object.fromEntries(header.split(",").map(p => p.split("=")));
const ts = Number(parts.t);
if (!ts || Math.abs(Date.now() / 1000 - ts) > toleranceSec) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${ts}.` + rawBody) // raw body string/Buffer — do NOT re-serialize parsed JSON
.digest("hex");
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1 || ""));
}import hashlib, hmac, time
def verify_idcheck_signature(raw_body: bytes, header: str, secret: str,
tolerance_sec: int = 300) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
ts = int(parts.get("t", "0"))
if abs(time.time() - ts) > tolerance_sec:
return False
expected = hmac.new(
secret.encode(), str(ts).encode() + b"." + raw_body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))HMAC input is the exact bytes received. Parsing the JSON and re-serializing it (different key order, whitespace or unicode escaping) will produce a different digest and every signature will fail.