Ask before you render.
One call answers whether a person's face or voice is licensed for what you are about to generate — for this company, this purpose, this platform, this territory, right now. The answer is derived from the signed license itself, so it changes the moment consent does.
API keys
Sign in with your wallet to register an integration and mint a key. A key is shown once and stored only as a hash — we cannot show it to you again, and neither can anyone else.
The shape of an integration
Two calls, at two different moments. Everything else is detail.
- Before you generate — POST /api/v1/authorize with the Human ID, the likeness you need, and what you intend to do with it.
- If allowed is false, stop. The reason says whether this is fixable — a platform outside the grant is — or final.
- If allowed is true, keep the licenseId. It is the document your permission stands on.
- After you publish — POST /api/v1/usage with that licenseId and what you made. That is the record the person whose face it is gets to read.
An integration that only ever calls /authorize leaves a permission trail with no usage in it. The person learns that somebody asked, not that anything was made.
Authentication
Every /api/v1 request carries a key. Send it as a bearer token, or in X-API-Key where a custom header is easier to set.
Authorization: Bearer hid_sk_8f3a92… # or, where a custom header is easier to set: X-API-Key: hid_sk_8f3a92…
A key is shown once, at the moment it is minted, and stored only as a SHA-256 digest. No endpoint can read one back: a lost key is replaced, not recovered.
Keys carry scopes — authorize, read and usage. An integration that only checks permissions should not be able to file usage against somebody's license, and a scoped key cannot.
Rate limits
Per key, per minute, in a fixed window. Every response carries the remaining budget, not only the ones that refuse you: a client that first hears about a limit when it hits it has no way to slow down before it does.
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 57
X-RateLimit-Reset: 1788000060
# and, on a refusal:
HTTP/1.1 429
Retry-After: 12
{ "error": { "code": "RATE_LIMITED", "message": "…" } }Refused calls are recorded and then left out of the count, so retrying against a closed door cannot extend your own lockout. The window is counted from the request log itself, which makes the limit and the audit trail the same fact seen twice.
New keys start at 60 requests per minute.
/api/v1/authorize
scope: authorizeThe question this platform exists to make answerable. Every field in the body is a claim about what you intend to do, and none of them can widen a permission: sending aiGeneration false does not switch AI generation on, it says you are not doing it, and the license still decides.
platform and territory are optional, and an omitted one is skipped rather than failed — a caller who did not name a platform did not ask about one. Sending them is what makes the answer specific. A refusal about the license is a 200 with allowed false; a 4xx means the request could not be understood, and an unknown Human ID is a 404 rather than a quiet no.
curl -X POST https://your-host/api/v1/authorize \
-H "Authorization: Bearer hid_sk_…" \
-H "Content-Type: application/json" \
-d '{
"humanId": "HID-A82F91",
"assetType": "face",
"company": "Nike",
"purpose": "commercial_advertising",
"platform": "instagram",
"territory": "KZ",
"aiGeneration": true,
"modelTraining": false
}'
{
"allowed": true,
"licenseId": "HID-2026-8F3A92",
"expiresAt": "2026-11-19T00:00:00.000Z",
"reason": "VALID_LICENSE",
"detail": "",
"humanId": "HID-A82F91",
"assetType": "face",
"checkedAt": "2026-08-28T09:41:02.118Z",
"verifyUrl": "https://your-host/verify/HID-2026-8F3A92"
}purpose is a closed list. An unrecognised value is refused rather than guessed at, because the only guess available is a permissive one — made silently, on the exact question the license exists to settle.
commercial_advertising brand_endorsement entertainment editorial education research internal_testing other
/api/v1/license/:id
scope: readThe document behind an answer: what it grants, where, until when, and the two hashes that let you check it has not moved since you last read it. expiresAt is the term actually in force after any renewal; signedExpiration is the date on the original signed document, which never changes.
curl https://your-host/api/v1/license/HID-2026-8F3A92 \
-H "Authorization: Bearer hid_sk_…"
{
"license": {
"licenseId": "HID-2026-8F3A92",
"status": "ACTIVE",
"asset": { "face": true, "voice": false },
"permissions": { "aiGeneration": true,
"modelTraining": false,
"commercialAdvertising": true,
"sublicensing": false },
"platforms": ["Instagram", "TikTok"],
"territories": "Kazakhstan, CIS",
"startDate": "2026-08-21T00:00:00.000Z",
"expiresAt": "2026-11-19T00:00:00.000Z",
"signedExpiration": "2026-11-19T00:00:00.000Z",
"version": 1,
"revokedAt": null,
"pausedAt": null,
"documentHash": "<64 hex>",
"scopeHash": "<64 hex>",
"anchored": { "txSignature": "…", "network": "devnet" },
"verifyUrl": "https://your-host/verify/HID-2026-8F3A92"
},
"inForce": true
}/api/v1/human/:humanId/status
scope: readTwo things /authorize deliberately does not tell you. Whether this person has actually been checked — a Human ID is free to claim and a verified one is not. And their standing position on AI use of their likeness, which is what they say to somebody who has not asked yet.
That standing position never overrides a license. A signed document is an instrument and a passport stance is a disposition; letting the second void the first would mean a forgotten toggle could cancel a contract. Read it as context before you negotiate, not as permission.
curl https://your-host/api/v1/human/HID-A82F91/status \
-H "Authorization: Bearer hid_sk_…"
{
"humanId": "HID-A82F91",
"rightsStatus": "LICENSABLE",
"verified": true,
"checks": {
"identity": { "status": "VERIFIED", "demo": false },
"face": { "status": "VERIFIED", "demo": false },
"voice": { "status": "UNVERIFIED", "demo": false }
},
"permissions": {
"commercial": "ASK_FIRST",
"ai_training": "PROHIBITED",
"advertising": "ALLOWED",
"entertainment": "ASK_FIRST"
},
"licenses": { "active": 2, "paused": 0, "expired": 1, "revoked": 0 },
"registeredAt": "2026-08-21T11:02:44.000Z",
"profileUrl": "https://your-host/human/HID-A82F91"
}/api/v1/usage
scope: usageThe other half of asking. File one receipt per piece of content you generated, pointing at the licenseId /authorize handed you. The verdict is derived from the license rather than from your body — there is no field here that can make a receipt say it was authorized.
The license has to name either your registered developer name or the company in the request as its agency or client. That is attribution rather than proof: a platform files on behalf of its customers, so what closes the gap is that every receipt permanently records which developer filed it.
curl -X POST https://your-host/api/v1/usage \
-H "Authorization: Bearer hid_sk_…" \
-H "Content-Type: application/json" \
-d '{
"licenseId": "HID-2026-8F3A92",
"company": "Nike",
"contentTitle": "Summer campaign — 15s cutdown",
"contentType": "VIDEO",
"assetType": "FACE",
"purpose": "commercial_advertising",
"platform": "Instagram",
"territory": "Kazakhstan",
"contentUrl": "https://example.com/ad/8f3a92",
"contentHash": "<sha256 of the file>",
"aiModel": "internal-diffusion-3",
"generatedAt": "2026-09-02T10:14:00.000Z",
"publishedAt": "2026-09-03T08:00:00.000Z"
}'
{
"receiptId": "RCP-8F3A92C1",
"status": "AUTHORIZED",
"receiptHash": "<64 hex>",
"licenseId": "HID-2026-8F3A92",
"humanId": "HID-A82F91",
"url": "https://your-host/receipt/RCP-8F3A92C1"
}Reasons
Ten, and only ten. Switch on the reason, never on the sentence beside it.
- VALID_LICENSE
- A license covers this use, and it is in force today.
- NO_LICENSE
- No license names this company for this likeness — or the one that does is not signed by both parties, or its term has not started yet.
- LICENSE_EXPIRED
- The term has ended. What follows an expiry is a new license, not an extension.
- LICENSE_REVOKED
- Consent was withdrawn. Terminal — nothing walks this back.
- LICENSE_PAUSED
- Consent is suspended. Reversible, and not your call to make.
- PLATFORM_NOT_ALLOWED
- The license names platforms, and this is not one of them.
- TERRITORY_NOT_ALLOWED
- The license names territories, and this is not one of them.
- COMMERCIAL_USE_NOT_ALLOWED
- The license does not permit commercial advertising.
- AI_GENERATION_NOT_ALLOWED
- The license does not permit AI-generated content.
- MODEL_TRAINING_NOT_ALLOWED
- The license does not permit training a model on this likeness.
When several licenses could apply, one that permits the use wins outright. Otherwise you get the refusal from the license that came closest, rather than whichever row the database returned first.
Errors
One envelope, one code per failure. The message is for the human reading your log; the code is what your program branches on.
400 INVALID_REQUEST the body did not parse, or a field is wrong
401 UNAUTHORIZED no key, or a key that is revoked, expired or unknown
403 FORBIDDEN the key lacks the scope this endpoint needs
403 SUSPENDED the developer account has been suspended
404 UNKNOWN_HUMAN_ID no such Human ID
404 UNKNOWN_LICENSE no such license
429 RATE_LIMITED too many calls this minute
500 SERVER_ERROR our fault; the message never carries internals
{ "error": { "code": "INVALID_REQUEST",
"message": "purpose must be one of …",
"fields": [ { "field": "purpose", "message": "…" } ] } }What the API never returns
Nothing personal, on any endpoint, for any key. This is a machine surface a key holder can call in a loop, which makes it a different thing from the public certificate at /verify — that page does show the parties, to somebody handed one license identifier who opened it deliberately.
- No names — not the person's, not the agency's, not the client's.
- No wallet addresses, no email, no country, no photo.
- No fee, no campaign name, no restrictions text.
- No biometric data of any kind, and no reference face or voice hashes.
The request log keeps the route pattern rather than the URL, so no identifier reaches it through a query string, and the caller's address only as a salted digest. Entries are kept for thirty days.