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.

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

  1. Write to support@soluface.ai with your organization name, the flows you need (§3) and a technical contact.
  2. 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.
  3. 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…" }

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"

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

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

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:

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

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

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.

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

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