§ 01 · Prerequisites
Before the first call, you need a tenant and a set of credentials. Paphwey is provisioned per relying party — one tenant maps to one RP, one set of keys, one audit chain.
- A Paphwey tenant and an
X-API-Keywith theagent:delegatescope. - Your RP identifier (the
audienceyou will set on every challenge — e.g.merchant.example). - At least one policy attached to the tenant — created via the Paphwey admin or provisioned by your account manager.
- A cryptographically-secure HTTP client with TLS 1.2+ and
Host/SNIcorrectly set.
§ 02 · Authentication
Every request carries an X-API-Key
header. Production keys are prefixed
sk_live_; sandbox tenants (RP
is_sandbox=true) are issued
sk_test_ keys against the same
endpoints, so test traffic is visibly distinct
from production at a glance. Rotate keys with a
one-week overlap window — rotate before expiry,
not after.
X-API-Key: sk_live_8f3a2b4c.deadbeefcafef00d1234567890abcdef Content-Type: application/json Accept: application/json User-Agent: your-service/1.4.2 (+https://yourco.example)
agent:delegate scope is required for
create_delegation,
present_delegation, and
verify_outcome. Revocation-only services
can use an agent:revoke key instead.
Sanity checks before you build
Two unauthenticated /authenticated probes confirm you are pointed at the right host with the right key — useful as a first call from a new integration.
# 1. Liveness (no auth, returns 200 if the gateway is up). curl https://www.paphwey.com/healthz # 2. Identify yourself (auth required, returns RP + scopes + policies). curl https://www.paphwey.com/api/v1/whoami \ -H "X-API-Key: sk_live_..."
/api/v1/whoami returns the relying
party your key resolves to, the credential's scopes
and expiry, the policy codes you may pass as
challenge_type, and the canonical base
URL. If your tenant is brand-new and
policies.codes comes back empty, ping
your account manager — Paphwey seeds
HIGH_VALUE_PURCHASE_REQUIRED,
AGE_VERIFICATION_REQUIRED, and
LOGIN_ASSURANCE by default but the
tenant's allowed_policy_codes may be
empty.
§ 03 · Step 1 — Create a delegation
A delegation is a signed authority from the principal to a named agent, bounded by scopes, a spending ceiling, and an expiry. Creating one does not prompt the user — it only registers the authority.
/api/v1/agent/delegations
Request body
| Field | Type | Notes |
|---|---|---|
principal_email | string | One of principal_email or principal_id is required. Matches the wallet-bound identity. |
provider | string | Free-form provider label (e.g. openai, anthropic, google, your own internal id). Recorded verbatim on the audit chain. |
agent_id | string | Stable identifier for the agent binding (e.g. shopping-agent). |
allowed_scopes | string[] | Subset of the policy's permitted scopes. |
allowed_challenge_types | string[] | The policy codes this delegation may exercise (e.g. HIGH_VALUE_PURCHASE_REQUIRED, AGE_VERIFICATION_REQUIRED). The string you pass here in §04 as challenge_type is the policy lookup key — it must match a policy code that exists on the tenant. |
max_amount_minor | integer | Per-action ceiling in minor units (pence / cents). Required for monetary scopes; pass 0 for non-monetary flows like age verification. |
currency | ISO-4217 | Three-letter currency code. |
valid_until | RFC-3339 | UTC; inclusive. Must be ≤ tenant-max TTL. |
metadata | object | Optional. Opaque key/value annotations retained on the audit record. |
Example — cURL
curl -X POST https://www.paphwey.com/api/v1/agent/delegations \
-H "X-API-Key: sk_live_..." \
-H "Content-Type: application/json" \
-d '{
"principal_email": "alice@example.com",
"provider": "openai",
"agent_id": "shopping-agent",
"allowed_scopes": ["purchase"],
"allowed_challenge_types": ["HIGH_VALUE_PURCHASE_REQUIRED"],
"max_amount_minor": 2500,
"currency": "GBP",
"valid_until": "2026-05-01T00:00:00Z",
"metadata": {"channel": "web-chat", "order_hint": "gift"}
}'
Response · 201 Created
{
"delegation_id": "c5a3b2e1-7d4f-4e9a-b1c2-3d4e5f6a7b8c",
"status": "active",
"credential_jwt": "eyJhbGciOiJSUzI1NiIs...",
"credential_kid": "plan3-abcd",
"agent_did": "did:key:z6Mk...",
"present_token": "eyJ... short-lived hint ...",
"present_token_expires_at": "2026-04-18T09:25:13Z",
"principal_type": "HUMAN_DELEGATED"
}
credential_jwt is a W3C VC-JWT describing
the delegation — verify it locally against the
gateway JWKS if you don't trust Paphwey blindly.
present_token is a 5-minute bearer hint
that can be surfaced to the relying party before a
challenge opens. There is no created_at,
principal_did, or
agent_provider in the response — query
GET /api/v1/agent/delegations/{id}
if you need the full envelope.
§ 04 · Step 2 — Present the delegation
Presenting opens a challenge bound
to the delegation and the specific action. The
response carries an approval_url — a
single-use link your agent surfaces on the user's
phone (via SMS, email, or the RP's own UI).
/api/v1/agent/delegations/{delegation_id}/challenge
Request body
| Field | Type | Notes |
|---|---|---|
challenge_type | string | The policy code to evaluate. Must match a policy active on the tenant and appear in the delegation's allowed_challenge_types. There is no fixed enum at the gateway level — what's "valid" depends entirely on the tenant's policy table. |
audience | string | Your RP identifier. Must match the audience you later verify against. |
payload.action_context | object | Declarative description of the action (scope, amount_minor, currency, merchant). |
payload.minimum_assurance | integer | Minimum NIST-aligned assurance level you will accept (0–30). |
payload.agent_context | object | Optional — auto-filled. If you omit it, Paphwey injects {"provider": delegation.provider, "agent_id": delegation.agent_id} from the delegation. Pass it explicitly only when you need to override. |
nonce | string | Optional. Echoed in the verified token; use to bind a session to a specific challenge. |
callback_url | URL | Optional. Paphwey POSTs the outcome envelope here when the user approves. For local development, ngrok or a public tunnel is required — Paphwey cannot reach localhost; for those flows, omit callback_url and use the poll_url path described in §07. |
agent_pop | string | Required for key-bound delegations. JWS produced by the agent's bound key, asserting possession. |
Example — cURL
curl -X POST \
https://www.paphwey.com/api/v1/agent/delegations/dlg_01HZ3VJT1M8T8.../challenge \
-H "X-API-Key: sk_live_..." \
-H "Content-Type: application/json" \
-d '{
"challenge_type": "HIGH_VALUE_PURCHASE_REQUIRED",
"audience": "merchant.example",
"payload": {
"action_context": {
"scope": "purchase",
"amount_minor": 1500,
"currency": "GBP",
"merchant": "acme.example"
},
"minimum_assurance": 10
},
"callback_url": "https://merchant.example/hooks/paphwey",
"ttl_seconds": 900
}'
Response · 201 Created
{
"challenge_id": "a277c8e4-1234-4abc-b9d0-fedcba012345",
"status": "AWAITING_APPROVAL",
"approval_url": "https://www.paphwey.com/link-device/?challenge_id=a277c8e4-...&approval_id=ap_8b2...",
"poll_url": "https://www.paphwey.com/api/v1/orchestration/challenges/a277c8e4-.../",
"expires_at": "2026-04-18T09:35:13Z"
}
approval_url is the user-facing link
(open it on their phone, scan the QR). It hosts the
Paphwey link-device flow — there is no separate
wallet domain. poll_url points at
/api/v1/orchestration/challenges/{id}/;
poll it (1-second floor) until status
flips to APPROVED or DENIED.
§ 05 · Step 3 — Verify the outcome
When the user approves, you receive an
attestation token — a JWS
containing the delegation ID, the audience, the
action context, a key-binding confirmation
(cnf.jwk), and the assurance score.
Verify it on every request that depends on it.
/api/v1/agent/outcomes/verify
Request body
| Field | Type | Notes |
|---|---|---|
attestation_token | JWS | The compact-serialized token the agent presents to your RP. |
audience | string | Must match your RP identifier. |
expected_delegation_id | string | Optional but strongly recommended. Rejects replay across delegations. |
expected_action | object | Optional. Assert the scope / amount / currency you expect. |
Example — cURL
curl -X POST https://www.paphwey.com/api/v1/agent/outcomes/verify \
-H "X-API-Key: sk_live_..." \
-H "Content-Type: application/json" \
-d '{
"attestation_token": "eyJhbGciOiJFUzI1NiIsImtpZCI6I...",
"audience": "merchant.example",
"expected_delegation_id": "dlg_01HZ3VJT1M8T8...",
"expected_action": {
"scope": "purchase",
"amount_minor": 1500,
"currency": "GBP"
}
}'
Response · 200 OK
{
"valid": true,
"claims": {
"sub": "principal-uuid",
"challenge_id": "a277c8e4-...",
"nonce": "session-nonce-123"
},
"delegation": {
"id": "c5a3b2e1-...",
"agent_did": "did:key:z6Mk...",
"allowed_scopes": ["purchase"],
"status": "active"
},
"actor_chain": {
"principal_id": "principal-uuid",
"agent": {"provider": "openai", "agent_id": "shopping-agent"}
},
"receipt_id": "rcpt-01HZ3VKZ...",
"kyc_reference": ""
}
claims (sub,
challenge_id, nonce) and
delegation (agent_did,
allowed_scopes, status).
The top-level valid only tells you
Paphwey accepted the signature — your business
logic still needs to check the
actor_chain matches what you expected.
Offline verification (optional)
For latency-sensitive paths you can verify the JWS
locally against the gateway JWKS. Fetch
https://www.paphwey.com/.well-known/jwks.json
and cache with a max-age of 5 minutes.
HEADER { "alg": "ES256", "kid": "2026-04-key-1" }
PAYLOAD {
"iss": "https://www.paphwey.com",
"aud": "merchant.example",
"sub": "did:web:paphwey.com:users:alice",
"cnf": { "jwk": { "kty":"EC","crv":"P-256","x":"...","y":"..." } },
"delegation_id": "dlg_...",
"action_context": { "scope":"purchase", "amount_minor":1500, "currency":"GBP" },
"assurance_score": 22,
"iat": 1745000000,
"exp": 1745000600
}
§ 06 · Revoke & query
Two management endpoints round out the contract. A
daily snapshot of all revocations is also served at
/api/v1/delegations/revocations.json for
offline reconciliation.
/api/v1/agent/delegations/{delegation_id}
Returns the current status
(active, revoked,
expired) plus the full scope envelope.
/api/v1/agent/delegations/{delegation_id}/revoke
curl -X POST \
https://www.paphwey.com/api/v1/agent/delegations/dlg_01HZ.../revoke \
-H "X-API-Key: sk_live_..." \
-H "Content-Type: application/json" \
-d '{"reason": "user_request", "actor": "support:agent:42"}'
/api/v1/agent/policies
Lists the challenge types and scope envelopes enabled for your tenant. Read-only.
§ 07 · Webhooks & polling
Two delivery models — pick per environment. Callback URLs are preferred in production; polling is simpler in local development.
Callback payload
If you set callback_url, Paphwey POSTs
a signed envelope to that URL within ~500 ms of
user approval. The envelope is itself a JWS — treat
it exactly like an attestation token and verify
against the gateway JWKS before accepting.
POST https://merchant.example/hooks/paphwey Content-Type: application/jose Paphwey-Signature: t=1745000000,v1=eyJhbGciOiJFUzI1NiIs... Paphwey-Delivery: dlv_01HZ3VL1... eyJhbGciOiJFUzI1NiIsImtpZCI6IjIwMjYtMDQta2V5LTEifQ.eyJldmVudCI6Im91dGNvbWUuYXBwc...
Polling the challenge
Use the poll_url you got from §04 — it
resolves to
GET /api/v1/orchestration/challenges/{id}/
and is the canonical state source for the user's
decision. Poll at a 1-second floor.
curl https://www.paphwey.com/api/v1/orchestration/challenges/a277c8e4-.../ \ -H "X-API-Key: sk_live_..."
Status enum (uppercase, stable): PENDING,
AWAITING_APPROVAL, APPROVED,
DENIED, EXPIRED,
CANCELLED, ISSUED. Treat
anything other than APPROVED as a
terminal "no go" once expires_at has
passed.
Paphwey-Delivery — not on the payload.
§ 08 · Error envelopes
All errors share one shape. No stack traces, no
library-specific messages, no PII. The
code is stable; the detail
is human-readable and safe to log.
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/json
{
"error": {
"code": "delegation.scope_exceeds_policy",
"detail": "Requested scope 'purchase' exceeds tenant policy ceiling.",
"trace_id": "01HZ3VKZ...",
"hint": "Reduce max_amount_minor or raise the policy ceiling."
}
}
| HTTP | Code prefix | Meaning |
|---|---|---|
| 400 | request.* | Malformed JSON, missing fields, unknown enum value. |
| 401 | auth.* | Missing, invalid, or revoked API key. |
| 403 | scope.* | Key lacks the required scope, or tenant blocked. |
| 404 | not_found.* | Delegation, challenge, or policy not found. |
| 409 | state.* | Delegation already revoked, challenge already consumed. |
| 422 | delegation.* | Business-rule violation (scope/ceiling/TTL). |
| 429 | rate_limit.* | Rate limit tripped. See Retry-After. |
| 5xx | gateway.* | Transient. Retry with exponential backoff + jitter. |
§ 09 · Rate limits & retries
Rate limits are tenant-scoped. Default production
tier is 120 req/s per endpoint; bursts up to 600 in
a 10-second window. Responses always carry
X-RateLimit-* headers.
- Retry only on
429or5xx. Never retry4xx. - Exponential backoff starting at 250 ms, cap 30 s, full jitter.
- Use
Idempotency-Keyon retries. Send the same opaque key (e.g. a UUID per logical action) on the original call and every retry. Paphwey caches the response for 24 hours per(tenant, endpoint, key)tuple, so a network-flap retry returns the samedelegation_id/challenge_idinstead of creating a duplicate. Replays carry anIdempotency-Replayed: trueresponse header. - Budget ~150 ms P50 for
verify_outcomefrom UK/EU egress.
Idempotency-Key in detail
Send Idempotency-Key: <opaque> on
POST /api/v1/agent/delegations and
POST /api/v1/agent/delegations/{id}/challenge.
Keys must be 1-255 printable-ASCII characters; a
UUID is the conventional choice. The server's
behaviour:
| Scenario | Server response |
|---|---|
| Header absent | Endpoint runs as normal. Idempotency-Replayed: false. |
| Header present, first time seen on this tenant + endpoint | Endpoint runs; success response is cached for 24h. Idempotency-Replayed: false. |
| Header present, same key + same body | Cached response is returned verbatim — same status, same body. Idempotency-Replayed: true. The underlying mutation does not run again. |
| Header present, same key + different body | 409 Conflict with error_code: idempotency_key_conflict. Pick a new key. |
| Header present, malformed (empty / control chars / >255 chars) | 400 with error_code: validation_error and details.idempotency_key. |
§ 10 · Pre-production checklist
- API key is stored in a secrets manager, never in source or CI logs.
- Sandbox keys (
sk_test_…, issued for tenants withis_sandbox=true) never appear in production config. Confirm the active environment withGET /api/v1/whoamibefore going live. - Callback URL is served over TLS and verifies the signature header.
- Verification re-runs against the gateway on every incoming agent request — no cached "valid=true".
- Confirmation JWK is pinned for the session after first verification.
- Retry policy covers 429 and 5xx with full jitter, and sends an
Idempotency-Keyon everycreate_delegation/present_delegationcall so retries dedupe at the gateway. - Revocation snapshot is reconciled daily against your local cache.