This package wraps the ProofAge v1 HTTP API. Methods live on client.workspace,
client.verifications and client.webhook_subscriptions; AsyncProofAge has the same methods,
awaited. Responses are Pydantic
models from proofage.models, and they are lenient: a field the SDK does not know yet is kept in
model.model_extra, and a status the SDK does not know yet arrives as a plain str instead of a
VerificationStatus member (compare with ==, not is). model.model_dump(mode="json") gives a
plain dict. A machine-readable spec ships next to this file as openapi.json (authoritative for
endpoints and request bodies; where it does not describe a response, the shapes below are
authoritative).
All requests send X-API-Key and X-HMAC-Signature. Request bodies use snake_case to match
the API. A single object is never wrapped in data; the two list endpoints answer
{ data: [...] } (plus next_cursor on GET /verifications), and their models keep that shape.
Argument → environment variable → default. The environment variables use the Laravel/PHP
SDK's units, so one .env serves both; constructor arguments are seconds as floats.
| Argument | Environment | Default | Notes |
|---|---|---|---|
api_key |
PROOFAGE_API_KEY |
required | Workspace API key |
secret_key |
PROOFAGE_SECRET_KEY |
required | Used only to sign; never sent, never in repr |
base_url |
PROOFAGE_BASE_URL |
https://api.proofage.net |
API origin without the version; a trailing /v1 is stripped; a non-http(s) URL, a query or a fragment raises ConfigurationError |
version |
PROOFAGE_VERSION |
v1 |
|
timeout |
PROOFAGE_TIMEOUT (seconds) |
30.0 |
httpx per-operation timeout (connect, each read, each write), not a deadline for the whole request. Node reads this variable in milliseconds |
retry_attempts |
PROOFAGE_RETRY_ATTEMPTS |
3 |
Attempts for interactive requests |
retry_delay |
PROOFAGE_RETRY_DELAY (milliseconds) |
1.0 s |
Linear backoff unit; PROOFAGE_RETRY_DELAY=1000 is one second |
download_retry_attempts |
PROOFAGE_DOWNLOAD_RETRY_ATTEMPTS |
1 |
Media downloads; only transport failures are retried |
http_client |
— | owned | Your httpx.Client / httpx.AsyncClient; the SDK never closes it |
sdk_tokens |
— | () |
name/version tokens for a package wrapping this SDK (see "SDK identification") |
user_agent |
— | SDK default | Replaces the User-Agent; X-ProofAge-Sdk is still sent |
Every non-2xx response raises. All request errors extend ProofAgeError, which carries
status_code, message, code (the API's machine-readable code, when sent), error_data and the
raw response_body:
ProofAgeError
├── AuthenticationError 401
├── PaymentRequiredError 402 code PAYMENT_METHOD_REQUIRED; error_data has free_verifications_remaining, trial_ends_at, trial_active
├── PermissionDeniedError 403 code TEST_WORKSPACE_ONLY from set_test_outcome() in a live workspace; VERIFICATION_MISMATCH from erase_personal_data()
├── NotFoundError 404
├── ValidationError 422 .errors: dict[str, list[str]]; media rejections carry .code (e.g. FACE_NOT_FOUND); also INVALID_STATUS, WEBHOOK_SUBSCRIPTION_LIMIT, VERIFICATION_NOT_FINAL, VALIDATION_ERROR
├── RateLimitError 429 .retry_after: float | None, raised after the retries run out
├── ServerError 5xx code ERASURE_FAILED (503) from erase_personal_data()
└── TransportError no response (DNS, connection, TLS, timeout); __cause__ is the httpx exception
ConfigurationError raised when a client is built
WebhookVerificationError see "Outbound webhook"
A status without its own class (such as 409 from erase_personal_data()) raises ProofAgeError
itself; read status_code and code.
The API uses four error body shapes; the client reads all of them:
{ error: { code, message } }— most errors (401 auth, 429RATE_LIMIT, submit 422, media download 404, test outcome 403TEST_WORKSPACE_ONLYand 422INVALID_STATUS, subscription 422WEBHOOK_SUBSCRIPTION_LIMIT, and on an erasure 403VERIFICATION_MISMATCH, 409VERIFICATION_PROCESSING/ERASURE_IN_PROGRESS, 422VERIFICATION_NOT_FINALand 503ERASURE_FAILED; an erasure's 422VALIDATION_ERRORalso carrieserrors).error_datais the inner object.{ code, message, ...extra }— flat:402 PAYMENT_METHOD_REQUIRED(extra:free_verifications_remaining,trial_ends_at,trial_active) and media-quality rejections on upload (422, e.g.FACE_NOT_FOUND;500 VALIDATION_SERVICE_UNAVAILABLE).error_datais the whole body.{ message, errors }— request validation (422).codeisNone;errorshas the fields.{ message }—403(verification not in your workspace) and404(Resource not found, also an unknown webhook subscription).
A 2xx with an empty body returns None from the methods typed -> None; where a model is
promised, an empty or non-JSON 2xx raises ProofAgeError (a non-JSON one almost always means
base_url points at a website rather than the API).
Retries: GETs retry on 408, 429, 5xx, timeouts and transport errors. POSTs and DELETEs retry
only on 429 and on errors raised before anything was sent (httpx.ConnectError,
httpx.ConnectTimeout) — never on 5xx or after sending began (WriteError, WriteTimeout,
ReadError, ReadTimeout, RemoteProtocolError), where the server may already have created the
verification or subscription, stored the upload, set the outcome or erased the verification. A 429 waits for Retry-After (seconds or an HTTP date) when present, up to 60 seconds —
a longer one raises RateLimitError at once with retry_after set — else retry_delay * attempt.
A 2xx whose body does not match the model raises ProofAgeError ("Unexpected response shape from
GET /v1/..."), naming the fields but never their values.
X-API-Key: workspace API key (plaintext; the server SHA256-hashes it).X-HMAC-Signature: hex HMAC-SHA256 with the workspace secret key over a canonical string:- JSON / no-file requests:
METHOD + /{version}/{path} + ?query + rawJsonBody(direct concatenation, no delimiter; the query part only when there is one, keys sorted and keys and values RFC 3986-encoded, soapproved,declinedisapproved%2Cdeclinedand a space%20). The client builds the query in that form, skippingNonearguments, and sends exactly the string it signed. The body is serialised once, compact, and sent as exactly the signed bytes; an empty payload is the empty string, never{}. - Multipart (file) requests:
METHOD/{version}/{path}\n{fields}\n{comma-joined sorted sha256(file) hashes}, where{fields}is PHPhttp_build_query(ksort($fields), '', '&', PHP_QUERY_RFC3986)— keys sorted, valuesrawurlencoded (so! ' ( ) *are percent-encoded too). The client signs exactly what it sends:Nonefields are dropped, numbers arestr(n), booleans"1"/"0", dicts and lists compact JSON strings. Golden vectors:tests/fixtures/hmac-vectors.jsonin the source repository.
- JSON / no-file requests:
Keyword arguments the ProofAge widget sends (fingerprint, page_url, the browser fields of accept_consent, the capture fields and type="liveness_selfie" of upload_media) are deprecated: they still work, raise DeprecationWarning, and are not part of this contract.
Request: none.
Response: { id: str, name: str, flow_type: str, mode: str, age_mode: str|None, age_threshold: int|None, verification_type: str, redirect_url: str|None, webhook_url: str|None, allow_expired_documents: bool, allow_duplicate_accounts: bool }
Request: none.
Response: { id: int, version: int, text_sha256: str, url: str } (version is informational: accept consent with id and text_sha256)
Request (all optional keywords): callback_url: url(<=2048), external_id: str(<=255), external_metadata: dict, metadata: dict.
Response (201): { id, external_id, external_metadata, redirect_url, status, reason, duplicate_check: DuplicateCheck, erasure: Erasure|None, consent_accepted_at, created_at, updated_at, url } — url is the hosted session the person opens.
Errors: 402 PaymentRequiredError; 422 ValidationError.
DuplicateCheck:{ checked: bool, duplicate_count: int, duplicates: [ { verification_id: str, external_id: str|None, similarity_score: float, verified_at: datetime|None } ] }— always present.Erasure:{ erased_at: datetime, scope: "personal_data", reason: str|None, requested_via: "customer"|"proofage"|"retention"|None }—Noneuntil the verification's personal data is erased.reasonis an erasure reason code (data_subject_request,customer_request,retention_policy,test_data,other) orNoneif unrecorded.
GET /verifications — client.verifications.list(*, status=None, external_id=None, limit=None, cursor=None) → VerificationList
Request (query, all optional): status: VerificationStatus | str | list[VerificationStatus | str] (one status, a list, or a comma-separated string; sent comma-separated; documents_required is not a filter, such a verification is stored as started; an empty list raises ValueError), external_id: str(<=255) (exact, case-sensitive), limit: int(1..100, default 20), cursor: str (the previous page's next_cursor; send the same filters with it).
Response: { data: [Verification], next_cursor: str|None } — newest first, each item the shape get() returns; next_cursor is None on the last page. Only the API key's workspace is listed.
Errors: 422 ValidationError (unknown status, limit out of range).
cursor = None
while True:
page = client.verifications.list(status=["approved", "declined"], limit=100, cursor=cursor)
for verification in page.data:
...
if page.next_cursor is None:
break
cursor = page.next_cursorRequest: none.
Response: same as create without url.
POST /verifications/{verification}/consent — client.verifications.accept_consent(verification_id, *, consent_version_id, text_sha256, ...) → AcceptConsentResult
Request: consent_version_id: int, text_sha256: str(64 hex). consent_version_id / text_sha256 are id / text_sha256 from workspace.consent(). Custom capture flows only.
Response: { consent_version_id: int, consent_accepted_at: datetime }
POST /verifications/{verification}/media — client.verifications.upload_media(verification_id, *, file, type, ...) → None
Request (multipart): file: bytes | pathlib.Path | binary file object (image, <=10 MB; documents >=200px per edge), type: "selfie"|"document", side: "front"|"back" and document: "id"|"driver_license"|"passport"|"residence_permit" (both required when type="document"), optional filename (default: the path's name, else upload.bin). A text-mode file raises TypeError; an invalid type/side/document combination raises ValueError, both before any request. Requires consent accepted first.
Response: 200 with an empty body; returns None.
Errors: 422 ValidationError with .code when the image is rejected (e.g. FACE_NOT_FOUND) or .errors for invalid fields; 500 ServerError VALIDATION_SERVICE_UNAVAILABLE.
Request: none.
Response: 200 with an empty body. Error: 422 { error: { code, message } } (e.g. MISSING_REQUIRED_MEDIA).
GET /verifications/{verification}/document — client.verifications.document(verification_id) → VerificationDocument
Request: none.
Response: { document: { type: DocumentResultType|str|None, issuing_country: str|None, issuing_subdivision: str|None, fields: { first_name: str|None, last_name: str|None, date_of_birth: date|None, document_number: str|None, middle_name: str|None, gender: DocumentGender|str|None, nationality: str|None, place_of_birth: str|None, address: str|None, issue_date: date|None, expiry_date: date|None } }, media: [ { id: str, type: "selfie"|"document_front"|"document_back", url: str|None } ], meta: { attempt_id: str|None } }. url is None once the media has been purged or is past retention.
Document semantics. None means the field was not read or is not printed on that document (a passport has no address, many cards print no place of birth, the German identity card prints no sex). type is an open enum (passport, id, driver_license, residence_permit, other; an unknown value arrives as a plain string): other is a readable document that is not an identity card, passport, driving licence or residence permit, such as a health insurance card, and id is the same value the upload endpoint takes. issuing_subdivision is the state or province of issuance as a bare code (FL with US; up to three uppercase letters or digits) or None: None when issuing_country is, filled today for US driving licences and ID cards, present on every workspace, and kept after erasure. issuing_country and nationality are ISO 3166-1 alpha-2 (XK for Kosovo) and can differ, for example on a residence permit. gender is F, M or X (DocumentGender, open); X means the document states that the sex is unspecified, and gender is None on a Mexican voter card or driving licence created before 20 August 2026 17:00 UTC. date_of_birth, issue_date and expiry_date are dates: a document that prints only the month or year of expiry reports the last day of that period, a partially printed birth or issue date is None, and a permanent document (for example PERMANENTE) has a None expiry_date. place_of_birth is the printed text. address is the printed text as read (trimmed, None when empty, not parsed, may contain line breaks). Age workspaces receive only type, issuing_country and first_name, last_name, date_of_birth, document_number; on them the other seven keys are absent and read as None.
with client.verifications.download_media(verification_id, media_id) as chunks: yields the image bytes as an iterator (async with and an async iterator on AsyncProofAge); client.verifications.download_media_to(verification_id, media_id, path) streams to disk and returns the Path, writing to a temporary sibling and renaming only after a 2xx, so a failure never leaves a partial file. media_id is media[].id from document(); check its url is not None first. Requests send Accept: application/json, */*;q=0.8 so errors come back as JSON. Error: 404 NotFoundError with code == "MEDIA_NOT_FOUND". An HTTP status is never retried, 429 included; a transport failure is retried download_retry_attempts times, only before the first byte.
GET /verifications/{verification}/estimation — client.verifications.estimation(verification_id) → AgeEstimation
Request: none.
Response: { verification_id: str, attempt_id: str|None, age_threshold: { minimum: int|None, passed: bool|None, confidence: float|None }, gender: { value: 0|1|None, confidence: float|None }|None } (gender value: 0=female, 1=male).
POST /verifications/{verification}/blocked-face — client.verifications.block_face(verification_id, *, reason_code=None, reason=None) → None
Request: reason_code: BlockFaceReasonCode | str, reason: str(<=1000).
Response: 204 No Content.
DELETE /verifications/{verification}/personal-data — client.verifications.erase_personal_data(verification_id, *, reason_code, reason=None) → Verification
Irreversibly erases the verification's personal data: images, recordings, document fields, analysis results and device data. The status, reason, dates, audit trail and the fact of the erasure stay. No webhook is sent. Afterwards get() carries erasure: { erased_at, scope: "personal_data", reason, requested_via: "customer" }, document() returns None fields and every media item with url: None, and the media download answers 404.
Request: reason_code: ErasureReasonCode | str (required), reason: str(<=1000)|None (required when reason_code is other).
Response: the Verification, as get() returns it, with its erasure block. A repeat call on an erased verification answers 200 with it again and changes nothing.
Only approved, declined, abandoned and expired verifications can be erased. Errors: 422 ValidationError with code == "VERIFICATION_NOT_FINAL" for any other status, or code == "VALIDATION_ERROR" and .errors for a missing or unknown reason_code or other without a reason; 403 PermissionDeniedError with code == "VERIFICATION_MISMATCH" for another workspace's verification; 409 ProofAgeError (status_code == 409) with code == "VERIFICATION_PROCESSING" (the decision was made a moment ago and the pipeline is finishing) or "ERASURE_IN_PROGRESS" (another erasure of it is running), with Retry-After: 3; 503 ServerError with code == "ERASURE_FAILED" and Retry-After: 3 (deleting the stored files failed, the erasure was rolled back and nothing was erased; safe to call again). The SDK retries neither the 409 nor the 503: call again after about 3 seconds. Limited to 60 calls a minute per workspace (a 429 is retried).
POST /verifications/{verification}/test-outcome — client.verifications.set_test_outcome(verification_id, *, status, reason=None) → Verification
Test workspaces only. Finishes the verification with the given outcome without a person, so an integration's handling of each outcome can be tested end to end; the decision webhooks are sent as for a real outcome. A verification nobody opened is moved through started and submitted first. Works from created, started, submitted, review and resubmission_requested.
Request: status: VerificationOutcome | str (approved, declined, review, resubmission_requested), reason: str(<=1000)|None — a note kept with a resubmission_requested outcome in the verification's history, not the decision reason code.
Response: the Verification, as get() returns it, with its new status.
Errors: 403 PermissionDeniedError with code == "TEST_WORKSPACE_ONLY" in a live workspace (or { message } for a verification of another workspace); 422 ValidationError with code == "INVALID_STATUS" when the verification is already final, or .errors for an invalid field. Not retried on 5xx.
POST /webhook-subscriptions — client.webhook_subscriptions.create(*, url, statuses=None, include_document_data=None) → WebhookSubscription
Subscribes a URL to the decision webhooks, in addition to the workspace webhook URL set in the console. Built for REST hooks such as Zapier: subscribe when an automation is turned on, delete the subscription when it is turned off. Up to 50 per workspace.
Request: url: str (http or https, <=2048, a public address: private, local and cloud-metadata addresses are refused), statuses: list[VerificationStatus | str] | None (any of approved, declined, resubmission_requested, review, abandoned, expired; None or omitted for all of them; a bare string raises TypeError), include_document_data: bool (API default False).
Response (201): { id: str, url: str, statuses: list[VerificationStatus | str]|None, include_document_data: bool, created_at: datetime } — statuses is None when every decision status is delivered.
Errors: 422 ValidationError with code == "WEBHOOK_SUBSCRIPTION_LIMIT" at 50 subscriptions, or .errors for an invalid field. Not retried on 5xx.
Deliveries have the workspace webhook's body and headers (see "Outbound webhook"), only event == "status.updated" (never data.updated) and only for the subscribed statuses. They are signed with the secret key that signed the create request while that key exists, then with the active secret key. Unless include_document_data=True, the body leaves out document, fingerprint_signals and manual_moderation.performed_by (all None on the parsed WebhookEvent). A delivery answered 410 Gone deletes the subscription.
Request: none.
Response: { data: [WebhookSubscription] }, newest first. Not paginated (at most 50).
DELETE /webhook-subscriptions/{subscription} — client.webhook_subscriptions.delete(subscription_id) → None
Request: none.
Response: 204 No Content. Deliveries already queued are not sent.
Errors: 404 NotFoundError (Resource not found) for an id not in the workspace. Not retried on 5xx.
Every verification_id, media_id and subscription_id must be a ProofAge id (letters, digits, -, _); anything else raises ValueError before a request is built.
status(VerificationStatus):created,started,submitted,resubmission_requested,approved,declined,abandoned,expired,review, ordocuments_required(the last is surfaced from the latest attempt's state, not a verification status). Open: an unknown value arrives asstr.statusofset_test_outcome()(VerificationOutcome):approved,declined,review,resubmission_requested.reason_codeoferase_personal_data()(ErasureReasonCode):data_subject_request(the verified person asked for their data to be erased),customer_request(you are erasing it on your own initiative),retention_policy(your retention window elapsed),test_data(a test or synthetic session),other(explain inreason, which is then required).reason_code(BlockFaceReasonCode):presentation_attack(spoof: screen, print or mask),fraudulent_document(forged, edited, or not a real document),scam_or_abuse(identity may be genuine — blocked for behaviour on your platform),underage,other(explain inreason). Optional over the API, mandatory in the ProofAge consoles: send it whenever a person made the decision, or the block cannot be told apart from an automated one in reporting.reason(ondeclined/resubmission_requested): dotted codes from the server's reason catalog — illustrative examples:aml.blocklist.face_match,document.face.mismatch,verification.age_threshold.failed. Treatreasonas an open string.
Headers: X-Auth-Client (api key), X-Timestamp (unix seconds), X-HMAC-Signature
(= hex HMAC-SHA256 of {timestamp}.{rawJsonBody} with the active secret key, or for a webhook
subscription the secret key that created it while that key exists),
X-ProofAge-Webhook-Delivery-Id (the same on every automatic retry of one delivery, a new one on a
manual resend — de-duplicate on it).
from proofage import WebhookVerificationError, verify_webhook
try:
# api_key=, secret_key= and tolerance= fall back to the environment
event = verify_webhook(raw_body, headers)
except WebhookVerificationError as error:
... # answer error.http_status; error.code says which check failedverify_webhook(raw_body, headers, *, api_key=None, secret_key=None, tolerance=None)→WebhookEvent;verify_webhook_signature(...)→None(checks only). Keys and tolerance fall back toPROOFAGE_API_KEY,PROOFAGE_SECRET_KEY,PROOFAGE_WEBHOOK_TOLERANCE(default 300 seconds, in both directions).- Pass the body exactly as received (
bytesorstr). A body whose only change is whitespace is still accepted (the canonical compact JSON is tried once), but one that lost PHP's\/escapes is not. - Error codes, in check order:
MISSING_SIGNATURE,MISSING_TIMESTAMP,MISSING_AUTH_CLIENT(401);CONFIGURATION_ERROR(500, keys missing — checked after the three headers);INVALID_AUTH_CLIENT,MISSING_TIMESTAMPfor a non-integer timestamp,TIMESTAMP_TOO_OLD,INVALID_SIGNATURE(401);INVALID_PAYLOAD(400, correctly signed but not a webhook event; the message names the fields, never the body's values). WebhookEvent:
{
"verification_id": str,
"event": WebhookEventType | str | None, # "status.updated" | "data.updated"; None = status.updated (a retry of a delivery created before the field existed)
"status": VerificationStatus | str, # on data.updated: the current status, unchanged
"external_id": str|None,
"external_metadata": dict|None,
"reason": str|None, # a code only on resubmission_requested / declined
"timestamp": datetime,
"document": Document|None, # as verifications.document() returns it, no media; None on a body sent before it existed and on a subscription delivery without include_document_data
"changed_fields": list[str]|None, # only on data.updated: names of what the correction changed (document.fields keys, or type, issuing_country, issuing_subdivision), no values
"duplicate_detected": bool (default False), # the three duplicate_* keys appear together
"duplicate_count": int|None,
"duplicate_of": { "verification_id": str, "external_id": str|None }|None,
"fingerprint_signals": dict|None, # None on a subscription delivery without include_document_data
"manual_moderation": { # after a console approve/decline
"action": "approve"|"decline", "reason": str, "source": "tenant_admin"|"landlord_admin",
"performed_by": { "id": int, "name": str|None, "email": str|None, "role": str|None }|None, # None on a subscription delivery without include_document_data
"source_status": str|None, "source_reason": str|None
}|None,
"delivery_id": str|None # from X-ProofAge-Webhook-Delivery-Id
}
Dispatch on event first. status.updated (or event is None) is every webhook you already know:
the verification moved to status. data.updated means someone on the tenant's team corrected document
fields the reader got wrong (console or MCP): status is the current one and a correction never changes
it, document holds the corrected values and changed_fields names what changed. It is not a decision:
update the stored document fields and leave the verification's status alone. A handler that ignores
event sees what looks like a resend of the same status, which it must tolerate anyway (de-duplicate on
delivery_id, make applying a status idempotent). Compare with == (event.event == WebhookEventType.DATA_UPDATED):
an event added later arrives as a plain string.
Extras (floors: fastapi>=0.100, flask>=3.0, django>=5.2) that wrap verify_webhook (install proofage[fastapi], proofage[django] or
proofage[flask]; importing one without its framework raises ImportError with the exact
command). Each answers a failed verification itself (401, INVALID_PAYLOAD 400, CONFIGURATION_ERROR
500) and never calls your handler.
- FastAPI:
from proofage.integrations.fastapi import ProofAgeWebhook— annotate a parameterevent: ProofAgeWebhook; explicit keys:Depends(webhook_dependency(api_key=, secret_key=, tolerance=)). Error body:{"detail": {"code", "message"}}. - Django:
@proofage_webhook(bare or with the keyword arguments) — CSRF-exempt, POST-only, setsrequest.proofage_event; synchronous views. Error body:{"error": {"code", "message"}}. - Flask:
@proofage_webhook(bare or with the keyword arguments) — passes theWebhookEventas the view's first argument. Error body:{"error": {"code", "message"}}.
Every request carries X-ProofAge-Sdk: [wrapper tokens ]python/{version} (wrappers pass
sdk_tokens=["telegram-bot/1.2.0"], outermost first; a wrapper token named python is dropped)
and User-Agent: ProofAge-Python/{version} (Python {x.y.z}) unless you pass user_agent or your
http_client carries its own. Neither header is part of the signature.
This contract is drift-tested against openapi.json by tests/test_api_contract.py, so it stays
aligned with the API. Maintainers refreshing it after an API change: see the checklist for
carrying an API change to every client, .ai/guidelines/api-changes.md in the ProofAge app repo.