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:

  1. Bearer syntax. The header must match Bearer <token>. A missing or malformed header is normalised to 401 with error_code: UNAUTHORIZED and a WWW-Authenticate: Bearer header.
  2. Token verification. The signature is verified offline with pinned public Cognito JWKS. token_use must be access; issuer, client id and pool must match exactly, and token lifetime must not exceed five minutes.
  3. Operational authority. Caller-supplied roles, scopes, permissions and public metadata are ignored. Effective authority is derived from current database events.
  4. Coarse group. cognito:groups must contain exactly one of payzone-cpo or payzone-member. Additional or missing groups are denied.
  5. 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.

  1. 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.

  2. 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_level AAL3
    https://remit.payzio.com/authentication_method webauthn-platform or webauthn-roaming
    https://remit.payzio.com/authenticated_at Unix seconds, at most 300s old
    https://remit.payzio.com/step_up_intent_id the exact intent_id
  3. Submit the decision (compliance:decide) with the same step_up_intent_id in the body, the same If-Match row 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 returns 201 for creates. Do not treat 201 as 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_EXCEEDED from an authorisation is persisted against the key and replayed as 409, not 200. 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. RELEASE closes the case and moves the transaction to AUTHORIZED; REJECT_RETURN closes it and moves the transaction to REJECTED, releasing reserved limits; ESCALATE_TO_MLRO routes to the main agent's MLRO; REQUEST_INFORMATION and REQUIRE_ENHANCED_DUE_DILIGENCE set PENDING_INFORMATION.
  • 202 with status: "PENDING_DUAL_APPROVAL" and a high_risk_request_id — the action needs a second person. This is required for FILE_SAR_STR, FREEZE_ASSETS, and RELEASE on a HIGH or CRITICAL case. The maker's step-up is consumed; the request expires in 5 minutes. A second actor then calls POST /v1/compliance/high-risk-requests/{requestId}/decision-intents followed 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_UNAVAILABLEan 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 404 on 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 503 until 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.