Payzone API — connection and integration guide
This is the developer guide for connecting a system to the Payzone Remittance Control API.
The machine-readable contract is openapi.json (OpenAPI 3.1, title Payzone Remittance Control
API, version 2026-08-03). It documents 46 paths and 47 operations, covering every route defined in
the source — though not every one of those is wired into the running application, which is called out
under Availability. Confirm the exact surface for your environment during onboarding.
listed at the end of this page; they are real and not yet reconciled.
The API is an operational control plane. It records and authorises remittances, runs fail-closed screening and limit checks, and opens compliance cases. It does not itself move money, dispatch to a payment network, or file with a regulator. Provider and regulator connectivity is not implied by any endpoint on this page.
Base URL and transport
The contract names a single server, https://payzone.io, described as the configured production
origin with runtime enablement environment-gated. Confirm the origin, and whether the routes you need
are enabled, with your integration contact before building against it.
Every response carries Cache-Control: no-store, HSTS, X-Content-Type-Options: nosniff,
X-Frame-Options: DENY, a default-src 'none' CSP and a restrictive Permissions-Policy.
There is no browser access in production. Cross-origin headers are emitted only for a fixed
localhost development allowlist; when NODE_ENV=production the allowlist is empty, so no Origin
receives Access-Control-Allow-Origin. Call the API from a server.
Authentication
Bearer JWT access tokens are issued by the environment's Amazon Cognito user
pool in ca-central-1. Interactive sign-in is performed by the Payzone web
application. The API pins COGNITO_ISSUER, COGNITO_USER_POOL_ID,
COGNITO_APP_CLIENT_ID; signing keys are refreshed from the issuer-bound public JWKS endpoint.
Present the token as Authorization: Bearer <access_token>. Each protected request is checked in
this order, and the first failure ends the request:
- Bearer syntax. The header must match
Bearer <token>. A missing or malformed header is normalised to401witherror_code: UNAUTHORIZEDand aWWW-Authenticate: Bearerheader. - Token verification. The signature is verified offline with pinned public
Cognito JWKS.
token_usemust beaccess; issuer, client id and pool must match exactly, and token lifetime must not exceed five minutes. - Operational authority. Caller-supplied roles, scopes, permissions and public metadata are ignored. Effective authority is derived from current database events.
- Coarse group.
cognito:groupsmust contain exactly one ofpayzone-cpoorpayzone-member. Additional or missing groups are denied. - Database context. The immutable subject mapping and current database assignments determine CPO/member standing, agent scope and permissions.
Consequences worth planning for:
- This is a workforce API. Every caller is a human-authenticated operator. There is no client-credentials path, no machine token and no API-key issuance surface. See Not available yet.
- Authentication time older than 12 hours and access tokens with more than a five-minute lifetime are refused; integrations must return the operator to interactive sign-in.
Two error shapes
Authentication failures use the Payzone error shape:
Token verification failures return 401 UNAUTHORIZED. Authority, MFA, authorized-party and application failures use the same Payzone
shape described under Error model.
Roles, offices and why a valid scope can still be refused
Cognito group membership is necessary and not sufficient. The database is authoritative for what an actor may touch. Database role-to-office mapping:
| Database operational role | Office class |
|---|---|
CPO Control |
CPO rail controller (not an office class) |
Front Office Operator |
FRONT_OFFICE |
Middle Office Analyst |
MIDDLE_OFFICE |
Back Office Operator |
BACK_OFFICE |
Main Agent Administrator |
MAIN_AGENT_ADMIN |
Sub-agent Operator |
SUB_AGENT_OPERATOR |
Agent MLRO |
AGENT_MLRO |
Teller/Agent Operator (legacy) |
FRONT_OFFICE, SUB_AGENT_OPERATOR |
Compliance Analyst (legacy) |
MIDDLE_OFFICE |
Senior Compliance Officer (legacy) |
MIDDLE_OFFICE |
MLRO (legacy) |
AGENT_MLRO |
The legacy application roles are accepted during the office-class migration window and map only to their canonical equivalent.
An authorisation decision is derived from the actor's active office assignments for that exact
agent and other current database grants. A caller is refused when the necessary front-office or
other standing is absent, regardless of any extra claim supplied in the token.
Object-scoped refusals are returned as 404 rather than 403 wherever leaking existence would be a
disclosure — an unauthorised agent lookup is indistinguishable from a missing one.
Read your own effective authority from GET /v1/me. It requires an authenticated operational token
but no additional scope, and resolves offices from the database, not from the token:
permissions and scopes are distinct claims. Only scopes is enforced. A token whose
permissions array lists a scope that is absent from scope will be refused.
{ "error_code": "UNAUTHORIZED", "message": "A valid Cognito access token is required" }
GET /v1/me HTTP/1.1
Authorization: Bearer <access_token>
{
"user_id": "9c2f5b41-0d3a-4a77-8f2e-6b1c9a4e7d30",
"roles": ["Front Office Operator"],
"permissions": ["quotes:create", "remittances:create"],
"scopes": ["quotes:create", "remittances:create"],
"compliance_actions": [],
"operations_context": {
"cpo_rail_controller": false,
"cpo_role": null,
"office_assignments": [
{
"agent_id": "5e0a1c88-7b64-4a1f-9d2c-31f0b7ae4c12",
"main_agent_id": "5e0a1c88-7b64-4a1f-9d2c-31f0b7ae4c12",
"agent_type": "MAIN_AGENT",
"office_class": "FRONT_OFFICE",
"role": "Front Office Operator",
"workspace": "Front Office Operator"
}
]
}
}
Scope model
Twelve scopes exist. These are the complete set.
| Scope | Unlocks |
|---|---|
quotes:create |
POST /v1/quotes, GET /v1/remittance-context |
remittances:create |
POST /v1/transactions, POST /v1/agents/{agent_id}/beneficiary-events, POST /v1/channel-ingress/authorize |
remittances:authorize |
POST /v1/transactions/{transactionId}/authorize |
remittances:read |
GET /v1/transactions, GET /v1/transactions/{transactionId}, GET /v1/operations/payouts, GET /v1/operations/payout-executions, GET /v1/operations/cns |
compliance:cases:read |
GET /v1/compliance/cases, GET /v1/compliance/cases/{caseId}, GET /v1/compliance/high-risk-approvals, GET /v1/compliance/regulator-filings, GET /v1/regulatory-reporting/cases |
compliance:step-up |
POST /v1/compliance/cases/{caseId}/decision-intents, POST /v1/compliance/high-risk-requests/{requestId}/decision-intents |
compliance:decide |
POST /v1/compliance/cases/{caseId}/decision, POST /v1/compliance/high-risk-requests/{requestId}/decision |
identity-verification:start |
POST /v1/agents/{agentId}/in-store-onboarding, POST /v1/agents/{agentId}/in-store-onboarding/{caseId}/identity-capture-session |
identity-verification:read |
GET /v1/agents/{agentId}/in-store-onboarding/{caseId}, GET /v1/agents/{agentId}/in-store-onboarding/provider-status |
agent-rails:read |
GET /v1/cpo/overview, GET /v1/cpo/agents/{agentId}/rails, GET /v1/cpo/ledger/balances, GET /v1/cpo/operational-readiness, GET /v1/cpo/resilience/evidence, GET /v1/operations/reconciliation, GET /v1/operations/notifications/dead-letters |
agent-rails:manage |
POST /v1/cpo/agents/{agentId}/rail-events, POST /v1/cpo/corridor-events, POST /v1/cpo/ledger/accounts/provision |
agent-onboarding:manage |
POST /v1/cpo/agents, POST /v1/cpo/agents/{agent_id}/sub-agent-operator, POST /v1/cpo/agents/{agent_id}/regulatory-profile, and the two conditionally registered regulatory-decision routes |
GET /health requires no token. GET /v1/me requires a token and no scope. Several agent-rails:*
and compliance:* routes additionally require the CPO Control role or compartment membership on
top of the scope; those refusals are 403 CPO_CONTROL_FORBIDDEN, 403 SAR_COMPARTMENT_REQUIRED or a
concealing 404.
Step-up authentication
Two operations require a fresh, bound, phishing-resistant WebAuthn assertion, over and above the baseline MFA every request already carries:
POST /v1/compliance/cases/{caseId}/decision— recording a compliance disposition.POST /v1/compliance/high-risk-requests/{requestId}/decision— the checker half of a dual-control approval.
These are the binding, irreversible decisions in the system: releasing or returning a held transfer, freezing assets, filing a SAR/STR. The control ensures the person clicking is provably the person authenticated, seconds ago, for this exact decision — not merely someone holding a live session.
Step-up is a two-call protocol.
Create an intent (
compliance:step-up). The service records the case id, the actor, the intended action, the expected row version and a canonical hash of the decision draft. The intent expires in 5 minutes.Re-authenticate the operator with WebAuthn, carrying the returned
intent_id, and obtain a new access token bearing:Claim Required value https://remit.payzio.com/assurance_levelAAL3https://remit.payzio.com/authentication_methodwebauthn-platformorwebauthn-roaminghttps://remit.payzio.com/authenticated_atUnix seconds, at most 300s old https://remit.payzio.com/step_up_intent_idthe exact intent_idSubmit the decision (
compliance:decide) with the samestep_up_intent_idin the body, the sameIf-Matchrow version and a byte-identical draft.
The server re-derives the decision hash and compares it to the stored intent. Any drift — a different action, a changed rationale, a stale row version, a different actor, an authentication that predates the intent by more than 30 seconds — fails. Intents are single-use.
| Failure | Status | error_code |
|---|---|---|
| No AAL3 claims, wrong method, or older than 300s | 401 | STEP_UP_MFA_REQUIRED |
intent_id unknown |
401 | STEP_UP_INTENT_INVALID |
| Intent past its 5-minute expiry | 401 | STEP_UP_INTENT_EXPIRED |
| Proof not bound to this exact decision | 401 | STEP_UP_INTENT_MISMATCH |
| Intent already consumed | 409 | STEP_UP_INTENT_CONSUMED |
Idempotency
Idempotency-Key is required on every mutating request. Omitting it is 400 IDEMPOTENCY_KEY_REQUIRED — it is not optional and there is no fallback.
Format differs by route family:
- Workflow and CPO rail routes: an opaque string, 8–200 characters.
- Channel ingress and the agent-regulatory routes: a UUID.
How keys bind:
- The key is namespaced to the authenticated actor. Two operators may use the same key without colliding; one operator cannot reuse a key across different requests.
- The stored key is bound to a canonical SHA-256 hash of the operation: the operation name, the validated request body (keys sorted, recursively), the resolved actor id and, where applicable, the path id and the expected row version.
- Replay with an identical body returns the stored response with
200. Note that this differs from the first call, which returns201for creates. Do not treat201as the only success. - Reuse with a different body returns
409 IDEMPOTENCY_KEY_REUSE. Nothing is executed. - Terminal business refusals are stored too. A
409 LIMIT_EXCEEDEDfrom an authorisation is persisted against the key and replayed as409, not200. A limit breach does not become a success by retrying it.
Key derivation is per-actor and per-operation-hash, so an idempotency key does not protect you across a change of operator. Generate a fresh UUID per logical attempt and reuse it only for retries of that attempt.
Correlation ids
Send X-Correlation-ID as a UUID. If it is valid it is adopted; if it is absent or malformed the
server generates one silently — you will never get an error for a bad correlation id, so validate it
on your side.
The correlation id is echoed in the response body as correlation_id (both on success and on error),
not as a response header. It is persisted onto the remittance record and onto every hash-chained
immutable audit event the request produces, which is how a request is traced end to end. Log it
against your own request id.
Mutating responses also return audit_event_id, the identifier of the audit event appended for that
action. On errors constructed by the application, audit_event_id is null; the one significant
exception is LIMIT_EXCEEDED, which carries a real audit event id because the breach itself is
recorded.
Error model
Application errors are:
error_code and message are always present. details carries structured context where the error
has any — currently the breached limit policies on LIMIT_EXCEEDED.
Branch on error_code, never on message. Codes a caller must handle:
Authentication and authority
error_code |
Status | Meaning |
|---|---|---|
UNAUTHORIZED |
401 | Missing/malformed bearer, or the token's authorized party is not this client |
PHISHING_RESISTANT_MFA_REQUIRED |
401 | No current WebAuthn session behind the token |
OPERATIONAL_ROLE_REQUIRED |
403 | No allowlisted Payzone role on the token |
OBJECT_ACCESS_FORBIDDEN |
403 | The named remittance objects are not accessible to this actor |
QUOTE_CREATION_FORBIDDEN |
403 | Actor holds no front-office or sub-agent office |
SEPARATION_OF_DUTIES_REQUIRED |
403 | The creator of a transfer cannot authorise it |
COMPLIANCE_SEPARATION_REQUIRED |
403 | The creator of a transfer cannot dispose of its compliance case |
COMPLIANCE_ACTION_FORBIDDEN |
403 | The verified role does not permit this compliance action |
CPO_CONTROL_FORBIDDEN |
403 | CPO Control role required |
SAR_COMPARTMENT_REQUIRED |
403 | SAR/STR work requires active compartment membership |
Request and state
error_code |
Status | Meaning |
|---|---|---|
INVALID_REQUEST, INVALID_TRANSACTION_ID, INVALID_CASE_ID, INVALID_AGENT_ID, INVALID_REQUEST_ID, INVALID_QUERY |
400 | Validation |
IDEMPOTENCY_KEY_REQUIRED |
400 | Idempotency-Key absent or wrong length |
IF_MATCH_REQUIRED |
428 | If-Match absent or not a positive integer |
IDEMPOTENCY_KEY_REUSE |
409 | Key reused with a different body |
VERSION_CONFLICT |
409 | If-Match does not match the current row version |
STATE_CONFLICT |
409 | The object is not in a state that permits this operation |
QUOTE_NOT_FOUND, QUOTE_EXPIRED, QUOTE_MISMATCH |
404 / 409 | Quote missing, past its 15-minute life, or its payout method disagrees with the transaction |
TRANSACTION_NOT_FOUND, CASE_NOT_FOUND, AGENT_NOT_FOUND, HIGH_RISK_REQUEST_NOT_FOUND |
404 | Missing — or deliberately concealed because unauthorised |
Fail-closed — the controls that deny rather than guess
error_code |
Status | Meaning |
|---|---|---|
CORRIDOR_UNAVAILABLE |
422 | The corridor is not configured, or is not enabled for this agent |
AMOUNT_TOO_LARGE |
422 | A calculated amount exceeds the supported safe-integer range |
RAIL_UNAVAILABLE |
409 | The selected pay-in or pay-out rail is disabled for the agent. Re-checked after screening, so it can fire late |
LIMIT_EXCEEDED |
409 | A hard remittance limit would be breached. details[] lists policy_id, metric_type (AMOUNT_MINOR or TRANSACTION_COUNT), limit_value, projected_value |
LIMIT_POLICY_UNAVAILABLE |
503 | No active limit policy applies. The system will not authorise without one |
REQUIRED_CONTROL_UNAVAILABLE |
501 | A required control is not in place — reachable today when escalation is requested and the main agent has no active MLRO |
CONTROL_READ_MODEL_UNAVAILABLE |
503 | An authoritative operational relation is unavailable. Never rendered as an empty list |
TRUSTED_GATEWAY_UNAVAILABLE, CHANNEL_CONTROL_UNAVAILABLE |
503 | Channel-ingress verification or its authoritative control is unavailable |
Screening deserves its own note, because it does not surface as an error code at all.
Screening never returns a pass on failure. The screening call has a 2-second deadline; a timeout,
an exception, or an unrecognised outcome all resolve to UNAVAILABLE. Authorisation proceeds to
AUTHORIZED only when every party screens CLEAR. Anything else — POSSIBLE_MATCH,
CONFIRMED_MATCH, PEP_MATCH, ADVERSE_MEDIA_MATCH, INTERNAL_BLOCK, INCONCLUSIVE,
UNAVAILABLE — holds the transfer and opens a compliance case. You will see this as 202, not as an
error. Treat 202 as the normal path, not the exception: a deployment whose screening provider is
not configured holds every transfer, by design.
Retry policy: retry 503 with backoff and the same idempotency key. Do not retry 409, 422,
428, 501 or any 4xx authority error without changing the request.
{
"error_code": "STATE_CONFLICT",
"message": "The transaction cannot be authorized in its current state",
"correlation_id": "4f6d2a90-6c1b-4d7e-9a51-8f0e2c3b7a44",
"audit_event_id": null,
"details": []
}
Worked example: quote, create, authorise
Two different operators are required. The actor who creates a transaction may not authorise it.
1. Confirm what the operator may initiate
Returns the actor's initiation-eligible agents, enabled corridors, ready senders and verified beneficiaries. Senders and beneficiaries are masked labels only. An empty context is a valid answer, not an error — it means the actor currently has no eligible agent or corridor.
2. Create a quote
Fee and rate are returned with the quote and are binding for its lifetime. Send
corridors are configured from CAD, paying out in PHP, INR, NGN, KES, MXN, BRL, USD,
EUR and GBP. Corridor and method availability is returned by the API — do not hard-code it.
Supplying ordering_entity_id is optional but recommended: when present, the server enforces that the
actor may initiate for that agent and that the corridor is enabled, returning 422 CORRIDOR_UNAVAILABLE if not. Omitting it defers those checks to step 3.
Possible: 200 (idempotent replay), 201, 400, 401, 403 QUOTE_CREATION_FORBIDDEN, 409 IDEMPOTENCY_KEY_REUSE, 422 CORRIDOR_UNAVAILABLE, 422 AMOUNT_TOO_LARGE.
3. Create the transaction
The quote must be unexpired (15 minutes), accessible to the actor, and its payout method must match.
RECEIVED means recorded. No authorisation, screening or settlement has happened.
Possible: 200 (replay), 201, 400, 401, 403 OBJECT_ACCESS_FORBIDDEN, 404 QUOTE_NOT_FOUND,
409 QUOTE_EXPIRED | QUOTE_MISMATCH | RAIL_UNAVAILABLE | IDEMPOTENCY_KEY_REUSE, 503 LIMIT_POLICY_UNAVAILABLE.
Keep row_version from this response. You need it next.
4. Authorise — by a different operator
If-Match is the current row_version as a plain positive integer, not an ETag string. It is
mandatory here despite the published contract marking it optional.
There is no request body.
Cleared — every party screened CLEAR and no hard limit was breached:
Held for compliance — any party did not screen CLEAR, including because screening was
unavailable:
A compliance case is opened at the same time: risk MEDIUM with reason SCREENING_UNAVAILABLE if a
provider was unreachable, risk HIGH with reason SCREENING_POSSIBLE_MATCH otherwise. The case is
not returned in this response; find it with GET /v1/compliance/cases.
Missing If-Match:
Hard limit breach:
Full set: 200, 202, 400 INVALID_TRANSACTION_ID | IDEMPOTENCY_KEY_REQUIRED, 401, 403 SEPARATION_OF_DUTIES_REQUIRED, 404 TRANSACTION_NOT_FOUND, 409 STATE_CONFLICT | VERSION_CONFLICT | RAIL_UNAVAILABLE | LIMIT_EXCEEDED | IDEMPOTENCY_KEY_REUSE, 428 IF_MATCH_REQUIRED, 503 LIMIT_POLICY_UNAVAILABLE.
Soft limit breaches do not block. They are recorded on the audit event and are not signalled in the response.
5. Read state
External states are RECEIVED, PROCESSING, AUTHORIZED, REJECTED, PAYOUT_PENDING, PAID_OUT,
SETTLED, CANCELLED, RETURN_PENDING, RETURNED, REVERSED. The internal states VALIDATED,
LIMITS_RESERVED, SCREENING_PENDING and HELD_COMPLIANCE are all collapsed to PROCESSING, and
status_message is present only then. You cannot distinguish "held for compliance" from other
in-flight states through this API, by design.
GET /v1/transactions pages with ?state=, ?cursor= and ?limit= (default 25, maximum 100).
Results are scoped to the actor's offices.
GET /v1/remittance-context HTTP/1.1
Host: payzone.io
Authorization: Bearer <token with quotes:create>
POST /v1/quotes HTTP/1.1
Host: payzone.io
Authorization: Bearer <token with quotes:create>
Content-Type: application/json
Idempotency-Key: 3f7c1d02-9a4b-4c6e-8d51-2b0e7a9f4c33
X-Correlation-ID: 4f6d2a90-6c1b-4d7e-9a51-8f0e2c3b7a44
{
"ordering_entity_id": "5e0a1c88-7b64-4a1f-9d2c-31f0b7ae4c12",
"origin_country": "CA",
"destination_country": "PH",
"send_amount": { "amount_minor": 50000, "currency": "CAD" },
"payout_currency": "PHP",
"payout_method": "CASH_PICKUP"
}
HTTP/1.1 201 Created
{
"quote_id": "b81e4f27-5c3a-4d90-a1f6-7e2c8b40d915",
"status": "ACTIVE",
"send_amount": { "amount_minor": 50000, "currency": "CAD" },
"fee_amount": { "amount_minor": 600, "currency": "CAD" },
"total_amount": { "amount_minor": 50600, "currency": "CAD" },
"payout_amount": { "amount_minor": 2090000, "currency": "PHP" },
"exchange_rate": 41.8,
"rate_provider": "DEMO_FIXED",
"expires_at": "2026-08-03T14:15:00.000Z",
"correlation_id": "4f6d2a90-6c1b-4d7e-9a51-8f0e2c3b7a44"
}
POST /v1/transactions HTTP/1.1
Host: payzone.io
Authorization: Bearer <token with remittances:create>
Content-Type: application/json
Idempotency-Key: 8c25a9b7-1f6d-4e03-9b8a-5d7c1e0a3f68
X-Correlation-ID: 4f6d2a90-6c1b-4d7e-9a51-8f0e2c3b7a44
{
"quote_id": "b81e4f27-5c3a-4d90-a1f6-7e2c8b40d915",
"ordering_entity_id": "5e0a1c88-7b64-4a1f-9d2c-31f0b7ae4c12",
"sender_party_id": "1a9d3e57-2b04-4c8f-90a3-6e5b7c2d1f80",
"receiver_party_id": "72b6c4e1-8d05-4f3a-b927-0c4e6a1d5738",
"channel": "AGENT",
"funding_method": "INTERAC",
"payout_method": "CASH_PICKUP",
"purpose_code": "FAMILY_SUPPORT"
}
HTTP/1.1 201 Created
{
"transaction_id": "d40b17c9-3e82-4a56-b1d7-9f38c0e6a274",
"state": "RECEIVED",
"row_version": 1,
"audit_event_id": "0b7e5c31-4a29-4d68-8f10-c3a6b9e2d457",
"correlation_id": "4f6d2a90-6c1b-4d7e-9a51-8f0e2c3b7a44"
}
POST /v1/transactions/d40b17c9-3e82-4a56-b1d7-9f38c0e6a274/authorize HTTP/1.1
Host: payzone.io
Authorization: Bearer <token with remittances:authorize, different operator>
Idempotency-Key: 5b91e6d4-7c2a-4038-8e15-a0f3b7c92d61
If-Match: 1
X-Correlation-ID: 4f6d2a90-6c1b-4d7e-9a51-8f0e2c3b7a44
HTTP/1.1 200 OK
{
"transaction_id": "d40b17c9-3e82-4a56-b1d7-9f38c0e6a274",
"state": "AUTHORIZED",
"row_version": 2,
"audit_event_id": "e5c8a013-9b7d-4e26-a4f1-70b2d6c85391",
"correlation_id": "4f6d2a90-6c1b-4d7e-9a51-8f0e2c3b7a44"
}
HTTP/1.1 202 Accepted
{
"transaction_id": "d40b17c9-3e82-4a56-b1d7-9f38c0e6a274",
"state": "PROCESSING",
"status_message": "Your transfer is being processed. We will notify you when it is complete.",
"row_version": 2,
"audit_event_id": "e5c8a013-9b7d-4e26-a4f1-70b2d6c85391",
"correlation_id": "4f6d2a90-6c1b-4d7e-9a51-8f0e2c3b7a44"
}
HTTP/1.1 428 Precondition Required
{
"error_code": "IF_MATCH_REQUIRED",
"message": "If-Match row version is required",
"correlation_id": "4f6d2a90-6c1b-4d7e-9a51-8f0e2c3b7a44",
"audit_event_id": null,
"details": []
}
HTTP/1.1 409 Conflict
{
"error_code": "LIMIT_EXCEEDED",
"message": "The transfer exceeds an applicable remittance limit",
"correlation_id": "4f6d2a90-6c1b-4d7e-9a51-8f0e2c3b7a44",
"audit_event_id": "7a1c9f35-2d68-4b07-91ae-4c6d0b3e8215",
"details": [
{
"policy_id": "c3e58a20-6f14-4d9b-8207-15b9e6a4c703",
"metric_type": "AMOUNT_MINOR",
"limit_value": 1000000,
"projected_value": 1050000
}
]
}
GET /v1/transactions/d40b17c9-3e82-4a56-b1d7-9f38c0e6a274 HTTP/1.1
Authorization: Bearer <token with remittances:read>
Resolving a compliance hold
Held transfers are released or returned through the case, never by retrying authorize.
201 returns intent_id, case_status and expires_at (5 minutes), and claims the case for the
actor. Then re-authenticate with WebAuthn for that intent_id and submit the same draft plus
step_up_intent_id to POST /v1/compliance/cases/{caseId}/decision with compliance:decide, the
same If-Match and a new Idempotency-Key.
Outcome depends on the action and the case risk:
200— decided.RELEASEcloses the case and moves the transaction toAUTHORIZED;REJECT_RETURNcloses it and moves the transaction toREJECTED, releasing reserved limits;ESCALATE_TO_MLROroutes to the main agent's MLRO;REQUEST_INFORMATIONandREQUIRE_ENHANCED_DUE_DILIGENCEsetPENDING_INFORMATION.202withstatus: "PENDING_DUAL_APPROVAL"and ahigh_risk_request_id— the action needs a second person. This is required forFILE_SAR_STR,FREEZE_ASSETS, andRELEASEon aHIGHorCRITICALcase. The maker's step-up is consumed; the request expires in 5 minutes. A second actor then callsPOST /v1/compliance/high-risk-requests/{requestId}/decision-intentsfollowed by.../decision, each with their own step-up. Maker and checker must be different actors.
Which actions a role may take is capped by the token:
| Role | Permitted actions |
|---|---|
Middle Office Analyst, Compliance Analyst |
REQUEST_INFORMATION, REQUIRE_ENHANCED_DUE_DILIGENCE, ESCALATE_TO_MLRO |
Senior Compliance Officer |
the above plus RELEASE, REJECT_RETURN, FREEZE_ASSETS |
Agent MLRO, MLRO |
all seven, including FILE_SAR_STR |
FILE_SAR_STR additionally requires active SAR compartment membership. Note that internal dual
approval of a SAR/STR is an internal control only: every response carries
external_regulator_submission: false. Nothing on this API files with a regulator.
POST /v1/compliance/cases/{caseId}/decision-intents
Authorization: Bearer <token with compliance:step-up>
Idempotency-Key: <key>
If-Match: <case row_version>
{
"action": "RELEASE",
"reason_code": "SCREENING_FALSE_POSITIVE",
"rationale": "Name match resolved against date of birth and place of issue.",
"evidence_refs": ["evidence://case-notes/2026-08-03/1"]
}
Trusted-gateway channel ingress
POST /v1/channel-ingress/authorize admits signed order metadata from registered POS, ISO 8583
gateway, kiosk, teller, web and mobile channels. Admission is not authorisation, settlement or
network dispatch; a 202 ADMITTED receipt means only that a signed record was accepted.
The service does not terminate client mTLS. A separately operated gateway must validate the client certificate and forward its fingerprint inside the signed assertion. The body is a strict metadata envelope; PAN, track data, PIN blocks, CVV, customer identity and arbitrary provider payloads are rejected and never persisted, and client/device identifiers are reduced to SHA-256 digests before they reach the database.
The call still requires a normal OAuth token with remittances:create, plus seven headers:
| Header | Format |
|---|---|
Idempotency-Key |
UUID |
X-Correlation-ID |
UUID, required here |
X-Channel-Nonce |
UUID, single-use per identity |
X-Channel-Asserted-At |
RFC 3339 with offset; accepted from 5 minutes old to 30 seconds ahead |
X-Client-Certificate-Sha256 |
64 lowercase hex characters |
X-Gateway-Key-Id |
3–100 chars, [A-Za-z0-9_-] |
X-Gateway-Signature |
64 lowercase hex characters, HMAC-SHA256 |
The signature covers a canonical JSON object with keys sorted lexicographically:
Responses: 202 with the receipt, 400 INVALID_CHANNEL_REQUEST, 401 CHANNEL_AUTHENTICATION_FAILED
or CHANNEL_REPLAY_WINDOW_REJECTED, 409 CHANNEL_IDEMPOTENCY_CONFLICT, 429 CHANNEL_SAFETY_LIMIT_EXCEEDED (per-channel amount ceiling or per-minute receipt ceiling), 503 TRUSTED_GATEWAY_UNAVAILABLE or CHANNEL_CONTROL_UNAVAILABLE.
This boundary is not certified for production use. Gateway ownership, CA and trust-store policy, certificate issuance and revocation, HMAC key custody and rotation, ISO 8583 field allowlisting, penetration testing and end-to-end replay/failover testing are all outstanding.
const body_sha256 = sha256(canonicalJson(body)); // recursively key-sorted JSON
const canonical = canonicalJson({
asserted_at: "2026-08-03T14:02:11.000Z",
body_sha256,
certificate_fingerprint_sha256: "…64 hex…",
correlation_id: "…uuid…",
gateway_key_id: "pos-gateway-key-1",
idempotency_key: "…uuid…",
method: "POST",
nonce: "…uuid…",
path: "/v1/channel-ingress/authorize"
});
const signature = hmacSha256Hex(gatewayKey, canonical);
Operational reads
Operational collections return metadata only — never customer identity or contact data, vault or account references, credential material, evidence hashes, instruction hashes or provider payloads.
All accept limit (default 25, max 100) and after=<uuid>, and return
{ "items": [...], "next_cursor": "<uuid>|null" }. Agent-scoped reads require agent_id=<uuid>;
GET /v1/cpo/resilience/evidence additionally requires kind=BACKUP|INTEGRITY|DRILL.
An unauthorised agent lookup returns 404, so that the API cannot be used to enumerate agents. A
missing table, view, column or authorisation function returns 503 CONTROL_READ_MODEL_UNAVAILABLE — an unavailable read model is never rendered as an empty queue.
For regulator filings, interpret states strictly: QUEUED and CLAIMED are not filed, SUBMITTED is
not acknowledged, UNKNOWN_OUTCOME and RECONCILIATION_REQUIRED require recovery, and only
ACKNOWLEDGED evidences an acknowledgement.
Webhooks
Payzone does not send webhooks. There is no outbound webhook subsystem, no subscription or
endpoint-registration surface, no signing key issued to integrators and no delivery or retry
machinery for integrator callbacks. The only outbound notification channels the platform operates are
SMS and EMAIL, addressed to end parties. Poll GET /v1/transactions and GET /v1/compliance/cases for state.
The /v1/webhooks/* paths are inbound provider boundaries — they exist so that Payzone can
receive signed events from its own vendors. They are not OAuth routes and they are not for
integrator use. They are documented here so their behaviour is not mistaken for an outbound feed, and
because the verification each performs is the standard a receiver should hold itself to.
| Path | Verification performed before parsing |
|---|---|
POST /v1/webhooks/entrust |
x-sha2-signature: HMAC-SHA256 over the exact raw body, hex, compared in constant time. Body is read as a buffer and only parsed after the signature passes. Non-matching or malformed → 401, no body |
POST /v1/webhooks/amazon-connect |
X-Payzone-Timestamp (10-digit Unix seconds, ±5 minutes) and X-Payzone-Signature: HMAC-SHA256 over timestamp + "." + rawBody, constant-time. Then the instance_id and 12-digit aws_account_id must match the configured provider boundary (403 PROVIDER_BOUNDARY_MISMATCH), and occurred_at must be within −5/+15 minutes (400 STALE_PROVIDER_EVENT) |
POST /v1/webhooks/postmark-events |
HTTP Basic credentials held in the callback URL, compared in constant time. Postmark does not sign its payloads, so this is weaker than the signature checks above; the route is correspondingly narrow, recording a delivery outcome for a message the platform already knows it sent and reconciling anything else |
POST /v1/webhooks/twilio/message-status |
The Twilio request signature over the canonical callback URL and sorted form fields |
If you are building a receiver for any signed callback, the pattern to copy is the one used throughout: read the raw bytes, verify the MAC in constant time before parsing, bind a timestamp or nonce to reject replay, then check the sender's account boundary, and only then act.
Note that of these four, only the Entrust and Postmark routes are wired into the running application. See below.
Availability
Not every capability is enabled on every deployment, and some are gated behind commercial or regulatory certification.
- Environment gating. Certain routes are enabled per environment. Treat a
404on a documented path as "not enabled here" and confirm the surface for your environment during onboarding rather than assuming it from this document. - Provider-dependent routes. Endpoints backed by an external provider return
503until that provider is configured for your deployment. This is deliberate: the platform fails closed rather than proceeding without a control. - Credentials. Access is issued during onboarding. There is no self-service signup, and scopes are bound to what your entity is authorised to do.
If a capability you need is not described here, ask — the roadmap is shaped by integrator requirements.