Integrating with SoluFace
For platforms, apps and AI services that publish, share or generate images of people.
1. What SoluFace does for your platform
People who choose to enroll their face with SoluFace set their own rules: who may show them, in what kind of content, for what purpose. Before your platform publishes, shares or generates an image of a person, you ask SoluFace, and SoluFace answers with the person's decision — not with who they are.
- Consent at the moment of publication. You send the image (or the reference image of a generation) with what you intend to do with it. SoluFace compares the faces with the faces of enrolled people only, on our own servers, and applies their current rules. Faces of people who did not enroll are processed for that one call and discarded.
- The person decides. The answer is the strictest rule of everyone matched:
allow,deny, or — for device apps — blur the face or ask the person. A person can change a rule at any time; the next check follows it at once, and if you already hold anallow, we tell you that it no longer stands (§3.4). - Receipts. Every answer carries a signed receipt: a small, non-secret token that proves what SoluFace decided, for which bytes, at what time. Anyone can verify it with our public keys — your auditors, a court, the person — without asking us. A receipt never names a person and never holds a face.
What SoluFace does not do: it does not tell you who is in an image, how many people matched, or how sure the match is; it does not detect whether media is synthetic or manipulated (you state the representation, we take it as stated); and it does not store your media.
2. Onboarding
2.1 Your partner tenant and credentials
- Write to
support@soluface.aiwith your organization name, the flows you need (§3) and a technical contact. - We create a partner Organization for you, switch on the partner flag for it, and create one service user in it. You receive the service user's sign-in name and an initial password through a separate channel.
- Your back end signs in as that service user and calls the API with the access token:
export SF_API=https://staging-api.soluface.ai
TOKENS=$(curl -sS -X POST "$SF_API/v1/auth/login" \
-H 'Content-Type: application/json' \
-d "{\"identifier\":\"$SF_PARTNER_USER\",\"password\":\"$SF_PARTNER_PASSWORD\"}")
export SF_TOKEN=$(echo "$TOKENS" | jq -r .access_token)
SF_REFRESH=$(echo "$TOKENS" | jq -r .refresh_token)
Response 200:
{ "access_token": "eyJhbGciOiJFZERTQSIs…", "expires_in": 900, "refresh_token": "eyJhbGciOiJFZERTQSIs…" }
- The access token lives 15 minutes (
expires_in, in seconds), the refresh token 30 days. Rotate withPOST /v1/auth/refresh{"refresh_token": "…"}→ a new pair, same shape. A refresh token issued before a password change is refused (401); sign in again. - The token carries
tenant= your partner Organization. That is how every call is attributed to you. - Keep the password and the tokens in your secret store. Never put them in a mobile app, a web page or a log.
- A client-credentials flow (no password) is planned; until then the service user is the credential. Ask us to rotate the password whenever someone who knew it leaves.
2.2 Environments
| Environment | Base URL | Use |
|---|---|---|
| staging | https://staging-api.soluface.ai |
integration and testing — your partner tenant is created here first |
| demo | https://demo-api.soluface.ai |
our stable showcase; a demo tenant on request once your staging integration works |
There is no production partner environment yet (see §4.5). Data on staging and demo is test data and is swept after at most 90 days.
2.3 Test persona and test accounts
You never test with photos of real people. With your tenant you receive a test pack: synthetic face images (generated people, not real persons) whose test accounts are enrolled on staging with fixed rules, and the answer each one must give:
| Image | The enrolled test person's rule | representation-check answers |
|---|---|---|
allow |
synthetic: allow | allow (non-commercial) |
deny |
synthetic: deny | deny |
ask |
synthetic: ask | deny (ask is answered as deny, §3.1) |
allow_deny |
two people, one allows, one denies | deny (strictest wins) |
intimate_ok |
synthetic: allow, commercial: allow | allow for commercial; still deny with content_class=intimate |
stranger |
never enrolled | no_match |
For the device guard (§3.2) you need two test user accounts of your own on staging (a sender and a person who appears in the photo); we enable them on request.
2.4 Rate limits
| Call | Limit | Over the limit |
|---|---|---|
POST /v1/guard/representation-check |
per partner, 60 per minute by default; we set your own value without a deploy | 429 rate_limited + Retry-After (seconds) |
GET /v1/partner/receipts/withdrawn |
counted with the representation check | 429 + Retry-After |
POST /v1/receipts/verify |
60 per minute per IP address, body at most 8 KB | 429 + Retry-After; 413 |
POST /v1/feedback/reports |
per install and account 10 / hour and 30 / day | 429 + Retry-After |
Back off on 429 for exactly Retry-After seconds. Never retry in a tight loop; never treat a 429 as allow.
2.5 Support and "report a problem"
- Every call may carry your own
X-Request-Id(8–64 characters[A-Za-z0-9-]); every answer, errors included, echoes the request id in theX-Request-Idheader. Log it — it is the one thing we need to find a call. It carries no personal data. - Server integrations: write to
support@soluface.aiwith the request id, the time (UTC), the path and the status code. Never send us the image, the token or a person's data. - Apps built on the SDK: every error screen offers Copy and Report. The SDK's
FeedbackReportersendsPOST /v1/feedback/reportswith facts only — error code, status, request id, path template, app and device model — never an image, token, e-mail address or request body, and answers with a referenceFB-…you can quote to us.
3. Flows
All examples run against staging with the variables of §2.1. Media is sent as multipart/form-data; the whole
request body is at most 16 MiB (413 payload_too_large). Every answer is the decision and a receipt — never
an identity, a template, an embedding, a score, a box or a count of people.
3.1 Representation check — before you generate or distribute
For AI platforms and any service that generates an image of a person from a reference, or distributes output that may show a person. Your partner token is required.
| Field | Values | Meaning |
|---|---|---|
media |
1 to 8 image parts named media |
stage=generate: exactly one reference image. stage=distribute: the output frame(s) |
representation |
synthetic · biometric |
how the person is shown — synthetic = generated / altered likeness; biometric = the person's real face. As you state it; we do not verify it. voice is refused (422 unsupported) |
purpose |
commercial · non_commercial |
people may allow one and deny the other |
stage |
generate · distribute |
before generation, or before distribution |
content_class |
intimate · other (default) |
optional — your own label; intimate synthetic content of a person is always deny |
curl -sS -X POST "$SF_API/v1/guard/representation-check" \
-H "Authorization: Bearer $SF_TOKEN" -H "X-Request-Id: $(uuidgen)" \
-F media=@allow.jpg -F representation=synthetic -F purpose=non_commercial -F stage=generate
Response 200 (a real staging answer, receipt cut):
{
"decision": "allow",
"signed_receipt": "eyJhbGciOiJFZERTQSIsImtp…",
"valid_until": "2026-10-29T02:17:02.304817723Z"
}
decision |
What you do |
|---|---|
allow |
every matched person allows it — proceed, and keep the receipt with the content |
deny |
at least one matched person does not allow it — do not generate / distribute |
ask |
reserved: today answered as deny; if you ever receive it, handle it as not allowed |
no_match |
nobody who enrolled with SoluFace was matched. no_match is not permission. It says only that no enrolled person objected; your own rules, the law and other rights still apply |
- Validity.
valid_untilis 15 minutes after the check, an RFC 3339 UTC time that may carry fractional seconds (up to 9 digits) — parse it as RFC 3339, do not match a fixed format. Generate or distribute within it; after it, check again (people can change their rules). - Strictest wins. Several people matched →
denybeatsaskbeatsallow. - Bind the receipt to the bytes. The receipt names the SHA-256 of every frame you sent (
h, in request order). Distribute exactly those bytes, or check the new bytes again. - Two checks for generation.
stage=generatewith the reference image before you generate; thenstage=distributewith the output before you publish it.
Decoded receipt (the JWS payload):
{
"iss": "http://consent",
"v": 1,
"payload": {
"action": "representation.check",
"at": "2026-10-29T02:02:02.301Z",
"decision": "allow",
"h": ["1a766695…aeeb8"],
"partner": "<your partner Organization id>",
"purpose": "non_commercial",
"representation": "synthetic",
"rid": "f4509588…",
"stage": "generate"
},
"salt": "",
"iat": 1793239322,
"rid": "f4509588…"
}
iss names the SoluFace service that signed the receipt. It is a name, not an address: do not fetch it and do
not pin its value — the signing keys come from GET /v1/receipts/keys (§3.3), matched by the header's kid.
h lists the SHA-256 (hex) of every presented frame, in request order. An allow receipt carries a receipt id
rid, in the payload and as a top-level claim: the id a withdrawal notice (§3.4) refers to.
3.2 Guard decide — apps that embed the SDK
For apps where a person shares a photo (messenger, social app, camera roll export). The sender's device asks
before the photo leaves the app. This call is made with the sender's own SoluFace access token, not with your
partner token: the sender must have a SoluFace account and have opted in (412 not_opted_in otherwise —
nothing is processed).
curl -sS -X POST "$SF_API/v1/guard/decide" \
-H "Authorization: Bearer $SENDER_TOKEN" -H "X-Request-Id: $(uuidgen)" \
-F image=@photo.jpg
The examples below are real staging answers (receipts cut, ids cut or replaced by a placeholder).
Nobody enrolled is matched (here: a photo without a face) — response 200:
{
"action": "allow",
"signed_receipt": "eyJhbGciOiJFZERTQSIsImtp…",
"system_version": "sm1-a9f947c1038f6414"
}
An enrolled person is matched whose rule is "ask me" — response 200:
{
"action": "request",
"expires_at": "2026-10-29T02:16:58.409799522Z",
"request_id": "a121c296…",
"signed_receipt": "eyJhbGciOiJFZERTQSIsImtp…",
"system_version": "sm1-a9f947c1038f6414"
}
Poll the request until it is answered:
curl -sS "$SF_API/v1/guard/requests/$REQUEST_ID" -H "Authorization: Bearer $SENDER_TOKEN"
{ "expires_at": "2026-10-29T02:16:58.409799Z", "status": "pending" }
…and, once the person approved in their app:
{ "expires_at": "2026-10-29T02:16:58.409799Z", "status": "approved" }
(or "status": "denied" when the person declines, "expired" when nobody answered in time). On approved,
decide again with the same image and the request receipt:
curl -sS -X POST "$SF_API/v1/guard/decide" \
-H "Authorization: Bearer $SENDER_TOKEN" -H "X-Request-Id: $(uuidgen)" \
-F image=@photo.jpg -F receipt="$REQUEST_RECEIPT"
The answer is a fresh allow with a new receipt — keep this one with the shared photo:
{
"action": "allow",
"signed_receipt": "eyJhbGciOiJFZERTQSIsImtp…",
"system_version": "sm1-a9f947c1038f6414"
}
action |
What the app does |
|---|---|
allow |
nobody matched, or everyone matched allows it — share as is |
transform |
blur the matched faces before the photo leaves the app (the SDK does it) |
request |
the matched people are asked. Poll GET /v1/guard/requests/{request_id} → pending · approved · denied · expired. On approved, call guard/decide again with the same image and the field receipt = the request's signed_receipt: the answer is a fresh allow with a new receipt. Anything else = do not share the original |
system_versionnames the version of SoluFace's matching system that made the decision (sm1-+ 16 hex characters). Log it with the receipt; it is not personal data.expires_atis an RFC 3339 UTC time with fractional seconds (up to 9 digits in thedecideanswer, 6 in the poll) — parse it as RFC 3339, do not match a fixed format. An unanswered request lapses at that time (expired= do not share).- The poll answers exactly
statusandexpires_at— the aggregate state only, never who was asked or who answered. - In this flow the sender's own face and faces of people who did not enroll never count: nobody matched is
allowhere (unlike the representation check, where it isno_match). - A different image, or a bad or expired receipt, is decided as a fresh share; it never turns into an
allow.
Decoded receipts (the JWS payloads). The allow for a photo in which nobody enrolled was matched is
unchained — nothing about a person is recorded for it; h is the SHA-256 of the image bytes:
{
"iss": "http://consent",
"v": 1,
"payload": {
"action": "allow",
"h": "36390e30…ecf6d31",
"sender": "<the sender's user id>"
},
"salt": "",
"iat": 1793239316
}
A request (and a transform) is a chained guard.decided entry of our audit chain. The matched people
appear only blinded; the payload holds the action, the request id, the sender, the image hash and the matching
model and threshold:
{
"iss": "http://consent",
"v": 1,
"org": "<organization id>",
"seq": 1689,
"at": "2026-10-29T02:01:58.419Z",
"source": "consent",
"type": "guard.decided",
"sourceId": "<entry id>",
"prev": "<sha-256>",
"hash": "<sha-256>",
"payload": {
"action": "request",
"h": "a1b9888c…122bb",
"model": { "name": "insightface", "version": "buffalo_l" },
"requestId": "a121c296…",
"sender": "<the sender's user id>",
"subjects": { "blinded": "<sha-256>" },
"systemVersion": "sm1-a9f947c1038f6414",
"threshold": 0.35
},
"salt": "<32 hex>",
"iat": 1793239318
}
The allow after an approval is chained too: the same claims with its own seq, sourceId, prev and hash,
"action": "allow", the same h, and no requestId.
3.3 Receipts — verify offline or online
A receipt is a JWS compact string (alg EdDSA, typ sf-receipt+jwt, a kid), under 2 KB, not secret.
Keep it with the content it is about.
Online (public, no token):
curl -sS -X POST "$SF_API/v1/receipts/verify" -H 'Content-Type: application/json' \
-d "{\"receipt\":\"$RECEIPT\"}"
Response 200 (a representation-check receipt):
{ "valid": true, "signature": "ok", "inChain": "unchained", "chainStatus": "intact", "payloadAtOperator": "erased" }
| Field | Values |
|---|---|
valid |
true when the signature (and, for a chained receipt, its place in our audit chain) checks out |
signature |
ok · bad · unknown_kid · key_compromised (signed by a retired key after its compromise time) |
inChain |
yes · no · unchained — representation-check receipts and the guard allow for a photo in which nobody enrolled was matched are unchained by design: nothing about a person is recorded for them. A guard request / transform, and the allow after an approved request, are chained guard.decided entries |
chainStatus |
intact or broken_since:<seq> |
payloadAtOperator |
present · erased — whether we still hold the receipt's content. Always erased for an unchained receipt (we never recorded it); for a chained receipt, erased after the person deleted their account. Your own copy of the receipt stays a full proof either way |
failedStep |
with valid: false: signature · digest · hash · chain |
status |
(with §3.4) for an allow receipt with a receipt id: valid · withdrawn (+ withdrawn_at, reason) · unknown |
A string that is not a JWS answers 400 invalid_receipt; a well-formed receipt that fails answers 200 with
valid: false.
Offline (keys only — for audits and high volume): fetch GET /v1/receipts/keys (the current key and every
retired key; cache it, refresh on an unknown kid) and check the Ed25519 signature:
import base64, json, sys, urllib.request
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
def b64(s): return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))
api, jws = sys.argv[1], sys.argv[2]
h, p, s = jws.split(".")
kid = json.loads(b64(h))["kid"]
keys = json.load(urllib.request.urlopen(api + "/v1/receipts/keys"))["keys"]
key = next(k for k in keys if k["kid"] == kid) # StopIteration = unknown kid
Ed25519PublicKey.from_public_bytes(b64(key["x"])).verify(b64(s), f"{h}.{p}".encode()) # raises if bad
print(json.dumps(json.loads(b64(p)), indent=2))
For a chained receipt (people's own consent records, approval answers, matched guard decisions) the full
algorithm also recomputes the payload digest and the entry hash and can compare with our signed hourly chain
heads (GET /v1/audit/heads/{org}); the Dart SDK implements it (verifyReceipt). An offline check proves the
receipt is authentic; only the online check says whether an allow still stands (§3.4).
3.4 Withdrawal notice — when an allow no longer stands
Being built (SR-38). Shapes below follow the contract in review; this section is updated when it is live.
When a person withdraws a consent, changes a rule to deny or ask, removes their enrollment, deletes their
account, or loses a registry credential, every still-valid allow receipt you hold for content showing that
person becomes withdrawn, and we tell you — by receipt id only. The notice never names the person, the media
or how many people are involved.
The event (receipt.withdrawn, the same JSON on the webhook and on the pull endpoint):
{
"event": "receipt.withdrawn",
"receipt_id": "9b7f…",
"issued_at": "2026-10-27T10:00:00Z",
"withdrawn_at": "2026-10-27T10:05:00Z",
"reason": "rules_changed",
"grace_until": "2026-10-28T10:05:00Z",
"partner_tenant": "<your partner Organization id>",
"kid": "consent-…",
"sig": "<base64url Ed25519 signature>"
}
reason ∈ consent_withdrawn · rules_changed · enrollment_removed · account_deleted ·
credential_revoked. receipt_id is the rid claim of your receipt. sig is our Ed25519 signature by kid
(keys of GET /v1/receipts/keys) over the RFC 8785 canonical JSON of the event without sig.
Webhook — we register your HTTPS URL and a shared secret on your tenant. We POST the event to it with:
X-SoluFace-Signature: sha256=<hex>— HMAC-SHA256 of the raw body with your secret;Idempotency-Key: <receipt_id>— the same on every retry;Content-Type: application/json,User-Agent: SoluFace-Notify.
Check the signature on the raw bytes, in constant time, before parsing:
import hmac, hashlib
def verify_webhook(raw_body: bytes, header: str, secret: bytes) -> bool:
expected = "sha256=" + hmac.new(secret, raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, header or "")
Answer any 2xx within 10 seconds once the event is safely queued on your side. Anything else is retried with
backoff (about 1 min, 5 min, 30 min, then hourly) for 24 hours; redirects are not followed; order is not
guaranteed; a repeat with the same Idempotency-Key is already done.
Pull — without a webhook, or to catch up after an outage:
curl -sS "$SF_API/v1/partner/receipts/withdrawn?since=2026-10-27T00:00:00Z&page=1&limit=100" \
-H "Authorization: Bearer $SF_TOKEN"
→ 200 {"items": [<event>…], "page": 1, "has_more": false}, oldest first, up to 90 days back. Every notice is
listed whether or not the webhook was delivered. Poll at least hourly if you have no webhook.
Your obligation (grace period). On receipt.withdrawn you re-check the content
(representation-check with stage=distribute) or remove it before grace_until (24 hours after the
withdrawal). Distributing it after grace_until without a new allow breaches the SoluFace API terms. We cannot
enforce this technically; it is stated, logged on our side and auditable. Online verify answers status:
withdrawn for such a receipt at once — before you re-distribute stored content, verify online.
4. Rules you keep
These are part of the SoluFace API terms. They exist because the people in your media trusted us with a decision, not with their data.
4.1 Nothing retained
- Keep the receipt, the decision and the bytes hash with your content. Do not keep anything else you could derive about the people in it from our answers — there is nothing else in them, and you must not try to infer it (for example by sending crops or many variants to work out who matched).
- Send only the media the check is about, at the moment it is about. Do not use the API to build a gallery, to identify people, or to search for a person.
- Our side keeps no media, face, embedding, box or score from your calls — no row, file, log line or metric. We keep an audit entry per check (your tenant, representation, purpose, stage, decision — no media, no person).
4.2 No personal data in your logs
Log the request id, the path, the status, the decision and the receipt id. Never log the image, its URL, the access token, the refresh token or the password. A receipt is not secret and may be logged.
4.3 What you show to people
- People who enroll through your app (SDK): show the SoluFace enrollment consent text in full, unchanged,
before any capture, from
GET /v1/consent/enrollment/texts/current, and record the version shown (POST /v1/consent/enrollment{consent_text_version, agreed: true, age_attested: true}). Only the current version is accepted (422 consent_text_unknowncarries the current one). Enrollment is for people of 18 or over. - Senders (guard): tell the sender, in your own words, when a photo is blurred or waits for someone's approval. Never tell them who was matched — you do not know, and neither does the app.
- Your own users and the public: your privacy notice says that images are sent to SoluFace for a consent
check at publication and are not kept. Do not say SoluFace "verified", "cleared" or "approved" a person, and do
not say a
no_matchimage is "consented".
4.4 Fail closed
Every error, timeout or 503 means not allowed: do not publish, do not generate, do not share the original.
Retry later. A missing receipt, an unreadable answer or a value you do not know (a new decision or action)
is also not allowed. 503 from the representation check is never an allow and carries no receipt.
4.5 Test personas only — real people after our legal gate
Until SoluFace confirms in writing that the legal review for real-people traffic is complete, send only the synthetic test images of your test pack (§2.3). No production traffic, no photos of real people — staff included. This gate is ours; we lift it per partner.
5. SDK, versions and changes
5.1 SDKs
| Language | Package | Status |
|---|---|---|
| Dart / Flutter | soluface_sdk |
available to partners on request (repository access with the onboarding); pin a release tag, e.g. v0.22.0 |
| Others | — | not yet. The API reference (§5.2) is OpenAPI 3.1; generate a client with the generator of your choice, or call it over HTTP as in §3 |
The Dart SDK covers sign-in and refresh, guardDecide (with blur on transform and the request poll), receipt
verification offline and online (verifyReceipt, verifyReceiptOnline, receiptKeys), and report-a-problem
(FeedbackReporter). The representation check and the withdrawal notice are server-to-server calls; use HTTP as
in §3.1 and §3.4.
5.2 API reference
The reference is generated from the same OpenAPI contracts our services are built from — only the paths a
partner can call: https://soluface.ai/developers/api/.
5.3 Versions, changelog and deprecation
- Paths are versioned in the URL (
/v1/…). A breaking change gets a new prefix (/v2/…) served side by side with the old one;/v1is not changed in a breaking way. - Each API part has its own semantic version (for example guard
0.6.0). A-draftsuffix means the part is still under review and may still change; we tell partners in advance even then. - Additive changes (a new optional field, a new path, a new error code on an existing status) can arrive at any time. Ignore fields you do not know; treat an unknown enum value as "not allowed" (§4.4).
- Changelog. Every change is listed per part and version, with the date. We send partners the entries that touch a path they use.
- No retirement countdown. A new version is released alongside the previous one, and you move to it when you are ready — new features are the reason to move, not a deadline. A version keeps working until we retire it by a business decision, and we tell you well in advance when we do (security fixes excepted — then as fast as safety requires, and we say so).
- Deprecation. A field or path we no longer recommend is marked
deprecatedin the reference and keeps working for as long as its version is served; an older field name keeps its meaning while it is accepted (e.g.email→identifierat sign-in).
6. Errors and troubleshooting
6.1 Error format
Every error has the same body, and the X-Request-Id header:
{ "code": "rate_limited", "message": "too many checks; retry later", "details": {} }
Branch on code and the HTTP status, never on message.
6.2 Error catalogue (the codes a partner can meet)
| Status | code |
Where | Meaning / what to do |
|---|---|---|---|
| 400 | bad_request |
representation check, guard | not multipart/form-data, or no media / image part |
| 400 | invalid_receipt |
receipts verify | not a compact JWS |
| 400 | invalid_since |
withdrawn pull | since is not RFC 3339 |
| 401 | unauthorized |
every bearer call | no, invalid or expired token — refresh or sign in again |
| 403 | not_partner |
representation check, withdrawn pull | your Organization is not (or no longer) a partner — contact us |
| 403 | account_inactive |
sign-in, refresh | the service user is deleted or blocked |
| 412 | not_opted_in |
guard decide | the sender has not opted in — nothing processed |
| 413 | payload_too_large |
media calls (16 MiB), verify (8 KB) | downscale or re-encode and retry |
| 422 | unsupported |
representation check | representation=voice |
| 422 | too_many_frames |
representation check | more than 8 frames |
| 422 | unprocessable_image |
representation check, guard | a part is empty, undecodable or under 8 px a side — retrying the same bytes cannot succeed |
| 422 | unprocessable |
representation check | missing or unknown representation / purpose / stage / content_class |
| 422 | consent_text_unknown |
enrollment consent | not the current text version — details.current_version |
| 422 | age_attestation_required |
enrollment consent | age_attested missing or false |
| 429 | rate_limited |
representation check, verify, feedback | wait Retry-After seconds |
| 500 | internal |
guard | our fault — fail closed, report with the request id |
| 503 | unavailable / guard_unavailable |
representation check, guard | a dependency is down — not allowed, retry with backoff |
6.3 Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
401 on every call after a while |
access token expired (15 min) | refresh with the refresh token before expiry |
401 on refresh |
refresh token older than 30 days, or issued before a password change | sign in again |
403 not_partner |
wrong environment (tenant exists on staging only), or partner flag removed | check SF_API; contact us |
Always no_match with a test image |
wrong environment, or the image was re-encoded / cropped so the face is lost | use the test pack image as delivered, on staging |
allow expected, deny received |
another person in the image denies it; or commercial vs non_commercial; or content_class=intimate |
check the fields; strictest wins |
413 on a small photo |
the multipart body, not the image, is over 16 MiB (several frames) | send fewer or smaller frames |
Verify says valid: true but you were told it was withdrawn |
valid covers signature and chain only |
read status (§3.3, §3.4) |
unknown_kid offline |
key rotated since you cached the keys | re-fetch GET /v1/receipts/keys (it holds every retired key too) |
| Webhook never arrives | URL not HTTPS, a redirect, a non-2xx, or > 10 s to answer |
fix the endpoint; catch up with the pull endpoint |
| Webhook signature mismatch | HMAC computed over parsed / re-serialised JSON | compute it over the raw body bytes |
| Requests hang on large uploads | a proxy on your side buffers or limits bodies | send directly; tell us the request id |