Moja Switch Partner API (1.0.0-draft)
Download OpenAPI specification:Download
B2B payment infrastructure API for cross-border corridors.
Examples use the sandbox corridor Canada → Tanzania (CA-TZ, CAD → TZS).
Implementation status is part of the contract. An operation or
property marked x-moja-status: planned (summary prefixed [Planned], or
🔲 in its description) is published target contract that the deployed
platform does not serve or emit yet. Everything unmarked is live: an
automated check keeps this file and the deployed API in step. Requests
with unknown fields are rejected with 400 invalid_request, so do not
send a planned request property. Error codes are catalogued at
https://docs.mojaswitch.com/guides/errors/.
Corridors are data, not a fixed list. What a given tenant may actually
use is per-account entitlement: read GET /corridors/{corridor_code} (the
list, GET /corridors, is planned) and treat the corridor
code, its currencies and their minor-unit exponents as server-supplied.
Naming a corridor here is an example, not a statement of what is live
for you and not a constraint in the contract — the schemas do not enumerate corridors, and a
client that hardcodes one has to be re-released to gain another.
Currencies differ in minor-unit exponent (zero-decimal currencies
exist). No client may assume two decimal places; see MinorUnitAmount and
GET /corridors/{corridor_code}.
How FX is priced depends on your tenant's FX model, set by
Moja Switch per tenant — see POST /payments.
- BYO-FX. You supply the send→receive rate and the exact
receive (payout) amount; Moja executes that instruction and adds no FX
margin. Responses echo your locked
fx_rateandreceive_amount. - Moja Switch FX. You pay on a single-use
quote_idfromPOST /quotes: the quote is priced from the Moja Switch FX reference rate plus your tenant's own agreed margin, and the platform locks that price exactly. Moja Switch serves a reference rate; it is not the underlying FX counterparty.
Off-production, a BYO-FX payment that omits fx on a corridor with a
configured sandbox rate gets a Moja-derived rate as a dev convenience
(see FX schema); that is never production pricing.
Collections (kind: collection) are sandbox-only. See
POST /payments and the Payouts tag. Production collections and tenant
payouts are not offered yet.
Versioning. The API is versioned by URL major (/v1). This contract
is a pre-launch draft (1.0.0-draft) and may still change before the
first partner goes live. After that, a breaking change is published as a
new major version (/v2) with a deprecation window; it is never made
silently to /v1.
Create FX quote
Prices an FX quote for a corridor and returns the resulting
receive_amount and all-in fx_rate. Pre-step before payment creation.
Who prices is a property of your tenant, not of the request. Moja Switch holds your FX model in your tenant commercial configuration, and your commercial terms (your margin) per corridor — yours alone, independent of any other tenant on the same corridor. The two models behave differently here:
| Your FX model | Who sets the rate | What you send | Margin |
|---|---|---|---|
| Moja Switch FX | Moja Switch: its own validated reference rate, less your commercial margin for this corridor | send_amount or receive_amount; no fx |
Applied by Moja Switch, inside the rate |
| BYO-FX | You: your own FX source, rate and pricing | send_amount and an inline fx |
None added by Moja Switch |
Moja Switch FX
- Send exactly one of
send_amount(what the sender pays) orreceive_amount(what the recipient must get). Withreceive_amountthe quote'sreceive_amountequals the request exactly andsend_amountis the smallest amount that covers it;fx_rateis then the effective rate (receive ÷ send, floored at 8 places, never above the margin-implied rate). - Do not send
fx— anyfxkey, even{}ornull→422 fx_not_accepted. A Moja Switch FX tenant does not bring a rate. - Moja Switch reads its own reference rate. If it has no valid, fresh
rate, the quote is refused with
503 fx_unavailable(never priced from a cached or indicative rate). Otherwise it applies your margin for the quoted corridor, prices with integer arithmetic (each currency at its own minor-unit exponent; CAD and TZS two digits), persists the quote and returns it. - Show your end user only
send_amountandfx_rate. The margin is already insidefx_rate. The response'sfx_provenanceandfees.fx_markupare for your systems, never for an end user;disclosure.total_costis a figure, never a percentage. - A quote is single-use and valid until
expires_at. Pay on it withPOST /paymentsquote_id— see that operation. Re-quote rather than extending it client-side.
BYO-FX
BYO-FX rules:
- Production requires
fxwith bothfx.rateandfx.receive_amount, and so does any corridor with no sandbox rate, in every environment. Omitting it →422 fx_required. fx.receive_amountis authoritative (paid verbatim);fx.rateis the declared basis (recorded, consistency-checked, not used to recompute the payout).- Consistency rule:
|receive_amount − round(send_amount × rate)| ≤ 1 minor unit, else422 fx_inconsistent. Values that are not positive, not valid for the corridor, or too precise →422 validation_errorwithdetails[].fieldnaming thefxfield. - Sandbox only: when
fxis omitted off-production on a corridor with a configured sandbox rate (CA-TZ), Moja derives an indicative rate as a dev convenience — never production pricing. This fallback does not exist on the production server.
Scope: write.
Cost disclosure is part of the response, not an afterthought. Every
quote carries a required disclosure object with the total cost as a
figure in the send currency and the rate basis behind it. A client
cannot render a quote without it; see
QuoteDisclosure.
No rate available → 503 fx_unavailable (Moja Switch FX). This
can be an expected state, not an incident: a reference rate that is
stale, unvalidated or outside its deviation band is refused rather than
used. Render the no-quote state and retry later; no return time is
given. Never fall back to a cached or indicative rate — a displayed
rate is a rate a customer expects to receive.
Platform-priced quotes (Moja Switch FX). A quote Moja Switch priced
returns pricing_mode: platform_priced, the all-in fx_rate, the
expires_at it is good until, a disclosure (the total cost as a figure
in the send currency) and fx_provenance — the source rate, the provider,
the rate timestamp, the pricing-rule version, and whether the underlying
rate was indicative or executable — so an integrator (and an auditor)
can answer "where did this number come from".
This is available only to tenants configured for Moja Switch FX. For
every other tenant, production still requires fx (422 fx_required) and pricing_mode is client_provided: Moja Switch does not
price a BYO-FX client's customers and adds no margin to their rate.
receive_amount is accepted only for a Moja Switch FX tenant.
Errors specific to Moja Switch FX: 422 fx_not_accepted (you sent
fx), 422 commercial_terms_not_configured (no commercial terms are
configured for you on this corridor — there is no default margin; ask
Moja Switch to configure them), 422 amount_below_minimum (the amount delivers nothing at this
rate), 422 transaction_limit_exceeded / 422 amount_out_of_range (the
same ceilings a payment is held to), 503 fx_unavailable (no valid,
fresh reference rate — retry; never display a price).
Authorizations:
Request Body schema: application/jsonrequired
| corridor required | string (Corridor) ^[A-Z]{2}-[A-Z]{2}$ Corridor on the transacting routes. This was a closed enum
( It had to change. A closed enum in the contract is the same defect as a hardcoded corridor in a client: the corridor set is per-tenant entitlement data, it moves, and an enum makes every move a contract change and a client release. Published as an enum, a quote on any newer corridor was a schema violation rather than an entitlement question — which is not something the contract should have an opinion about. Entitlement is the authority on what a given tenant may actually
use ( |
| receive_currency required | string^[A-Z]{3}$ ISO 4217 payout currency. Was a closed enum ( |
| payout_method required | string (PayoutMethod) Enum: "MOBILE_MONEY" "BANK_TRANSFER" |
object (Money) Decimal-string amount, used by the original partner operations ( | |
object Moja Switch FX only. What the recipient must get, in the corridor's receive currency (whole units for a zero-decimal currency). The quote delivers exactly this amount and prices the smallest | |
| end_user_id | string Accepted so a client can send the same identity on the quote and the payment; not used to price today (a quote is not end-user-specific). |
object Client-provided FX (BYO-FX tenants only). REQUIRED in production for a BYO-FX tenant (else |
Responses
Response Schema: application/json
| id required | string |
| corridor required | string (Corridor) ^[A-Z]{2}-[A-Z]{2}$ Corridor on the transacting routes. This was a closed enum
( It had to change. A closed enum in the contract is the same defect as a hardcoded corridor in a client: the corridor set is per-tenant entitlement data, it moves, and an enum makes every move a contract change and a client release. Published as an enum, a quote on any newer corridor was a schema violation rather than an entitlement question — which is not something the contract should have an opinion about. Entitlement is the authority on what a given tenant may actually
use ( |
required | object (Money) Decimal-string amount, used by the original partner operations ( |
required | object The locked payout amount. Echoes the partner's |
| fx_rate required | string The partner's locked send→receive rate, echoed back (receive units per 1 send unit). Not a Moja-computed or marked-up rate. Off-production, when the partner omitted |
required | object (QuoteFees) Quote cost breakdown, always denominated in the SEND currency (what the
quote costs the sender). Both fields are always present and may be zero
amounts. |
required | object Required. The cost disclosure for this quote. Required so that a client cannot render a quote without it. |
| expires_at required | string <date-time> When the locked rate stops being valid. A |
| created_at required | string <date-time> |
| pricing_mode | string Enum: "client_provided" "platform_priced" Who priced this quote. Absent on quotes issued before this field existed; treat an absent value as |
object Where the rate came from. Present only when |
Request samples
- Payload
{- "corridor": "CA-TZ",
- "send_amount": {
- "value": "100.00",
- "currency": "CAD"
}, - "receive_amount": {
- "value": "100.00",
- "currency": "CAD"
}, - "receive_currency": "TZS",
- "payout_method": "MOBILE_MONEY",
- "end_user_id": "eu_7Qm4xK2pLd",
- "fx": {
- "rate": "1850.25",
- "receive_amount": {
- "value": "100.00",
- "currency": "CAD"
}, - "rate_id": "dealer-quote-7f3a91"
}
}Response samples
- 201
- 400
- 401
- 403
- 422
- 503
{- "id": "quote_xyz789",
- "corridor": "CA-TZ",
- "send_amount": {
- "value": "100.00",
- "currency": "CAD"
}, - "receive_amount": {
- "value": "100.00",
- "currency": "CAD"
}, - "fx_rate": "1850.25",
- "fees": {
- "platform_fee": {
- "value": "100.00",
- "currency": "CAD"
}, - "fx_markup": {
- "value": "100.00",
- "currency": "CAD"
}
}, - "pricing_mode": "client_provided",
- "fx_provenance": {
- "source_rate": "1850.25",
- "provider": "rate_source_a",
- "rate_timestamp": "2019-08-24T14:15:22Z",
- "rule_version": "pricing-2026-09-01",
- "rate_type": "indicative"
}, - "disclosure": {
- "total_cost": {
- "amount": 10000,
- "currency": "CAD",
- "minor_unit_exponent": 2
}, - "rate_basis": "SOURCE_RATE_NO_MARKUP",
- "fees_total": {
- "amount": 10000,
- "currency": "CAD",
- "minor_unit_exponent": 2
}, - "fx_margin": {
- "amount": 10000,
- "currency": "CAD",
- "minor_unit_exponent": 2
}
}, - "expires_at": "2019-08-24T14:15:22Z",
- "created_at": "2019-08-24T14:15:22Z"
}Create payment
Initiates a cross-border payment. Requires Idempotency-Key header. Target response time: < 30s for sync rails (mobile money B2C).
How the payment is priced depends on your tenant's FX model.
- Moja Switch FX: pay on a
quote_idfromPOST /quotesand omitfx(anfx→422 fx_not_accepted, or400 invalid_requestif it is malformed; noquote_id→422 quote_required). Moja Switch locks that quote's price exactly — it never re-prices. The quote must be yours, priced by Moja Switch FX in this environment, matching this payment, unexpired and not already used: a quote is single-use, consumed atomically with the payment, so a second payment on it →422 quote_invalidand an expired one →422 quote_expired. A retry with the sameIdempotency-Keyreturns the original payment and never touches the quote. - BYO-FX: the partner sets the FX; Moja executes it (below). Moja Switch adds no margin.
Either way the response carries fx_provenance — see Payment.
BYO-FX. The partner sets the FX; Moja executes
it. Supply the locked FX inline via the fx object. The response
echoes the partner's fx_rate and receive_amount (their values;
no Moja markup).
- Production requires inline
fx(with bothfx.rateandfx.receive_amount), and so does any corridor with no sandbox rate, in every environment; a request without it →422 fx_required. Aquote_iddoes not satisfy this. fx.receive_amountis authoritative (paid verbatim);fx.rateis the declared basis, consistency-checked againstsend_amount:|receive_amount − round(send_amount × rate)| ≤ 1 minor unit, else422 fx_inconsistent.- Values must be positive, valid for the corridor and within precision
(
422 validation_error,details[].fieldnaming thefxfield). - Sandbox only: with
fxomitted off-production on a corridor with a configured sandbox rate (CA-TZ), Moja applies a derived rate as a dev convenience — never production pricing.
Compliance is not an HTTP error here. Screening runs after the
payment is persisted. A block (a review decision is treated as a block),
including a missing or invalid required sender_kyc attestation,
returns 201 with status: FAILED and failure.code: compliance_blocked; a screening outage returns 201 FAILED with
failure.code: compliance_error. See PaymentFailure.
Scope: write.
The originator: sender, always. For a push collection it is the
inline Party. For funding_method: INTERAC_REQUEST it is an
InteracSender (email, name): the person the Interac request is sent
to.
The beneficiary: payout, or a saved recipient. A saved recipient
is named by recipient_id together with end_user_id (both or neither,
else 422 validation_error). Without payout, the platform builds the
payout from the screened recipient record, so the client restates none
of it. With payout as well, its beneficiary phone must equal the saved
phone (422 recipient_phone_mismatch). Before any collection:
404 end_user_not_found, 404 recipient_not_found (unknown, or another
end user's), 422 recipient_not_approved (screening not passed),
422 recipient_not_payable (not a mobile-money recipient in the
corridor's payout country).
The end user must be able to send. A payment naming an end user is
refused with 422 end_user_not_permitted unless that end user's
account_status is created and its transact_authorization is
authorized — with or without sender_kyc. An attestation says who the
sender is; it never lets a suspended, closed or unauthorized account send.
details[0].message names the refusing state (for example
account_status is suspended). Nothing is created, so the same
Idempotency-Key may be retried once the end user is restored. An
idempotent retry of a payment created before a suspension still returns
that payment.
FX. fx and quote_id are mutually exclusive (422 validation_error).
Tenant controls. Moja Switch must have enabled your tenant for the
corridor in this environment (403 tenant_not_activated; a contract or
an entitlement alone is not enablement), and the payment must fit your
tenant limit profile for the send currency (422 tenant_limit_exceeded,
details[0].message names the limit: per_transfer,
rolling_24h_amount, rolling_24h_count or rolling_30d_amount). A
profile with a blank limit refuses every payment
(403 tenant_limits_unresolved): a blank is never read as unlimited.
Nothing is created on any of these, so the same Idempotency-Key may be
retried once Moja Switch has changed the decision; an idempotent retry of
a payment created earlier still returns it.
Idempotency: a retried create with the same Idempotency-Key and body
returns the original payment (at-least-once delivery, exactly-once
effect) — the client-locked fx_rate/receive_amount never change on
replay.
Idempotency namespace: (partner_id, key). end_user_id does not
narrow it. A tenant that sends on behalf of many end users must keep keys
unique across all of them (for example, derive the key from the end user
and the client's own attempt key).
Generate the key (UUIDv4, one per transfer attempt) and persist it before the first attempt, so a retry after process death reuses it.
kind (optional): transfer or collection. Absent means
transfer, and a transfer request behaves exactly as it does without
kind; "kind": "transfer" and no kind are the same request for
idempotency. kind: collection is sandbox-only today:
outside a sandbox-serving instance it is refused with
403 product_not_available before the body is validated. On a sandbox
instance it is validated (422 validation_error naming each field) and
then requires an operator-activated collection product for that tenant
(403 tenant_not_activated when missing). With activation, Create returns
201 and the payment advances through Interac sandbox collection to
COLLECTED, then Moja Switch schedules a tenant CAD payout
(GET /v1/payouts). Production collections are not offered yet.
Any other kind value is 422 validation_error. A collection is not
corridor-checked (it has no corridor; one that names corridor anyway
is checked and refused unless you are entitled to it) and needs the
write scope. With a production key every collection is
403 product_not_available, whatever its fields. A collection's rules are conditional and
enforced by the server, so the schema below describes a transfer;
see kind for what a collection requires and forbids.
Authorizations:
header Parameters
| Idempotency-Key required | string <uuid> Example: 550e8400-e29b-41d4-a716-446655440000 UUIDv4, one per payment attempt, persisted by the client BEFORE the first attempt and reused on every retry of that attempt. Must be a UUID (else |
Request Body schema: application/jsonrequired
required | object The beneficiary and payout method, inline. Required unless a saved recipient ( |
| corridor required | string (Corridor) ^[A-Z]{2}-[A-Z]{2}$ Corridor on the transacting routes. This was a closed enum
( It had to change. A closed enum in the contract is the same defect as a hardcoded corridor in a client: the corridor set is per-tenant entitlement data, it moves, and an enum makes every move a contract change and a client release. Published as an enum, a quote on any newer corridor was a schema violation rather than an entitlement question — which is not something the contract should have an opinion about. Entitlement is the authority on what a given tenant may actually
use ( |
| reference required | string <= 128 characters Partner's unique reference for this payment |
required | object (Money) Decimal-string amount, used by the original partner operations ( |
required | Party (object) or InteracSender (object) The originator. A |
| purpose required | string (PaymentPurpose) Enum: "FAMILY_SUPPORT" "SCHOOL_FEES" "UTILITY_BILL" "MERCHANT_PAYMENT" "OTHER" |
| kind | string Default: "transfer" Enum: "transfer" "collection" What the payment does. Omit it, or send |
| payer_id | string [ 1 .. 128 ] characters
|
| destination_country | string^[A-Z]{2}$
|
object
| |
| quote_id | string Moja Switch FX tenants: required. The |
object Client-provided FX (BYO-FX tenants only). Mutually exclusive with | |
| funding_method | string^[A-Z][A-Z0-9_]{1,31}$ How the sender intends to pay. For |
| recipient_id | string^rcp_[a-zA-Z0-9]+$ A screened recipient from this end user's book. Requires |
| end_user_id | string^eu_[a-zA-Z0-9]+$ The end user whose saved recipient is being paid. Required with, and only with, |
object (SenderKYCAttestation) Partner-supplied sender KYC attestation (the partner-attestation model: the partner attests sender KYC is complete rather than Moja Switch re-running KYC). Optional at the API boundary, but effectively REQUIRED when the
corridor requires attestation, which is the default. When required and the attestation is absent, not
marked The identity the attestation covers is not repeated here. For a
transfer, it is | |
object <= 20 properties |
Responses
Response Schema: application/json
| id required | string |
| status required | string (PaymentStatus) Enum: "CREATED" "VALIDATING" "COMPLIANCE_CHECK" "FX_LOCKED" "ROUTING" "COLLECTING" "CANCEL_REQUESTED" "COLLECTED" "PAYOUT_SCHEDULED" "PAID_OUT" "REVERSED" "PAYOUT_PENDING" "PAYOUT_SENT" "PROCESSING" "SETTLED" "COMPLETED" "RECONCILED" "FAILED" "CANCELLED" Partner-facing payment lifecycle status. PROCESSING is a safe generic "in progress" value: the platform presents any internal-only substate (e.g. a compensation state) that is not one of the enumerated lifecycle stages as PROCESSING, so a partner never observes a raw internal state. For a transfer, PROCESSING is non-terminal and transient: a subsequent poll or webhook will report a terminal status. Clients should treat any unrecognised value as a non-terminal state.
Collection statuses are published but not emitted yet. |
| reference required | string |
required | object (Money) Decimal-string amount, used by the original partner operations ( |
| created_at required | string <date-time> |
| updated_at required | string <date-time> |
| corridor | string (Corridor) ^[A-Z]{2}-[A-Z]{2}$ Corridor on the transacting routes. This was a closed enum
( It had to change. A closed enum in the contract is the same defect as a hardcoded corridor in a client: the corridor set is per-tenant entitlement data, it moves, and an enum makes every move a contract change and a client release. Published as an enum, a quote on any newer corridor was a schema violation rather than an entitlement question — which is not something the contract should have an opinion about. Entitlement is the authority on what a given tenant may actually
use ( |
object The locked payout amount, echoing the partner's client-provided FX (BYO-FX). Present once FX is locked. | |
| fx_rate | string The partner's locked send→receive rate, echoed back (not Moja-computed or marked up). Present once FX is locked. |
| fx_rate_id | string <= 128 characters The partner's declared-rate identifier ( |
| quote_id | string The quote this payment was priced on (for a Moja Switch FX tenant, the single-use quote it paid on), for reconciliation. Absent when the payment carried no quote. |
object (PaymentFXProvenance) FX provenance: which FX model priced this payment, and what Moja Switch applied. Present whenever the tenant has an FX model configured. It answers two questions from a single record:
For ⛔ For your systems and Moja staff only — never render to an end user. The end user is shown the amount to send and the all-in rate; the margin percentage is never disclosed to them. | |
object (PayoutInstruction) | |
object (Party) | |
| end_user_id | string^eu_[a-zA-Z0-9]+$ The end user this payment was originated for. Present only when the payment was created with |
| recipient_id | string^rcp_[a-zA-Z0-9]+$ The saved recipient this payment was sent to. Present only when the payment was created with |
| purpose | string (PaymentPurpose) Enum: "FAMILY_SUPPORT" "SCHOOL_FEES" "UTILITY_BILL" "MERCHANT_PAYMENT" "OTHER" |
object ⛔ Integrator-only — never render to an end user. The rail identities behind a payment. A rail name tells a customer which provider handled their money, which is not theirs to know, changes without notice, and turns a routing decision into a support conversation. Use it for reconciliation and diagnostics; do not pass it to a consumer surface, a push payload or a receipt. | |
object (PaymentFailure) Why a payment failed, for the integrator. ⛔ | |
| funding_method | string^[A-Z][A-Z0-9_]{1,31}$ How the sender is funding this payment, as requested at create. Present for |
object What the sender must do to pay. Present for | |
| funds_taken | boolean Present for
A terminal status alone cannot answer this question, which is why
this field exists. Do not derive it from |
| return_state | string Enum: "NONE" "RETURN_IN_PROGRESS" "RETURNED" moja-status: planned 🔲 Planned — not emitted. Whether collected funds are on their way back to the sender. Absent or |
| taking_longer_than_usual | boolean moja-status: planned 🔲 Planned — not emitted. An attention signal, deliberately with no
cause attached. It is a boolean rather than a reason code on purpose: a boolean cannot leak which internal state, rail, counterparty or compliance step produced it, so a client cannot accidentally build copy that does. The platform derives it from dwell time against the expected duration of whatever stage the payment is in; the stage itself is not exposed and will not be. Pair it with a support entry point. Do not present it as a failure, do not promise a completion time, and do not ask the customer to do anything — there is nothing for them to do. |
| cancellable | boolean moja-status: planned 🔲 Planned — not emitted. Whether |
object | |
| completed_at | string <date-time> |
Response Schema: application/json
| id required | string |
| status required | string (PaymentStatus) Enum: "CREATED" "VALIDATING" "COMPLIANCE_CHECK" "FX_LOCKED" "ROUTING" "COLLECTING" "CANCEL_REQUESTED" "COLLECTED" "PAYOUT_SCHEDULED" "PAID_OUT" "REVERSED" "PAYOUT_PENDING" "PAYOUT_SENT" "PROCESSING" "SETTLED" "COMPLETED" "RECONCILED" "FAILED" "CANCELLED" Partner-facing payment lifecycle status. PROCESSING is a safe generic "in progress" value: the platform presents any internal-only substate (e.g. a compensation state) that is not one of the enumerated lifecycle stages as PROCESSING, so a partner never observes a raw internal state. For a transfer, PROCESSING is non-terminal and transient: a subsequent poll or webhook will report a terminal status. Clients should treat any unrecognised value as a non-terminal state.
Collection statuses are published but not emitted yet. |
| reference required | string |
required | object (Money) Decimal-string amount, used by the original partner operations ( |
| created_at required | string <date-time> |
| updated_at required | string <date-time> |
| corridor | string (Corridor) ^[A-Z]{2}-[A-Z]{2}$ Corridor on the transacting routes. This was a closed enum
( It had to change. A closed enum in the contract is the same defect as a hardcoded corridor in a client: the corridor set is per-tenant entitlement data, it moves, and an enum makes every move a contract change and a client release. Published as an enum, a quote on any newer corridor was a schema violation rather than an entitlement question — which is not something the contract should have an opinion about. Entitlement is the authority on what a given tenant may actually
use ( |
object The locked payout amount, echoing the partner's client-provided FX (BYO-FX). Present once FX is locked. | |
| fx_rate | string The partner's locked send→receive rate, echoed back (not Moja-computed or marked up). Present once FX is locked. |
| fx_rate_id | string <= 128 characters The partner's declared-rate identifier ( |
| quote_id | string The quote this payment was priced on (for a Moja Switch FX tenant, the single-use quote it paid on), for reconciliation. Absent when the payment carried no quote. |
object (PaymentFXProvenance) FX provenance: which FX model priced this payment, and what Moja Switch applied. Present whenever the tenant has an FX model configured. It answers two questions from a single record:
For ⛔ For your systems and Moja staff only — never render to an end user. The end user is shown the amount to send and the all-in rate; the margin percentage is never disclosed to them. | |
object (PayoutInstruction) | |
object (Party) | |
| end_user_id | string^eu_[a-zA-Z0-9]+$ The end user this payment was originated for. Present only when the payment was created with |
| recipient_id | string^rcp_[a-zA-Z0-9]+$ The saved recipient this payment was sent to. Present only when the payment was created with |
| purpose | string (PaymentPurpose) Enum: "FAMILY_SUPPORT" "SCHOOL_FEES" "UTILITY_BILL" "MERCHANT_PAYMENT" "OTHER" |
object ⛔ Integrator-only — never render to an end user. The rail identities behind a payment. A rail name tells a customer which provider handled their money, which is not theirs to know, changes without notice, and turns a routing decision into a support conversation. Use it for reconciliation and diagnostics; do not pass it to a consumer surface, a push payload or a receipt. | |
object (PaymentFailure) Why a payment failed, for the integrator. ⛔ | |
| funding_method | string^[A-Z][A-Z0-9_]{1,31}$ How the sender is funding this payment, as requested at create. Present for |
object What the sender must do to pay. Present for | |
| funds_taken | boolean Present for
A terminal status alone cannot answer this question, which is why
this field exists. Do not derive it from |
| return_state | string Enum: "NONE" "RETURN_IN_PROGRESS" "RETURNED" moja-status: planned 🔲 Planned — not emitted. Whether collected funds are on their way back to the sender. Absent or |
| taking_longer_than_usual | boolean moja-status: planned 🔲 Planned — not emitted. An attention signal, deliberately with no
cause attached. It is a boolean rather than a reason code on purpose: a boolean cannot leak which internal state, rail, counterparty or compliance step produced it, so a client cannot accidentally build copy that does. The platform derives it from dwell time against the expected duration of whatever stage the payment is in; the stage itself is not exposed and will not be. Pair it with a support entry point. Do not present it as a failure, do not promise a completion time, and do not ask the customer to do anything — there is nothing for them to do. |
| cancellable | boolean moja-status: planned 🔲 Planned — not emitted. Whether |
object | |
| completed_at | string <date-time> |
Request samples
- Payload
{- "corridor": "CA-TZ",
- "reference": "partner-tx-12345",
- "send_amount": {
- "value": "100.00",
- "currency": "CAD"
}, - "fx": {
- "rate": "1850.25",
- "receive_amount": {
- "value": "185025.00",
- "currency": "TZS"
}
}, - "sender": {
- "type": "INDIVIDUAL",
- "country": "CA",
- "external_id": "partner-cust-1001",
- "first_name": "Alex",
- "last_name": "Sender"
}, - "sender_kyc": {
- "attested": true,
- "attestation_id": "kyc_ref_abc123",
- "attested_at": "2026-11-09T10:00:00Z"
}, - "payout": {
- "method": "MOBILE_MONEY",
- "currency": "TZS",
- "beneficiary": {
- "type": "INDIVIDUAL",
- "first_name": "Neema",
- "last_name": "Recipient",
- "phone": "+255712345678"
}
}, - "purpose": "FAMILY_SUPPORT"
}Response samples
- 200
- 201
- 400
- 401
- 403
- 404
- 409
- 422
- 500
- 503
{- "id": "pay_abc123def456",
- "status": "CREATED",
- "corridor": "CA-TZ",
- "reference": "string",
- "send_amount": {
- "value": "100.00",
- "currency": "CAD"
}, - "receive_amount": {
- "value": "100.00",
- "currency": "CAD"
}, - "fx_rate": "string",
- "fx_rate_id": "dealer-quote-7f3a91",
- "quote_id": "quo_contract",
- "fx_provenance": {
- "model": "MOJA_SWITCH_FX",
- "reference_rate": "2754.41434878",
- "margin_bps": 200,
- "margin_minor": 201,
- "margin_currency": "CAD",
- "customer_rate": "2699.3260618",
- "reference": {
- "provider": "currencyapi",
- "rate_type": "indicative",
- "as_of": "2019-08-24T14:15:22Z"
}
}, - "payout": {
- "method": "MOBILE_MONEY",
- "currency": "TZS",
- "beneficiary": {
- "type": "INDIVIDUAL",
- "first_name": "string",
- "last_name": "string",
- "business_name": "string",
- "phone": "+255712345678",
- "bank_account": {
- "bank_code": "string",
- "account_number": "string",
- "account_name": "string"
}
}
}, - "sender": {
- "type": "INDIVIDUAL",
- "first_name": "string",
- "last_name": "string",
- "business_name": "string",
- "country": "string",
- "external_id": "string"
}, - "end_user_id": "eu_7Qm4xK2pLd",
- "recipient_id": "rcp_9Zd5nP6yTc",
- "purpose": "FAMILY_SUPPORT",
- "rails": {
- "send": "ca_psp_example",
- "receive": "tz_mobile_money_example"
}, - "failure": {
- "code": "payout_failed",
- "message": "string",
- "stage": "compliance"
}, - "funding_method": "INTERAC_REQUEST",
- "funding_instructions": {
- "method": "INTERAC_REQUEST",
- "reference": "MP-0000-0000-0001",
- "pay_by": "2019-08-24T14:15:22Z",
- "bill_payment_number": "string",
- "account_masked": "****4321"
}, - "funds_taken": false,
- "return_state": "NONE",
- "taking_longer_than_usual": false,
- "cancellable": true,
- "metadata": {
- "property1": "string",
- "property2": "string"
}, - "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z",
- "completed_at": "2019-08-24T14:15:22Z"
}List payments
Returns the authenticated partner's payments, newest first. Scoped to the partner: another partner's payments are never visible, and a filter naming one of their resources returns an empty page, not a 404.
Scope: read.
Keyset pagination, not offset. The page is ordered by
created_at DESC, id DESC and walked with an opaque cursor. Read
has_more; when it is true, pass next_cursor back verbatim as
cursor on the following request. The cursor encodes the sort position
and the filter set:
- Never construct, parse or mutate a cursor. Its encoding is not part of this contract and will change.
- Changing any filter while paginating invalidates the cursor →
422 invalid_cursor. Start the walk again with the new filters. - A payment created while you paginate sorts ahead of your cursor and so does not appear in the remaining pages. Re-request the first page to see it. Keyset pagination does not skip or duplicate rows the way offset pagination does.
Authorizations:
query Parameters
| end_user_id | string^eu_[a-zA-Z0-9]+$ Example: end_user_id=eu_7Qm4xK2pLd Return only payments originated by this end user. The natural filter for an activity screen. |
| status | Array of strings (PaymentStatus) <= 14 items Items Enum: "CREATED" "VALIDATING" "COMPLIANCE_CHECK" "FX_LOCKED" "ROUTING" "COLLECTING" "CANCEL_REQUESTED" "COLLECTED" "PAYOUT_SCHEDULED" "PAID_OUT" "REVERSED" "PAYOUT_PENDING" "PAYOUT_SENT" "PROCESSING" "SETTLED" "COMPLETED" "RECONCILED" "FAILED" "CANCELLED" Return only payments in these statuses. Repeat the parameter to match several ( |
| created_after | string <date-time> Only payments created at or after this instant (RFC 3339). |
| created_before | string <date-time> Only payments created strictly before this instant (RFC 3339). |
| limit | integer [ 1 .. 100 ] Default: 25 Page size. Defaults to 25. |
| cursor | string <= 512 characters Opaque keyset cursor from a previous response's |
Responses
Response Schema: application/json
required | Array of objects (Payment) Payments, ordered |
| has_more required | boolean Whether a further page exists. The only correct pagination predicate — an empty |
| next_cursor | string <= 512 characters Opaque cursor for the next page. Present only when |
Response samples
- 200
- 401
- 403
- 422
{- "data": [
- {
- "id": "pay_abc123def456",
- "status": "CREATED",
- "corridor": "CA-TZ",
- "reference": "string",
- "send_amount": {
- "value": "100.00",
- "currency": "CAD"
}, - "receive_amount": {
- "value": "100.00",
- "currency": "CAD"
}, - "fx_rate": "string",
- "fx_rate_id": "dealer-quote-7f3a91",
- "quote_id": "quo_contract",
- "fx_provenance": {
- "model": "MOJA_SWITCH_FX",
- "reference_rate": "2754.41434878",
- "margin_bps": 200,
- "margin_minor": 201,
- "margin_currency": "CAD",
- "customer_rate": "2699.3260618",
- "reference": {
- "provider": "currencyapi",
- "rate_type": "indicative",
- "as_of": "2019-08-24T14:15:22Z"
}
}, - "payout": {
- "method": "MOBILE_MONEY",
- "currency": "TZS",
- "beneficiary": {
- "type": "INDIVIDUAL",
- "first_name": "string",
- "last_name": "string",
- "business_name": "string",
- "phone": "+255712345678",
- "bank_account": {
- "bank_code": "string",
- "account_number": "string",
- "account_name": "string"
}
}
}, - "sender": {
- "type": "INDIVIDUAL",
- "first_name": "string",
- "last_name": "string",
- "business_name": "string",
- "country": "string",
- "external_id": "string"
}, - "end_user_id": "eu_7Qm4xK2pLd",
- "recipient_id": "rcp_9Zd5nP6yTc",
- "purpose": "FAMILY_SUPPORT",
- "rails": {
- "send": "ca_psp_example",
- "receive": "tz_mobile_money_example"
}, - "failure": {
- "code": "payout_failed",
- "message": "string",
- "stage": "compliance"
}, - "funding_method": "INTERAC_REQUEST",
- "funding_instructions": {
- "method": "INTERAC_REQUEST",
- "reference": "MP-0000-0000-0001",
- "pay_by": "2019-08-24T14:15:22Z",
- "bill_payment_number": "string",
- "account_masked": "****4321"
}, - "funds_taken": false,
- "return_state": "NONE",
- "taking_longer_than_usual": false,
- "cancellable": true,
- "metadata": {
- "property1": "string",
- "property2": "string"
}, - "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z",
- "completed_at": "2019-08-24T14:15:22Z"
}
], - "has_more": true,
- "next_cursor": "eyJjIjoiMjAyNi0wOS0xM1QxMDoxNTowMFoiLCJpIjoicGF5X2FiYzEyM2RlZjQ1NiJ9"
}Get payment
Authorizations:
path Parameters
| payment_id required | string^pay_[a-zA-Z0-9]+$ Example: pay_abc123def456 |
Responses
Response Schema: application/json
| id required | string |
| status required | string (PaymentStatus) Enum: "CREATED" "VALIDATING" "COMPLIANCE_CHECK" "FX_LOCKED" "ROUTING" "COLLECTING" "CANCEL_REQUESTED" "COLLECTED" "PAYOUT_SCHEDULED" "PAID_OUT" "REVERSED" "PAYOUT_PENDING" "PAYOUT_SENT" "PROCESSING" "SETTLED" "COMPLETED" "RECONCILED" "FAILED" "CANCELLED" Partner-facing payment lifecycle status. PROCESSING is a safe generic "in progress" value: the platform presents any internal-only substate (e.g. a compensation state) that is not one of the enumerated lifecycle stages as PROCESSING, so a partner never observes a raw internal state. For a transfer, PROCESSING is non-terminal and transient: a subsequent poll or webhook will report a terminal status. Clients should treat any unrecognised value as a non-terminal state.
Collection statuses are published but not emitted yet. |
| reference required | string |
required | object (Money) Decimal-string amount, used by the original partner operations ( |
| created_at required | string <date-time> |
| updated_at required | string <date-time> |
| corridor | string (Corridor) ^[A-Z]{2}-[A-Z]{2}$ Corridor on the transacting routes. This was a closed enum
( It had to change. A closed enum in the contract is the same defect as a hardcoded corridor in a client: the corridor set is per-tenant entitlement data, it moves, and an enum makes every move a contract change and a client release. Published as an enum, a quote on any newer corridor was a schema violation rather than an entitlement question — which is not something the contract should have an opinion about. Entitlement is the authority on what a given tenant may actually
use ( |
object The locked payout amount, echoing the partner's client-provided FX (BYO-FX). Present once FX is locked. | |
| fx_rate | string The partner's locked send→receive rate, echoed back (not Moja-computed or marked up). Present once FX is locked. |
| fx_rate_id | string <= 128 characters The partner's declared-rate identifier ( |
| quote_id | string The quote this payment was priced on (for a Moja Switch FX tenant, the single-use quote it paid on), for reconciliation. Absent when the payment carried no quote. |
object (PaymentFXProvenance) FX provenance: which FX model priced this payment, and what Moja Switch applied. Present whenever the tenant has an FX model configured. It answers two questions from a single record:
For ⛔ For your systems and Moja staff only — never render to an end user. The end user is shown the amount to send and the all-in rate; the margin percentage is never disclosed to them. | |
object (PayoutInstruction) | |
object (Party) | |
| end_user_id | string^eu_[a-zA-Z0-9]+$ The end user this payment was originated for. Present only when the payment was created with |
| recipient_id | string^rcp_[a-zA-Z0-9]+$ The saved recipient this payment was sent to. Present only when the payment was created with |
| purpose | string (PaymentPurpose) Enum: "FAMILY_SUPPORT" "SCHOOL_FEES" "UTILITY_BILL" "MERCHANT_PAYMENT" "OTHER" |
object ⛔ Integrator-only — never render to an end user. The rail identities behind a payment. A rail name tells a customer which provider handled their money, which is not theirs to know, changes without notice, and turns a routing decision into a support conversation. Use it for reconciliation and diagnostics; do not pass it to a consumer surface, a push payload or a receipt. | |
object (PaymentFailure) Why a payment failed, for the integrator. ⛔ | |
| funding_method | string^[A-Z][A-Z0-9_]{1,31}$ How the sender is funding this payment, as requested at create. Present for |
object What the sender must do to pay. Present for | |
| funds_taken | boolean Present for
A terminal status alone cannot answer this question, which is why
this field exists. Do not derive it from |
| return_state | string Enum: "NONE" "RETURN_IN_PROGRESS" "RETURNED" moja-status: planned 🔲 Planned — not emitted. Whether collected funds are on their way back to the sender. Absent or |
| taking_longer_than_usual | boolean moja-status: planned 🔲 Planned — not emitted. An attention signal, deliberately with no
cause attached. It is a boolean rather than a reason code on purpose: a boolean cannot leak which internal state, rail, counterparty or compliance step produced it, so a client cannot accidentally build copy that does. The platform derives it from dwell time against the expected duration of whatever stage the payment is in; the stage itself is not exposed and will not be. Pair it with a support entry point. Do not present it as a failure, do not promise a completion time, and do not ask the customer to do anything — there is nothing for them to do. |
| cancellable | boolean moja-status: planned 🔲 Planned — not emitted. Whether |
object | |
| completed_at | string <date-time> |
Response samples
- 200
- 401
- 404
{- "id": "pay_abc123def456",
- "status": "CREATED",
- "corridor": "CA-TZ",
- "reference": "string",
- "send_amount": {
- "value": "100.00",
- "currency": "CAD"
}, - "receive_amount": {
- "value": "100.00",
- "currency": "CAD"
}, - "fx_rate": "string",
- "fx_rate_id": "dealer-quote-7f3a91",
- "quote_id": "quo_contract",
- "fx_provenance": {
- "model": "MOJA_SWITCH_FX",
- "reference_rate": "2754.41434878",
- "margin_bps": 200,
- "margin_minor": 201,
- "margin_currency": "CAD",
- "customer_rate": "2699.3260618",
- "reference": {
- "provider": "currencyapi",
- "rate_type": "indicative",
- "as_of": "2019-08-24T14:15:22Z"
}
}, - "payout": {
- "method": "MOBILE_MONEY",
- "currency": "TZS",
- "beneficiary": {
- "type": "INDIVIDUAL",
- "first_name": "string",
- "last_name": "string",
- "business_name": "string",
- "phone": "+255712345678",
- "bank_account": {
- "bank_code": "string",
- "account_number": "string",
- "account_name": "string"
}
}
}, - "sender": {
- "type": "INDIVIDUAL",
- "first_name": "string",
- "last_name": "string",
- "business_name": "string",
- "country": "string",
- "external_id": "string"
}, - "end_user_id": "eu_7Qm4xK2pLd",
- "recipient_id": "rcp_9Zd5nP6yTc",
- "purpose": "FAMILY_SUPPORT",
- "rails": {
- "send": "ca_psp_example",
- "receive": "tz_mobile_money_example"
}, - "failure": {
- "code": "payout_failed",
- "message": "string",
- "stage": "compliance"
}, - "funding_method": "INTERAC_REQUEST",
- "funding_instructions": {
- "method": "INTERAC_REQUEST",
- "reference": "MP-0000-0000-0001",
- "pay_by": "2019-08-24T14:15:22Z",
- "bill_payment_number": "string",
- "account_masked": "****4321"
}, - "funds_taken": false,
- "return_state": "NONE",
- "taking_longer_than_usual": false,
- "cancellable": true,
- "metadata": {
- "property1": "string",
- "property2": "string"
}, - "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z",
- "completed_at": "2019-08-24T14:15:22Z"
}Cancel payment
Cancels a payment before send-side collection begins. Returns 409 if cancellation is no longer allowed.
Authorizations:
path Parameters
| payment_id required | string^pay_[a-zA-Z0-9]+$ Example: pay_abc123def456 |
Request Body schema: application/json
| reason | string <= 500 characters |
Responses
Response Schema: application/json
| id required | string |
| status required | string (PaymentStatus) Enum: "CREATED" "VALIDATING" "COMPLIANCE_CHECK" "FX_LOCKED" "ROUTING" "COLLECTING" "CANCEL_REQUESTED" "COLLECTED" "PAYOUT_SCHEDULED" "PAID_OUT" "REVERSED" "PAYOUT_PENDING" "PAYOUT_SENT" "PROCESSING" "SETTLED" "COMPLETED" "RECONCILED" "FAILED" "CANCELLED" Partner-facing payment lifecycle status. PROCESSING is a safe generic "in progress" value: the platform presents any internal-only substate (e.g. a compensation state) that is not one of the enumerated lifecycle stages as PROCESSING, so a partner never observes a raw internal state. For a transfer, PROCESSING is non-terminal and transient: a subsequent poll or webhook will report a terminal status. Clients should treat any unrecognised value as a non-terminal state.
Collection statuses are published but not emitted yet. |
| reference required | string |
required | object (Money) Decimal-string amount, used by the original partner operations ( |
| created_at required | string <date-time> |
| updated_at required | string <date-time> |
| corridor | string (Corridor) ^[A-Z]{2}-[A-Z]{2}$ Corridor on the transacting routes. This was a closed enum
( It had to change. A closed enum in the contract is the same defect as a hardcoded corridor in a client: the corridor set is per-tenant entitlement data, it moves, and an enum makes every move a contract change and a client release. Published as an enum, a quote on any newer corridor was a schema violation rather than an entitlement question — which is not something the contract should have an opinion about. Entitlement is the authority on what a given tenant may actually
use ( |
object The locked payout amount, echoing the partner's client-provided FX (BYO-FX). Present once FX is locked. | |
| fx_rate | string The partner's locked send→receive rate, echoed back (not Moja-computed or marked up). Present once FX is locked. |
| fx_rate_id | string <= 128 characters The partner's declared-rate identifier ( |
| quote_id | string The quote this payment was priced on (for a Moja Switch FX tenant, the single-use quote it paid on), for reconciliation. Absent when the payment carried no quote. |
object (PaymentFXProvenance) FX provenance: which FX model priced this payment, and what Moja Switch applied. Present whenever the tenant has an FX model configured. It answers two questions from a single record:
For ⛔ For your systems and Moja staff only — never render to an end user. The end user is shown the amount to send and the all-in rate; the margin percentage is never disclosed to them. | |
object (PayoutInstruction) | |
object (Party) | |
| end_user_id | string^eu_[a-zA-Z0-9]+$ The end user this payment was originated for. Present only when the payment was created with |
| recipient_id | string^rcp_[a-zA-Z0-9]+$ The saved recipient this payment was sent to. Present only when the payment was created with |
| purpose | string (PaymentPurpose) Enum: "FAMILY_SUPPORT" "SCHOOL_FEES" "UTILITY_BILL" "MERCHANT_PAYMENT" "OTHER" |
object ⛔ Integrator-only — never render to an end user. The rail identities behind a payment. A rail name tells a customer which provider handled their money, which is not theirs to know, changes without notice, and turns a routing decision into a support conversation. Use it for reconciliation and diagnostics; do not pass it to a consumer surface, a push payload or a receipt. | |
object (PaymentFailure) Why a payment failed, for the integrator. ⛔ | |
| funding_method | string^[A-Z][A-Z0-9_]{1,31}$ How the sender is funding this payment, as requested at create. Present for |
object What the sender must do to pay. Present for | |
| funds_taken | boolean Present for
A terminal status alone cannot answer this question, which is why
this field exists. Do not derive it from |
| return_state | string Enum: "NONE" "RETURN_IN_PROGRESS" "RETURNED" moja-status: planned 🔲 Planned — not emitted. Whether collected funds are on their way back to the sender. Absent or |
| taking_longer_than_usual | boolean moja-status: planned 🔲 Planned — not emitted. An attention signal, deliberately with no
cause attached. It is a boolean rather than a reason code on purpose: a boolean cannot leak which internal state, rail, counterparty or compliance step produced it, so a client cannot accidentally build copy that does. The platform derives it from dwell time against the expected duration of whatever stage the payment is in; the stage itself is not exposed and will not be. Pair it with a support entry point. Do not present it as a failure, do not promise a completion time, and do not ask the customer to do anything — there is nothing for them to do. |
| cancellable | boolean moja-status: planned 🔲 Planned — not emitted. Whether |
object | |
| completed_at | string <date-time> |
Request samples
- Payload
{- "reason": "string"
}Response samples
- 200
- 401
- 404
- 409
{- "id": "pay_abc123def456",
- "status": "CREATED",
- "corridor": "CA-TZ",
- "reference": "string",
- "send_amount": {
- "value": "100.00",
- "currency": "CAD"
}, - "receive_amount": {
- "value": "100.00",
- "currency": "CAD"
}, - "fx_rate": "string",
- "fx_rate_id": "dealer-quote-7f3a91",
- "quote_id": "quo_contract",
- "fx_provenance": {
- "model": "MOJA_SWITCH_FX",
- "reference_rate": "2754.41434878",
- "margin_bps": 200,
- "margin_minor": 201,
- "margin_currency": "CAD",
- "customer_rate": "2699.3260618",
- "reference": {
- "provider": "currencyapi",
- "rate_type": "indicative",
- "as_of": "2019-08-24T14:15:22Z"
}
}, - "payout": {
- "method": "MOBILE_MONEY",
- "currency": "TZS",
- "beneficiary": {
- "type": "INDIVIDUAL",
- "first_name": "string",
- "last_name": "string",
- "business_name": "string",
- "phone": "+255712345678",
- "bank_account": {
- "bank_code": "string",
- "account_number": "string",
- "account_name": "string"
}
}
}, - "sender": {
- "type": "INDIVIDUAL",
- "first_name": "string",
- "last_name": "string",
- "business_name": "string",
- "country": "string",
- "external_id": "string"
}, - "end_user_id": "eu_7Qm4xK2pLd",
- "recipient_id": "rcp_9Zd5nP6yTc",
- "purpose": "FAMILY_SUPPORT",
- "rails": {
- "send": "ca_psp_example",
- "receive": "tz_mobile_money_example"
}, - "failure": {
- "code": "payout_failed",
- "message": "string",
- "stage": "compliance"
}, - "funding_method": "INTERAC_REQUEST",
- "funding_instructions": {
- "method": "INTERAC_REQUEST",
- "reference": "MP-0000-0000-0001",
- "pay_by": "2019-08-24T14:15:22Z",
- "bill_payment_number": "string",
- "account_masked": "****4321"
}, - "funds_taken": false,
- "return_state": "NONE",
- "taking_longer_than_usual": false,
- "cancellable": true,
- "metadata": {
- "property1": "string",
- "property2": "string"
}, - "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z",
- "completed_at": "2019-08-24T14:15:22Z"
}Get payment receipt
✅ Implemented.
Returns the receipt for a payment whose funds have been delivered.
receipt_number is RCPT-<year>-<first 8 characters of the payment id, upper case>,
derived from the payment, so a re-fetch always returns the same number. issued_at
is the payment's completed_at. fees.platform_fee may be zero.
Scope: read.
Availability. A receipt exists only once the payment has reached
COMPLETED, SETTLED or RECONCILED. Anything earlier —
or a FAILED/CANCELLED payment, which has no receipt —
returns 409 receipt_not_available. Use GET /payments/{payment_id}
for in-flight state; a receipt is a record of money that moved, not a
status view.
Amounts are integer minor units (MinorUnitAmount), each carrying
its own currency and exponent — amount / 10^minor_unit_exponent is the
value to display. A receipt is the artifact a user screenshots and sends
to support, so it is the last place an amount should be ambiguous by a
factor of a hundred.
Masking is structural for everything except the recipient's own
phone number. recipient.phone is the destination MSISDN in full —
the sender is shown their own saved recipient's number so they can
verify who they are paying. Bank account numbers, document numbers and
any credential are never returned here — there is no parameter that
unmasks them.
Authorizations:
path Parameters
| payment_id required | string^pay_[a-zA-Z0-9]+$ Example: pay_abc123def456 |
Responses
Response Schema: application/json
| payment_id required | string |
| receipt_number required | string Human-quotable reference for this receipt. Stable for the life of the payment — a re-fetch returns the same number, so a user reading it to support and a user reading it from a saved PDF agree. |
| issued_at required | string <date-time> |
| status required | string Enum: "CREATED" "VALIDATING" "COMPLIANCE_CHECK" "FX_LOCKED" "ROUTING" "COLLECTING" "CANCEL_REQUESTED" "COLLECTED" "PAYOUT_SCHEDULED" "PAID_OUT" "REVERSED" "PAYOUT_PENDING" "PAYOUT_SENT" "PROCESSING" "SETTLED" "COMPLETED" "RECONCILED" "FAILED" "CANCELLED" The payment status at issue time. A receipt is only issued from |
| corridor required | string (CorridorCode) ^[A-Z]{2}-[A-Z]{2}$ A corridor identifier, |
required | object What the sender paid, in integer minor units of the send currency. |
required | object The beneficiary. |
| reference | string The partner's own reference for the payment, echoed back. |
| end_user_id | string^eu_[a-zA-Z0-9]+$ Present when the payment was originated by an end user. |
object The amount delivered, in integer minor units of the receive currency, with that currency's exponent carried alongside it. A zero-decimal receive currency therefore cannot arrive here with cents attached. | |
| fx_rate | string The rate applied to this payment, echoed back. |
object (ReceiptFees) What the transfer cost the sender, denominated in the SEND currency. The same breakdown as | |
| sender_name | string Display name of the originator, as held by the platform. Present only where the tenant supplied one. |
| completed_at | string <date-time> |
Response samples
- 200
- 401
- 403
- 404
- 409
{- "payment_id": "pay_abc123def456",
- "receipt_number": "RCPT-2026-0001234",
- "issued_at": "2019-08-24T14:15:22Z",
- "status": "CREATED",
- "corridor": "CA-TZ",
- "reference": "string",
- "end_user_id": "eu_7Qm4xK2pLd",
- "send_amount": {
- "amount": 10000,
- "currency": "CAD",
- "minor_unit_exponent": 2
}, - "receive_amount": {
- "amount": 10000,
- "currency": "CAD",
- "minor_unit_exponent": 2
}, - "fx_rate": "1850.25",
- "fees": {
- "platform_fee": {
- "amount": 10000,
- "currency": "CAD",
- "minor_unit_exponent": 2
}
}, - "sender_name": "Test Sender",
- "recipient": {
- "display_name": "Sample Recipient",
- "phone": "+255712345678",
- "country": "TZ"
}, - "completed_at": "2019-08-24T14:15:22Z"
}Tenant CAD payouts for collections. Sandbox only; production payouts are not offered yet.
Members expose gross, fee, net and the collection reference for reconciliation.
List tenant CAD payouts
Returns the authenticated partner's tenant CAD payouts,
newest first. Each member carries gross, fee, net and the collection
reference, so you can reconcile each payout line to your own records.
Scope: read.
Sandbox only: production payouts are not offered yet.
Authorizations:
query Parameters
| limit | integer [ 1 .. 100 ] Default: 25 |
Responses
Response Schema: application/json
required | Array of objects (TenantPayout) |
| has_more required | boolean |
Response samples
- 200
- 401
- 403
{- "data": [
- {
- "id": "string",
- "status": "CREATED",
- "currency": "CAD",
- "cycle": "string",
- "attempt": 1,
- "net_amount_minor": 0,
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z",
- "members": [
- {
- "collection_id": "string",
- "reference": "string",
- "gross_amount_minor": 0,
- "fee_amount_minor": 0,
- "net_amount_minor": 0,
- "active": true
}
]
}
], - "has_more": true
}Get tenant CAD payout
Returns one tenant CAD payout scoped to the authenticated partner.
Another partner's id is 404, never a cross-tenant leak.
Scope: read.
Authorizations:
path Parameters
| payout_id required | string^tpo_[a-zA-Z0-9]+$ |
Responses
Response Schema: application/json
| id required | string^tpo_[a-zA-Z0-9]+$ |
| status required | string Enum: "CREATED" "SENT" "PAID" "FAILED" "RETURNED" "OUTCOME_UNKNOWN" |
| currency required | string Value: "CAD" |
| cycle required | string |
| attempt required | integer >= 1 |
| net_amount_minor required | integer <int64> |
| created_at required | string <date-time> |
| updated_at required | string <date-time> |
required | Array of objects (TenantPayoutMember) |
Response samples
- 200
- 401
- 404
{- "id": "string",
- "status": "CREATED",
- "currency": "CAD",
- "cycle": "string",
- "attempt": 1,
- "net_amount_minor": 0,
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z",
- "members": [
- {
- "collection_id": "string",
- "reference": "string",
- "gross_amount_minor": 0,
- "fee_amount_minor": 0,
- "net_amount_minor": 0,
- "active": true
}
]
}Get the indicative FX reference rate for a corridor
The validated indicative market reference rate for an enabled
corridor, priced as its send→receive pair: receive-currency
units per one send-currency unit, a decimal string with at most 8
fractional digits. Never a customer rate: no spread, no quote, no
lock — do not show it as the rate a customer will get. To price a
payment, use POST /quotes.
Scope: read. You must also be entitled to corridor (403 corridor_not_entitled otherwise;
503 entitlement_unavailable if the entitlement cannot be checked).
If there is no valid, fresh rate, the answer is 503 fx_unavailable;
a stale or older rate is never returned instead.
Authorizations:
query Parameters
| corridor required | string (CorridorCode) ^[A-Z]{2}-[A-Z]{2}$ Example: corridor=CA-TZ A corridor identifier, |
Responses
Response Schema: application/json
| corridor required | string (CorridorCode) ^[A-Z]{2}-[A-Z]{2}$ A corridor identifier, |
| rate required | string^\d+(\.\d{1,8})?$ Receive-currency units per one send-currency unit. |
| rate_type required | string Value: "indicative" |
| provider required | string The reference-data provider. |
| as_of required | string <date-time> When the provider observed the rate. |
Response samples
- 200
- 400
- 401
- 403
- 404
- 503
{- "corridor": "CA-TZ",
- "rate": "2712.5",
- "rate_type": "indicative",
- "provider": "string",
- "as_of": "2019-08-24T14:15:22Z"
}Corridor discovery — payout methods, limits and minor-unit exponents, as
data rather than hardcoded client knowledge. GET /corridors/{corridor_code}
and GET /account/readiness are live; the corridor list and client
configuration are planned.
[Planned] Operating posture for a client application
What a client application must know before it lets anyone move money: whether this build is still supported, and whether money operations are currently enabled at all.
Not end-user scoped, and answerable before anyone signs in. A client below the minimum version must be able to learn that on launch, and a money-operations stop must reach a client whose user never authenticates.
This is the client half of the compliance kill switch. The platform
can already refuse a payment; without this, a stop leaves every deployed
app still offering to send money and discovering the refusal one request
at a time. money_operations_enabled: false means the client SHALL hide
or disable every send affordance while leaving read and support
surfaces reachable — a customer whose transfer is in flight still needs
to see it and still needs to reach support, and cutting them off is worse
than the condition that triggered the stop.
Clients SHOULD read this on launch and on foreground, and SHALL NOT cache
it past max_age_seconds. It is deliberately cheap to serve.
🔲 Planned — not implemented. Calling it today returns 404. The
"before anyone signs in" wording refers to end-user sign-in in a client
app: the call itself still requires a partner API key with scope
read.
Scope: read.
Authorizations:
Responses
Response Schema: application/json
| money_operations_enabled required | boolean When |
| max_age_seconds required | integer >= 0 How long this answer may be cached. A short value is the point: this is the control that has to arrive quickly when it changes. |
| money_operations_message | string <= 300 characters Consumer-safe text to show when money operations are disabled. Set by the platform so the wording is not invented per client. It SHALL NOT name a rail, a provider, a compliance stage, or a cause. |
| minimum_version | string The lowest client version permitted to move money, as the client's own version string. A client below it SHALL disable money operations and prompt the customer to update, independently of |
| upgrade_url | string <uri> Where to send a customer whose build is below |
Response samples
- 200
- 401
- 403
{- "money_operations_enabled": true,
- "money_operations_message": "Sending is temporarily unavailable. You can still view your transfers and contact support.",
- "minimum_version": "1.4.0",
- "max_age_seconds": 300
}[Planned] List the corridors this partner may use
Returns the corridors the authenticated partner is entitled to, as data.
Scope: read.
Discover, do not hardcode. A client that ships a corridor list, a
currency list or a decimal assumption has to be re-released to gain a
corridor; one that renders this response does not. The returned set is
per-partner entitlement, so two partners can legitimately see different
lists, and a corridor absent here is refused on the money routes with
403 corridor_not_entitled.
The transacting routes type corridor as the same pattern-constrained
string (see the Corridor schema), so this endpoint — not the type — is
what says which corridors a tenant may actually use. Treat
CorridorCode as an opaque server-supplied string, so that widening the
set is an entitlement change rather than a client release.
Authorizations:
Responses
Response Schema: application/json
required | Array of objects (CorridorSummary) |
Response samples
- 200
- 401
- 403
{- "data": [
- {
- "code": "CA-TZ",
- "send_country": "CA",
- "receive_country": "TZ",
- "send_currency": {
- "currency": "TZS",
- "minor_unit_exponent": 2
}, - "receive_currency": {
- "currency": "TZS",
- "minor_unit_exponent": 2
}, - "payout_methods": [
- "MOBILE_MONEY"
], - "funding_methods": [
- "INTERAC_REQUEST"
], - "wallet_networks": [
- "NETWORK_A"
]
}
]
}Get corridor detail
Returns what a client needs to build and validate a payment for one
enabled catalogue corridor: payout methods, the limits in force, and the
minor-unit exponent of both currencies. Not entitlement-scoped (an
unknown or disabled corridor is 404 corridor_not_found); per-method
beneficiary field specs are planned.
Scope: read.
The minor-unit exponent is load-bearing — never assume two decimals.
send_currency.minor_unit_exponent and
receive_currency.minor_unit_exponent give the number of fractional
digits that currency has. It is 2 for CAD and TZS; it is 0 for a
zero-decimal currency, where 1500 means one thousand five hundred
units and 15.00 is not a valid amount at all. Formatting,
rounding, input masks, "cents" arithmetic and any equality check must
read the exponent. A client that hardcodes two decimals will be wrong by
a factor of one hundred the first time it meets a zero-decimal currency,
and it will be wrong quietly.
Limits are server-supplied and this response is the only place to read them. Do not cache them past a session, do not re-implement them, and do not use them to decide anything — render them, and let the platform deny. The platform is the sole authority on limits and its denial is the answer, not a second opinion.
Authorizations:
path Parameters
| corridor_code required | string (CorridorCode) ^[A-Z]{2}-[A-Z]{2}$ Example: CA-TZ Corridor code as returned by |
Responses
Response Schema: application/json
| code required | string (CorridorCode) ^[A-Z]{2}-[A-Z]{2}$ A corridor identifier, |
required | object (CurrencyUnit) A currency and how many fractional digits it actually has. Read |
required | object (CurrencyUnit) A currency and how many fractional digits it actually has. Read |
| payout_methods required | Array of strings (PayoutMethod) Items Enum: "MOBILE_MONEY" "BANK_TRANSFER" Payout methods available on this corridor for this partner. |
| send_country | string^[A-Z]{2}$ |
| receive_country | string^[A-Z]{2}$ |
| funding_methods | Array of strings (FundingMethod) [ items^[A-Z][A-Z0-9_]{1,31}$ ] moja-status: planned 🔲 Planned — not emitted. How a sender may fund a payment on this corridor, in the order they should be offered. Read this rather than hardcoding a payment-method screen. |
| wallet_networks | Array of strings (WalletNetwork) [ items^[A-Z][A-Z0-9_]{1,31}$ ] moja-status: planned 🔲 Planned — not emitted. Mobile-money networks reachable on this corridor, for |
Array of objects (CorridorPayoutMethod) Per-method requirements: the beneficiary fields a payout of that method needs, and their validation. Present when the corridor catalogue carries them (CA-KE publishes the phone pattern and the mobile-money networks). Absent when it does not. Render and validate from this rather than hardcoding a form per corridor. | |
object (CorridorLimits) The limits in force for this partner on this corridor, in the send currency. Values are server-supplied and are published here to be rendered, not to be evaluated. The platform is the sole authority: a client must not pre-decide a denial from these numbers, must not cache them beyond a session, and must treat the platform's rejection as the answer. Amounts are absent when no limit of that kind applies — absent means "no limit expressed here", never "no limit".
Amounts are integer minor units ( | |
| fx_margin_bps | integer [ 0 .. 1000 ] Deprecated Deprecated — do not use. Returned only to one legacy tenant (whose older client applied it itself) and absent for every other partner: it is that tenant's commercial arrangement with Moja Switch, not a corridor price. Moja Switch never prices from it: a tenant on Moja Switch FX is priced by Moja Switch from its own reference rate and the tenant's margin for the corridor (see |
Response samples
- 200
- 401
- 403
- 404
{- "code": "CA-TZ",
- "send_country": "CA",
- "receive_country": "TZ",
- "send_currency": {
- "currency": "TZS",
- "minor_unit_exponent": 2
}, - "receive_currency": {
- "currency": "TZS",
- "minor_unit_exponent": 2
}, - "payout_methods": [
- "MOBILE_MONEY"
], - "funding_methods": [
- "INTERAC_REQUEST"
], - "wallet_networks": [
- "NETWORK_A"
], - "payout_method_details": [
- {
- "method": "MOBILE_MONEY",
- "required_fields": [
- {
- "name": "postal_code",
- "type": "string",
- "required": true,
- "pattern": "string",
- "max_length": 1,
- "options": [
- "string"
]
}
]
}
], - "limits": {
- "per_payment_min": {
- "amount": 10000,
- "currency": "CAD",
- "minor_unit_exponent": 2
}, - "per_payment_max": {
- "amount": 10000,
- "currency": "CAD",
- "minor_unit_exponent": 2
}, - "per_day_max": {
- "amount": 10000,
- "currency": "CAD",
- "minor_unit_exponent": 2
}, - "per_month_max": {
- "amount": 10000,
- "currency": "CAD",
- "minor_unit_exponent": 2
}
}, - "fx_margin_bps": 200
}End users — natural persons a tenant acts for. Every tenant with the
end_users:* scopes gets this surface; there is no separate consumer API.
GET /end-users/{end_user_id}/limits is planned (x-moja-status: planned).
Create end user
Registers a natural person the tenant acts for and returns their
platform id. Every tenant may model end users; nothing about this
resource is specific to any one product.
Scope: end_users:write.
Creation authorises nothing. A new end user starts at
account_status: created, verification_status: unverified,
transact_authorization: not_authorized. No money path is open until
transact_authorization is authorized.
Safe to retry. external_id is your own identifier for the person
and is unique within your tenant. Re-posting the same external_id with
an identical body returns the existing end user with 200 instead of
creating a second record; re-posting it with a different body is
409 end_user_already_exists. A create can therefore never fork one
person into two records under retry.
Screened before creation. When a name is supplied, the person's
identity (name, country, phone) is screened (sanctions, AML/fraud
rules) before the account exists. A block or review outcome is
422 compliance_blocked and no account is created. A transport or
dependency fault is 503 screening_unavailable, and nothing is created
either. With no name (sign-up with phone and country only)
there is nothing to screen against: the account is created
unscreened and cannot be authorized to transact until a profile
update supplies a name that clears screening.
Authorizations:
Request Body schema: application/jsonrequired
| external_id required | string <= 128 characters Your own identifier for this person. Unique within your tenant; re-posting it with an identical body returns the existing end user rather than creating a second one. |
| country required | string^[A-Z]{2}$ ISO 3166-1 alpha-2 country of residence. |
| first_name | string <= 100 characters |
| last_name | string <= 100 characters |
| date_of_birth | string <date> |
| phone | string E.164. |
string <email> | |
object <= 20 properties |
Responses
Response Schema: application/json
| id required | string^eu_[a-zA-Z0-9]+$ |
| external_id required | string <= 128 characters The tenant's own identifier for this person. Unique within the tenant, and the key that makes |
| account_status required | string (AccountStatus) Enum: "created" "suspended" "closed" Lifecycle of the end-user record itself. Independent of verification and of authorization to transact — see |
| verification_status required | string (VerificationStatus) Enum: "unverified" "pending" "verified" "failed" The outcome of identity verification, and only that. It records what was checked and how it resolved. It is never edited to express an access decision: to stop an end user transacting, revoke |
| transact_authorization required | string (TransactAuthorization) Enum: "authorized" "not_authorized" "revoked" Whether this end user may currently move money. This is the field a send affordance renders from, and the only one. Independently revocable at any time by the platform, without any change to |
| created_at required | string <date-time> |
| updated_at required | string <date-time> |
| first_name | string <= 100 characters |
| last_name | string <= 100 characters |
| date_of_birth | string <date> ISO 8601 date. Used by verification and screening. |
| country | string^[A-Z]{2}$ ISO 3166-1 alpha-2 country of residence. |
| phone | string E.164. The person's own contact number, not a destination. |
string <email> | |
| account_status_updated_at | string <date-time> When |
| verification_tier | string <= 64 characters The tier reached, as a server-defined opaque identifier. Present only once a verification has resolved. It is not a limit, and it does not map to one on the client: limits come from |
| transact_authorization_updated_at | string <date-time> When |
object <= 20 properties Tenant key/value metadata, echoed back. Never place credentials, document numbers or card data here. |
Response Schema: application/json
| id required | string^eu_[a-zA-Z0-9]+$ |
| external_id required | string <= 128 characters The tenant's own identifier for this person. Unique within the tenant, and the key that makes |
| account_status required | string (AccountStatus) Enum: "created" "suspended" "closed" Lifecycle of the end-user record itself. Independent of verification and of authorization to transact — see |
| verification_status required | string (VerificationStatus) Enum: "unverified" "pending" "verified" "failed" The outcome of identity verification, and only that. It records what was checked and how it resolved. It is never edited to express an access decision: to stop an end user transacting, revoke |
| transact_authorization required | string (TransactAuthorization) Enum: "authorized" "not_authorized" "revoked" Whether this end user may currently move money. This is the field a send affordance renders from, and the only one. Independently revocable at any time by the platform, without any change to |
| created_at required | string <date-time> |
| updated_at required | string <date-time> |
| first_name | string <= 100 characters |
| last_name | string <= 100 characters |
| date_of_birth | string <date> ISO 8601 date. Used by verification and screening. |
| country | string^[A-Z]{2}$ ISO 3166-1 alpha-2 country of residence. |
| phone | string E.164. The person's own contact number, not a destination. |
string <email> | |
| account_status_updated_at | string <date-time> When |
| verification_tier | string <= 64 characters The tier reached, as a server-defined opaque identifier. Present only once a verification has resolved. It is not a limit, and it does not map to one on the client: limits come from |
| transact_authorization_updated_at | string <date-time> When |
object <= 20 properties Tenant key/value metadata, echoed back. Never place credentials, document numbers or card data here. |
Request samples
- Payload
{- "external_id": "tenant-user-000123",
- "first_name": "Test",
- "last_name": "Sender",
- "date_of_birth": "1990-01-01",
- "country": "CA",
- "phone": "+14165550000",
- "email": "test.sender@example.com",
- "metadata": {
- "property1": "string",
- "property2": "string"
}
}Response samples
- 200
- 201
- 400
- 401
- 403
- 409
- 422
- 503
{- "id": "eu_7Qm4xK2pLd",
- "external_id": "tenant-user-000123",
- "first_name": "Test",
- "last_name": "Sender",
- "date_of_birth": "1990-01-01",
- "country": "CA",
- "phone": "+14165550000",
- "email": "test.sender@example.com",
- "account_status": "created",
- "account_status_updated_at": "2019-08-24T14:15:22Z",
- "verification_status": "unverified",
- "verification_tier": "tier_1",
- "transact_authorization": "authorized",
- "transact_authorization_updated_at": "2019-08-24T14:15:22Z",
- "metadata": {
- "property1": "string",
- "property2": "string"
}, - "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}Get end user
Returns the profile and the three independent state fields.
Scope: end_users:read.
This is the authoritative read for what an end user may do. A client that cached an earlier state, or that took a verification vendor's word for an outcome, is holding UI state — not authorization.
Authorizations:
path Parameters
| end_user_id required | string^eu_[a-zA-Z0-9]+$ Example: eu_7Qm4xK2pLd |
Responses
Response Schema: application/json
| id required | string^eu_[a-zA-Z0-9]+$ |
| external_id required | string <= 128 characters The tenant's own identifier for this person. Unique within the tenant, and the key that makes |
| account_status required | string (AccountStatus) Enum: "created" "suspended" "closed" Lifecycle of the end-user record itself. Independent of verification and of authorization to transact — see |
| verification_status required | string (VerificationStatus) Enum: "unverified" "pending" "verified" "failed" The outcome of identity verification, and only that. It records what was checked and how it resolved. It is never edited to express an access decision: to stop an end user transacting, revoke |
| transact_authorization required | string (TransactAuthorization) Enum: "authorized" "not_authorized" "revoked" Whether this end user may currently move money. This is the field a send affordance renders from, and the only one. Independently revocable at any time by the platform, without any change to |
| created_at required | string <date-time> |
| updated_at required | string <date-time> |
| first_name | string <= 100 characters |
| last_name | string <= 100 characters |
| date_of_birth | string <date> ISO 8601 date. Used by verification and screening. |
| country | string^[A-Z]{2}$ ISO 3166-1 alpha-2 country of residence. |
| phone | string E.164. The person's own contact number, not a destination. |
string <email> | |
| account_status_updated_at | string <date-time> When |
| verification_tier | string <= 64 characters The tier reached, as a server-defined opaque identifier. Present only once a verification has resolved. It is not a limit, and it does not map to one on the client: limits come from |
| transact_authorization_updated_at | string <date-time> When |
object <= 20 properties Tenant key/value metadata, echoed back. Never place credentials, document numbers or card data here. |
Response samples
- 200
- 401
- 403
- 404
{- "id": "eu_7Qm4xK2pLd",
- "external_id": "tenant-user-000123",
- "first_name": "Test",
- "last_name": "Sender",
- "date_of_birth": "1990-01-01",
- "country": "CA",
- "phone": "+14165550000",
- "email": "test.sender@example.com",
- "account_status": "created",
- "account_status_updated_at": "2019-08-24T14:15:22Z",
- "verification_status": "unverified",
- "verification_tier": "tier_1",
- "transact_authorization": "authorized",
- "transact_authorization_updated_at": "2019-08-24T14:15:22Z",
- "metadata": {
- "property1": "string",
- "property2": "string"
}, - "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}Update end user profile
Updates profile attributes. Omitted fields are left unchanged; an
explicit null is rejected with 422 validation_error rather than
silently clearing a field.
Scope: end_users:write.
The three state fields are not writable here. account_status,
verification_status and transact_authorization are outcomes, not
inputs: they move only through POST /end-users/{end_user_id}/suspend
and through verification and compliance decisions. Sending one returns
422 immutable_field naming the field. A partner cannot promote its own
end user to verified, which is the point.
Changing a name or date of birth on a verified end user does not
invalidate the verification record (that record is evidence of what was
checked and when); it may trigger re-screening, which surfaces as a
change to transact_authorization, never as an edit to
verification_status.
Screened before it is written. A change to the first name, last
name or phone is screened before it is saved, exactly as at creation:
422 compliance_blocked or 503 screening_unavailable leave the end
user unchanged. A clearing result on a verified end user that is not
yet authorized opens transact_authorization. A change that cannot be
screened (no name) is saved but moves an authorized end user back to
not_authorized until a named profile clears.
Authorizations:
path Parameters
| end_user_id required | string^eu_[a-zA-Z0-9]+$ Example: eu_7Qm4xK2pLd |
Request Body schema: application/jsonrequired
non-empty | first_name | string <= 100 characters |
| last_name | string <= 100 characters |
| date_of_birth | string <date> |
| phone | string E.164. |
string <email> | |
object <= 20 properties |
Responses
Response Schema: application/json
| id required | string^eu_[a-zA-Z0-9]+$ |
| external_id required | string <= 128 characters The tenant's own identifier for this person. Unique within the tenant, and the key that makes |
| account_status required | string (AccountStatus) Enum: "created" "suspended" "closed" Lifecycle of the end-user record itself. Independent of verification and of authorization to transact — see |
| verification_status required | string (VerificationStatus) Enum: "unverified" "pending" "verified" "failed" The outcome of identity verification, and only that. It records what was checked and how it resolved. It is never edited to express an access decision: to stop an end user transacting, revoke |
| transact_authorization required | string (TransactAuthorization) Enum: "authorized" "not_authorized" "revoked" Whether this end user may currently move money. This is the field a send affordance renders from, and the only one. Independently revocable at any time by the platform, without any change to |
| created_at required | string <date-time> |
| updated_at required | string <date-time> |
| first_name | string <= 100 characters |
| last_name | string <= 100 characters |
| date_of_birth | string <date> ISO 8601 date. Used by verification and screening. |
| country | string^[A-Z]{2}$ ISO 3166-1 alpha-2 country of residence. |
| phone | string E.164. The person's own contact number, not a destination. |
string <email> | |
| account_status_updated_at | string <date-time> When |
| verification_tier | string <= 64 characters The tier reached, as a server-defined opaque identifier. Present only once a verification has resolved. It is not a limit, and it does not map to one on the client: limits come from |
| transact_authorization_updated_at | string <date-time> When |
object <= 20 properties Tenant key/value metadata, echoed back. Never place credentials, document numbers or card data here. |
Request samples
- Payload
{- "first_name": "string",
- "last_name": "string",
- "date_of_birth": "2019-08-24",
- "phone": "string",
- "email": "user@example.com",
- "metadata": {
- "property1": "string",
- "property2": "string"
}
}Response samples
- 200
- 400
- 401
- 403
- 404
- 422
- 503
{- "id": "eu_7Qm4xK2pLd",
- "external_id": "tenant-user-000123",
- "first_name": "Test",
- "last_name": "Sender",
- "date_of_birth": "1990-01-01",
- "country": "CA",
- "phone": "+14165550000",
- "email": "test.sender@example.com",
- "account_status": "created",
- "account_status_updated_at": "2019-08-24T14:15:22Z",
- "verification_status": "unverified",
- "verification_tier": "tier_1",
- "transact_authorization": "authorized",
- "transact_authorization_updated_at": "2019-08-24T14:15:22Z",
- "metadata": {
- "property1": "string",
- "property2": "string"
}, - "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}Suspend an end user and revoke their authorization to transact
The named shorthand for the one transition partners reach for most:
equivalent to POST /end-users/{end_user_id}/state-transitions with
account_status: suspended, and transact_authorization: revoked
only where authorization is currently authorized. There is one
state machine, not two — this route is a convenience over it, and the
same audit record is written. Use the state-transition route for
anything else, including restoring access.
Why authorization is not revoked unconditionally. revoked means
withdrawn after having been granted. Writing it for an end user who
was never authorized would record a withdrawal of something never held
— a false entry on a retained audit record, and it would destroy the
distinction between not_authorized and revoked that makes the field
worth reading. A not_authorized end user is therefore left
not_authorized by this route; only account_status moves.
Scope: end_users:write.
verification_status is deliberately left untouched. Revoking
access and falsifying an identity record are different acts, and only
one of them is legal. The verification record is the evidence a
regulator asks for — who was checked, against what, when, with what
outcome — so it is never rewritten to express a present-day access
decision. That is what the separate, independently revocable
transact_authorization field exists for.
reason and actor are required: a suspension with no recorded cause
and no recorded initiator is not an audit trail.
Idempotent: suspending an already-suspended end user returns 200 with
the same state, not a conflict. In-flight payments are unaffected by
this call; the block applies to new payment creation.
Authorizations:
path Parameters
| end_user_id required | string^eu_[a-zA-Z0-9]+$ Example: eu_7Qm4xK2pLd |
Request Body schema: application/jsonrequired
| reason required | string [ 1 .. 500 ] characters Why this is being done, for the audit record. Recorded on the platform and never returned to any client or shown to an end user, so do not write it as end-user-facing copy — and do not put credentials, document numbers or case detail in it. |
required | object (Actor) Who initiated a state change, recorded on the audit record so that a transition can be attributed months later. Platform-initiated transitions are recorded with a platform actor that a partner cannot supply, so a partner can never attribute its own action to us. |
Responses
Response Schema: application/json
| id required | string^eu_[a-zA-Z0-9]+$ |
| external_id required | string <= 128 characters The tenant's own identifier for this person. Unique within the tenant, and the key that makes |
| account_status required | string (AccountStatus) Enum: "created" "suspended" "closed" Lifecycle of the end-user record itself. Independent of verification and of authorization to transact — see |
| verification_status required | string (VerificationStatus) Enum: "unverified" "pending" "verified" "failed" The outcome of identity verification, and only that. It records what was checked and how it resolved. It is never edited to express an access decision: to stop an end user transacting, revoke |
| transact_authorization required | string (TransactAuthorization) Enum: "authorized" "not_authorized" "revoked" Whether this end user may currently move money. This is the field a send affordance renders from, and the only one. Independently revocable at any time by the platform, without any change to |
| created_at required | string <date-time> |
| updated_at required | string <date-time> |
| first_name | string <= 100 characters |
| last_name | string <= 100 characters |
| date_of_birth | string <date> ISO 8601 date. Used by verification and screening. |
| country | string^[A-Z]{2}$ ISO 3166-1 alpha-2 country of residence. |
| phone | string E.164. The person's own contact number, not a destination. |
string <email> | |
| account_status_updated_at | string <date-time> When |
| verification_tier | string <= 64 characters The tier reached, as a server-defined opaque identifier. Present only once a verification has resolved. It is not a limit, and it does not map to one on the client: limits come from |
| transact_authorization_updated_at | string <date-time> When |
object <= 20 properties Tenant key/value metadata, echoed back. Never place credentials, document numbers or card data here. |
Request samples
- Payload
{- "reason": "string",
- "actor": {
- "type": "PARTNER_USER",
- "id": "partner-agent-0042"
}
}Response samples
- 200
- 400
- 401
- 403
- 404
- 409
- 422
{- "id": "eu_7Qm4xK2pLd",
- "external_id": "tenant-user-000123",
- "first_name": "Test",
- "last_name": "Sender",
- "date_of_birth": "1990-01-01",
- "country": "CA",
- "phone": "+14165550000",
- "email": "test.sender@example.com",
- "account_status": "created",
- "account_status_updated_at": "2019-08-24T14:15:22Z",
- "verification_status": "unverified",
- "verification_tier": "tier_1",
- "transact_authorization": "authorized",
- "transact_authorization_updated_at": "2019-08-24T14:15:22Z",
- "metadata": {
- "property1": "string",
- "property2": "string"
}, - "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}Submit the end user's customer due diligence (CDD) profile
Records the customer due diligence information the AML/ATF program requires before a consumer may send: residential address, occupation, expected activity (purpose, monthly frequency and value, destination countries), source of funds and a PEP/HIO self-declaration.
Scope: end_users:write.
Versioned, never edited. Each call records a new version; the newest is the current profile. Earlier versions are retained as the record of what was collected and when. Re-submit whenever the customer's information changes.
Accepted codes are compliance configuration. occupation_category,
expected_activity.purpose, monthly_count, monthly_value,
source_of_funds and pep_hio_declaration take lower-case codes from
the platform's compliance configuration. A code it does not accept is
422 validation_error on that field. The codes in force at launch are
listed on each property.
Required before sending. POST /payments naming an end user with no
profile — or with a code compliance has since retired — is
422 end_user_cdd_required. The platform rates the customer from this
profile; the rating is an internal compliance record and is not
returned. A customer the platform cannot serve without further review is
refused at payment creation with 422 end_user_review_required, and a
payment above a limit that applies to the customer with
422 end_user_limit_exceeded; neither discloses a rating or a limit.
Customer-facing wording for these refusals must not suggest a rating or
an investigation.
Authorizations:
path Parameters
| end_user_id required | string^eu_[a-zA-Z0-9]+$ Example: eu_7Qm4xK2pLd |
Request Body schema: application/jsonrequired
required | object (CddAddress) The customer's residential address. Personal information. |
| occupation required | string [ 1 .. 100 ] characters The customer's occupation in their own words (e.g. "Registered nurse"). Personal information. |
| occupation_category required | string^[a-z0-9_]{1,64}$ At launch: employed, self_employed, business_owner, retired, student, homemaker, unemployed, cash_intensive_business, money_services, other. |
required | object (CddExpectedActivity) What the customer expects to do. Actual activity is compared against it. |
| source_of_funds required | string^[a-z0-9_]{1,64}$ Where the money sent comes from. At launch: employment_income, pension_or_benefits, savings, business_income, investments, sale_of_assets, inheritance, gift, loan, other. |
| pep_hio_declaration required | string^[a-z0-9_]{1,64}$ The customer's politically exposed person / head of international organization self-declaration. At launch: none, domestic_pep, foreign_pep, hio, family_or_close_associate. Screening is separate and is not replaced by this declaration. |
required | object (Actor) Who initiated a state change, recorded on the audit record so that a transition can be attributed months later. Platform-initiated transitions are recorded with a platform actor that a partner cannot supply, so a partner can never attribute its own action to us. |
Responses
Response Schema: application/json
| end_user_id required | string |
required | object (CddAddress) The customer's residential address. Personal information. |
| occupation required | string |
| occupation_category required | string |
required | object (CddExpectedActivity) What the customer expects to do. Actual activity is compared against it. |
| source_of_funds required | string |
| pep_hio_declaration required | string |
| submitted_at required | string <date-time> |
Request samples
- Payload
{- "residential_address": {
- "line1": "string",
- "line2": "string",
- "city": "string",
- "region": "string",
- "postal_code": "string",
- "country": "string"
}, - "occupation": "string",
- "occupation_category": "string",
- "expected_activity": {
- "purpose": "string",
- "monthly_count": "string",
- "monthly_value": "string",
- "destination_countries": [
- "string"
]
}, - "source_of_funds": "string",
- "pep_hio_declaration": "string",
- "actor": {
- "type": "PARTNER_USER",
- "id": "partner-agent-0042"
}
}Response samples
- 201
- 400
- 401
- 403
- 404
- 422
{- "end_user_id": "string",
- "residential_address": {
- "line1": "string",
- "line2": "string",
- "city": "string",
- "region": "string",
- "postal_code": "string",
- "country": "string"
}, - "occupation": "string",
- "occupation_category": "string",
- "expected_activity": {
- "purpose": "string",
- "monthly_count": "string",
- "monthly_value": "string",
- "destination_countries": [
- "string"
]
}, - "source_of_funds": "string",
- "pep_hio_declaration": "string",
- "submitted_at": "2019-08-24T14:15:22Z"
}Read the end user's current CDD profile
The newest submitted profile. 404 cdd_profile_not_found when none has
been submitted; 404 end_user_not_found for an id that is not one of
your end users.
Scope: end_users:read.
Authorizations:
path Parameters
| end_user_id required | string^eu_[a-zA-Z0-9]+$ Example: eu_7Qm4xK2pLd |
Responses
Response Schema: application/json
| end_user_id required | string |
required | object (CddAddress) The customer's residential address. Personal information. |
| occupation required | string |
| occupation_category required | string |
required | object (CddExpectedActivity) What the customer expects to do. Actual activity is compared against it. |
| source_of_funds required | string |
| pep_hio_declaration required | string |
| submitted_at required | string <date-time> |
Response samples
- 200
- 401
- 403
- 404
{- "end_user_id": "string",
- "residential_address": {
- "line1": "string",
- "line2": "string",
- "city": "string",
- "region": "string",
- "postal_code": "string",
- "country": "string"
}, - "occupation": "string",
- "occupation_category": "string",
- "expected_activity": {
- "purpose": "string",
- "monthly_count": "string",
- "monthly_value": "string",
- "destination_countries": [
- "string"
]
}, - "source_of_funds": "string",
- "pep_hio_declaration": "string",
- "submitted_at": "2019-08-24T14:15:22Z"
}Move an end user's account status or authorization to transact
The single, bidirectional operation over the two settable states.
Set account_status, transact_authorization, or both in one atomic
transition; either can move in either direction, so there is no one-way
door and no separate "unsuspend" verb to keep in sync.
Scope: end_users:write.
verification_status is not settable here, in either direction.
Restoring authorization does not and cannot mark anyone verified, just
as revoking it does not unmark them: that field records what was
checked, and it is evidence, not a switch. Sending it returns
422 immutable_field. Restoring transact_authorization: authorized
may still be refused when the prerequisites are not met —
422 verification_required, or 422 identity_screening_required when
the end user's current name and phone have not cleared screening —
and a refusal leaves every state exactly as it was.
The trail runs both ways. reason and actor are required on every
transition, restoration included. A revocation with a recorded cause and
a restoration without one is a hole in the trail precisely where the
interesting question is asked — who let them back on, and on what
basis. Neither value is ever returned to a client or shown to an end
user; they are written to the audit record.
Permitted moves. account_status: created ↔ suspended.
closed is terminal — there is no transition out of it, and attempting
one is 409 invalid_state_transition.
transact_authorization: any of authorized, not_authorized,
revoked to any other. A transition to the state an end user is already
in is idempotent and returns 200.
Authorizations:
path Parameters
| end_user_id required | string^eu_[a-zA-Z0-9]+$ Example: eu_7Qm4xK2pLd |
Request Body schema: application/jsonrequired
| account_status required | string Enum: "created" "suspended" "closed" Target account status. |
| reason required | string [ 1 .. 500 ] characters Why, for the audit record — required on restorations as well as revocations. Never returned to any client and never shown to an end user. |
required | object (Actor) Who initiated a state change, recorded on the audit record so that a transition can be attributed months later. Platform-initiated transitions are recorded with a platform actor that a partner cannot supply, so a partner can never attribute its own action to us. |
| transact_authorization | string Enum: "authorized" "not_authorized" "revoked" Target authorization. Movable in both directions at any time. Moving it to |
Responses
Response Schema: application/json
| id required | string^eu_[a-zA-Z0-9]+$ |
| external_id required | string <= 128 characters The tenant's own identifier for this person. Unique within the tenant, and the key that makes |
| account_status required | string (AccountStatus) Enum: "created" "suspended" "closed" Lifecycle of the end-user record itself. Independent of verification and of authorization to transact — see |
| verification_status required | string (VerificationStatus) Enum: "unverified" "pending" "verified" "failed" The outcome of identity verification, and only that. It records what was checked and how it resolved. It is never edited to express an access decision: to stop an end user transacting, revoke |
| transact_authorization required | string (TransactAuthorization) Enum: "authorized" "not_authorized" "revoked" Whether this end user may currently move money. This is the field a send affordance renders from, and the only one. Independently revocable at any time by the platform, without any change to |
| created_at required | string <date-time> |
| updated_at required | string <date-time> |
| first_name | string <= 100 characters |
| last_name | string <= 100 characters |
| date_of_birth | string <date> ISO 8601 date. Used by verification and screening. |
| country | string^[A-Z]{2}$ ISO 3166-1 alpha-2 country of residence. |
| phone | string E.164. The person's own contact number, not a destination. |
string <email> | |
| account_status_updated_at | string <date-time> When |
| verification_tier | string <= 64 characters The tier reached, as a server-defined opaque identifier. Present only once a verification has resolved. It is not a limit, and it does not map to one on the client: limits come from |
| transact_authorization_updated_at | string <date-time> When |
object <= 20 properties Tenant key/value metadata, echoed back. Never place credentials, document numbers or card data here. |
Request samples
- Payload
{- "account_status": "created",
- "transact_authorization": "authorized",
- "reason": "string",
- "actor": {
- "type": "PARTNER_USER",
- "id": "partner-agent-0042"
}
}Response samples
- 200
- 400
- 401
- 403
- 404
- 409
- 422
{- "id": "eu_7Qm4xK2pLd",
- "external_id": "tenant-user-000123",
- "first_name": "Test",
- "last_name": "Sender",
- "date_of_birth": "1990-01-01",
- "country": "CA",
- "phone": "+14165550000",
- "email": "test.sender@example.com",
- "account_status": "created",
- "account_status_updated_at": "2019-08-24T14:15:22Z",
- "verification_status": "unverified",
- "verification_tier": "tier_1",
- "transact_authorization": "authorized",
- "transact_authorization_updated_at": "2019-08-24T14:15:22Z",
- "metadata": {
- "property1": "string",
- "property2": "string"
}, - "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}[Planned] Get this end user's remaining allowance
🔲 Contract published, not yet implemented.
Returns what this end user may still send on a corridor — not the
corridor's maxima, which is what GET /corridors/{corridor_code}
returns and is a different question. Without this, a client cannot say
"you can send $120 more today", and cannot explain a limit denial other
than by restating it.
Scope: end_users:read.
Read it; do not reimplement it. The remaining figure depends on aggregation the client cannot see — a 24-hour window across every payment, keyed on the end user, including payments in flight. A client that subtracts its own visible history will be wrong, and it will be wrong in the permissive direction. The platform remains the sole authority: a client renders these numbers and a denial, and never pre-decides one. Treat the response as point-in-time and re-read it before a send rather than caching it.
next_tier names the tier that would raise these limits and points at
the verification requirements to reach it, so a denial can tell a
customer what to do next instead of only what they cannot do. It carries
no limit values for that tier — those are a compliance determination and
are returned only once the tier is actually reached.
Authorizations:
path Parameters
| end_user_id required | string^eu_[a-zA-Z0-9]+$ Example: eu_7Qm4xK2pLd |
query Parameters
| corridor | string (CorridorCode) ^[A-Z]{2}-[A-Z]{2}$ Example: corridor=CA-TZ Limit the answer to one corridor. Omit to get every corridor this end user's tenant is entitled to. |
Responses
Response Schema: application/json
| as_of required | string <date-time> When these figures were computed. They move as the end user transacts and as rolling windows age out, so read them immediately before a send rather than caching them. |
required | Array of objects (EndUserCorridorLimits) One entry per corridor this end user's tenant is entitled to. |
object (NextTier) The tier that would raise these limits, and how to reach it — so a denial can tell a customer what to do next rather than only what they cannot do. It carries no limit values. Those are a compliance determination and are returned only once the tier is actually reached; publishing them early would let a client present an allowance nobody has approved. |
Response samples
- 200
- 401
- 403
- 404
{- "as_of": "2019-08-24T14:15:22Z",
- "corridors": [
- {
- "corridor": "CA-TZ",
- "currency": "CAD",
- "per_payment_max": {
- "amount": 10000,
- "currency": "CAD",
- "minor_unit_exponent": 2
}, - "per_day_remaining": {
- "amount": 10000,
- "currency": "CAD",
- "minor_unit_exponent": 2
}, - "per_month_remaining": {
- "amount": 10000,
- "currency": "CAD",
- "minor_unit_exponent": 2
}, - "payment_count_remaining": 0
}
], - "next_tier": {
- "tier": "tier_2",
- "display_name": "Verified",
}
}Identity verification of an end user — session handoff, status, tier, the backend-authored requirement list, and the tenant's own verification decision.
Start an identity verification
Opens a verification and returns a server-issued, single-use, expiring handoff the client can hand to the verification flow.
Scope: verifications:write.
The client never holds a vendor credential. handoff.reference (and
handoff.url, when the flow is hosted) are single-use and expire at
handoff.expires_at. They are capabilities, not identifiers: do not log
them, do not persist them, do not put them in a crash report or an
analytics event, and do not embed them in a durable deep link.
Anything the verification flow hands back to the client is untrusted
UI state. It is fine to use it to move a screen along. It is never
authority. Re-read GET /end-users/{end_user_id}/verifications/{verification_id}
for the decision and tier, and GET /end-users/{end_user_id} for
whether the end user may now transact. Those two reads are the only
authoritative answers.
Starting a second verification while one is still open returns
409 verification_in_progress with the open verification_id.
Authorizations:
path Parameters
| end_user_id required | string^eu_[a-zA-Z0-9]+$ Example: eu_7Qm4xK2pLd |
Request Body schema: application/json
| requested_tier | string <= 64 characters The tier you are asking for, as returned by |
object <= 20 properties |
Responses
Response Schema: application/json
| id required | string^ver_[a-zA-Z0-9]+$ |
| end_user_id required | string^eu_[a-zA-Z0-9]+$ |
| status required | string (VerificationSessionStatus) Enum: "created" "in_progress" "completed" "expired" "cancelled" Lifecycle of one verification attempt. Distinct from |
| created_at required | string <date-time> |
| decision | string Enum: "approved" "declined" "review" Present once the session resolves. A |
| tier | string <= 64 characters Tier reached, as a server-defined opaque identifier. Never map it to a limit on the client. |
object The single-use, expiring handoff into the verification flow. Returned at creation only. It is a capability, not an identifier: never log it, persist it, put it in a crash report or analytics event, or embed it in a durable deep link. The client holds no vendor credential at any point, and anything the flow hands back to the client is untrusted UI state — the authoritative result is this resource plus | |
| updated_at | string <date-time> |
| completed_at | string <date-time> |
Request samples
- Payload
{- "requested_tier": "tier_1",
- "metadata": {
- "property1": "string",
- "property2": "string"
}
}Response samples
- 201
- 400
- 401
- 403
- 404
- 409
- 422
- 503
{- "id": "ver_3Hb8tR1sVw",
- "end_user_id": "eu_7Qm4xK2pLd",
- "status": "created",
- "decision": "approved",
- "tier": "tier_1",
- "handoff": {
- "reference": "vfs_2b7f4c9e1a3d5f60",
- "expires_at": "2019-08-24T14:15:22Z"
}, - "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z",
- "completed_at": "2019-08-24T14:15:22Z"
}Get verification status
Returns the authoritative status, decision and tier for one verification. Poll this (or wait for your own notification) rather than trusting anything the verification flow returned to the device.
Scope: verifications:write.
A declined decision carries no reason beyond the decision itself. The
platform does not return the rule, the vendor signal or the list that
produced it — naming it would tell a bad actor exactly what to change.
Where a re-attempt is possible,
GET /end-users/{end_user_id}/verification-requirements says what is
still outstanding.
Authorizations:
path Parameters
| end_user_id required | string^eu_[a-zA-Z0-9]+$ Example: eu_7Qm4xK2pLd |
| verification_id required | string^ver_[a-zA-Z0-9]+$ Example: ver_3Hb8tR1sVw |
Responses
Response Schema: application/json
| id required | string^ver_[a-zA-Z0-9]+$ |
| end_user_id required | string^eu_[a-zA-Z0-9]+$ |
| status required | string (VerificationSessionStatus) Enum: "created" "in_progress" "completed" "expired" "cancelled" Lifecycle of one verification attempt. Distinct from |
| created_at required | string <date-time> |
| decision | string Enum: "approved" "declined" "review" Present once the session resolves. A |
| tier | string <= 64 characters Tier reached, as a server-defined opaque identifier. Never map it to a limit on the client. |
object The single-use, expiring handoff into the verification flow. Returned at creation only. It is a capability, not an identifier: never log it, persist it, put it in a crash report or analytics event, or embed it in a durable deep link. The client holds no vendor credential at any point, and anything the flow hands back to the client is untrusted UI state — the authoritative result is this resource plus | |
| updated_at | string <date-time> |
| completed_at | string <date-time> |
Response samples
- 200
- 401
- 403
- 404
{- "id": "ver_3Hb8tR1sVw",
- "end_user_id": "eu_7Qm4xK2pLd",
- "status": "created",
- "decision": "approved",
- "tier": "tier_1",
- "handoff": {
- "reference": "vfs_2b7f4c9e1a3d5f60",
- "expires_at": "2019-08-24T14:15:22Z"
}, - "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z",
- "completed_at": "2019-08-24T14:15:22Z"
}Record the tenant's own verification decision
For a tenant that runs its own identity check: records the outcome
against the end user, with an audit-trail entry whose actor is your
partner identity and whose reason carries decision and reference.
Returns the updated end user.
Scope: verifications:write.
approved— setsverification_status: verifiedand the tier reached, and, when the end user isaccount_status: createdandtransact_authorization: not_authorized, authorizes it to transact. This is what opens the send gate onPOST /payments.declined— setsverification_status: failed, unless alreadyverified(a decline never revokes a verified end user; usePOST /end-users/{end_user_id}/state-transitions).review— setsverification_status: pending, unless alreadyverified.expired— movespendingback tounverified; otherwise no change.
Repeating a decision is idempotent. decision outside the four values,
or an empty reference, is 422 validation_error.
Authorizations:
path Parameters
| end_user_id required | string^eu_[a-zA-Z0-9]+$ Example: eu_7Qm4xK2pLd |
Request Body schema: application/jsonrequired
| decision required | string Enum: "approved" "declined" "review" "expired" |
| reference required | string non-empty Your reference for the check (your case or inquiry id), recorded in the audit trail. |
Responses
Response Schema: application/json
| id required | string^eu_[a-zA-Z0-9]+$ |
| external_id required | string <= 128 characters The tenant's own identifier for this person. Unique within the tenant, and the key that makes |
| account_status required | string (AccountStatus) Enum: "created" "suspended" "closed" Lifecycle of the end-user record itself. Independent of verification and of authorization to transact — see |
| verification_status required | string (VerificationStatus) Enum: "unverified" "pending" "verified" "failed" The outcome of identity verification, and only that. It records what was checked and how it resolved. It is never edited to express an access decision: to stop an end user transacting, revoke |
| transact_authorization required | string (TransactAuthorization) Enum: "authorized" "not_authorized" "revoked" Whether this end user may currently move money. This is the field a send affordance renders from, and the only one. Independently revocable at any time by the platform, without any change to |
| created_at required | string <date-time> |
| updated_at required | string <date-time> |
| first_name | string <= 100 characters |
| last_name | string <= 100 characters |
| date_of_birth | string <date> ISO 8601 date. Used by verification and screening. |
| country | string^[A-Z]{2}$ ISO 3166-1 alpha-2 country of residence. |
| phone | string E.164. The person's own contact number, not a destination. |
string <email> | |
| account_status_updated_at | string <date-time> When |
| verification_tier | string <= 64 characters The tier reached, as a server-defined opaque identifier. Present only once a verification has resolved. It is not a limit, and it does not map to one on the client: limits come from |
| transact_authorization_updated_at | string <date-time> When |
object <= 20 properties Tenant key/value metadata, echoed back. Never place credentials, document numbers or card data here. |
Request samples
- Payload
{- "decision": "approved",
- "reference": "kyc_case_8812"
}Response samples
- 200
- 400
- 401
- 403
- 404
- 422
{- "id": "eu_7Qm4xK2pLd",
- "external_id": "tenant-user-000123",
- "first_name": "Test",
- "last_name": "Sender",
- "date_of_birth": "1990-01-01",
- "country": "CA",
- "phone": "+14165550000",
- "email": "test.sender@example.com",
- "account_status": "created",
- "account_status_updated_at": "2019-08-24T14:15:22Z",
- "verification_status": "unverified",
- "verification_tier": "tier_1",
- "transact_authorization": "authorized",
- "transact_authorization_updated_at": "2019-08-24T14:15:22Z",
- "metadata": {
- "property1": "string",
- "property2": "string"
}, - "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}Get the outstanding verification requirements
Returns the ordered, backend-authored list of steps this end user must complete, with the per-step field validation to apply. Render the steps in array order.
Scope: verifications:write.
No tier→limit table is ever returned, and none may be held on a
device. Steps carry stable keys, not display copy: the tenant owns
its own wording and localisation and looks it up by key, so changing a
rule here never ships a string to an app store. Eligibility is not
computed on a phone — client-side validation of these fields is a
courtesy to the user, and the platform re-validates everything and
remains the only authority on what an end user may do.
The list is a point-in-time answer (generated_at); re-read it after
every submission rather than caching it across a session.
Authorizations:
path Parameters
| end_user_id required | string^eu_[a-zA-Z0-9]+$ Example: eu_7Qm4xK2pLd |
Responses
Response Schema: application/json
| end_user_id required | string^eu_[a-zA-Z0-9]+$ |
| verification_status required | string (VerificationStatus) Enum: "unverified" "pending" "verified" "failed" The outcome of identity verification, and only that. It records what was checked and how it resolved. It is never edited to express an access decision: to stop an end user transacting, revoke |
required | Array of objects (VerificationRequirementStep) Ordered. Render in array order; the order is a platform decision, not a display preference. |
| generated_at required | string <date-time> When this answer was computed. It is point-in-time — re-read after each submission rather than caching it across a session. |
| current_tier | string <= 64 characters Tier reached so far, if any. Opaque identifier. |
| target_tier | string <= 64 characters The tier these steps would reach. Opaque identifier. |
Response samples
- 200
- 401
- 403
- 404
{- "end_user_id": "eu_7Qm4xK2pLd",
- "verification_status": "unverified",
- "current_tier": "tier_0",
- "target_tier": "tier_1",
- "steps": [
- {
- "key": "government_id",
- "type": "document",
- "status": "pending",
- "required": true,
- "remediation": "A clearer photo of the back of your driver's licence.",
- "fields": [
- {
- "name": "postal_code",
- "type": "string",
- "required": true,
- "pattern": "string",
- "max_length": 1,
- "options": [
- "string"
]
}
]
}
], - "generated_at": "2019-08-24T14:15:22Z"
}An end user's saved beneficiaries. Screened at save; usable only after a screening decision returns.
List an end user's recipients
Returns the end user's saved beneficiaries, newest first.
Scope: recipients:read.
Destination MSISDNs (Recipient.phone) are returned in full on every
read — the recipient is the end user's own saved contact, and the
number is not masked.
Authorizations:
path Parameters
| end_user_id required | string^eu_[a-zA-Z0-9]+$ Example: eu_7Qm4xK2pLd |
query Parameters
| status | Array of strings (RecipientStatus) <= 3 items Items Enum: "pending_screening" "approved" "rejected" "unverified" Return only recipients in these statuses. Repeat to match several. |
Responses
Response Schema: application/json
required | Array of objects (Recipient) Recipients, newest first. |
Response samples
- 200
- 401
- 403
- 404
{- "data": [
- {
- "relationship": "SELF",
- "id": "rcp_9Zd5nP6yTc",
- "end_user_id": "eu_7Qm4xK2pLd",
- "status": "pending_screening",
- "beneficiary_key": "bk_5f4a2c8e9d1b7306",
- "display_name": "Sample Recipient",
- "nickname": "Home",
- "type": "INDIVIDUAL",
- "country": "TZ",
- "phone": "+255712345678",
- "payout_method": "MOBILE_MONEY",
- "wallet_network": "NETWORK_A",
- "name_check": {
- "result": "MATCH",
- "checked_at": "2019-08-24T14:15:22Z"
}, - "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}
]
}Save a recipient
Saves a beneficiary to the end user's book and submits it for screening.
Scope: recipients:write.
The beneficiary is screened at save, before it is stored.
Screening happens at save, not at send, so a user is not told at the
moment of payment that their recipient is unusable. The recipient is
stored once, with its decision: approved, or rejected (no
explanation is given). A recipient is never usable before a decision:
if the screening dependency does not answer, nothing is saved and the
response is 503 screening_unavailable — retry the save. Treat any
status other than approved as not selectable, including
pending_screening, which a recipient can still hold. Referencing a
recipient that is not approved on a payment is
422 recipient_not_approved, with no screening detail attached.
Stable beneficiary key. The platform derives beneficiary_key from
exactly three inputs — name, destination MSISDN and destination country
— because 24-hour aggregation and velocity rules are keyed on the
beneficiary, and a phone string alone is not a stable enough identity.
Saving a beneficiary whose key already exists in this end user's book
returns 409 duplicate_recipient.
Authorizations:
path Parameters
| end_user_id required | string^eu_[a-zA-Z0-9]+$ Example: eu_7Qm4xK2pLd |
Request Body schema: application/jsonrequired
| type required | string Enum: "INDIVIDUAL" "BUSINESS" |
| country required | string^[A-Z]{2}$ ISO 3166-1 alpha-2 destination country. Part of the beneficiary key. |
| phone required | string Destination MSISDN in E.164. Part of the beneficiary key. Reads return it in full ( |
| payout_method required | string (PayoutMethod) Enum: "MOBILE_MONEY" "BANK_TRANSFER" |
| relationship | string (BeneficiaryRelationship) Enum: "SELF" "SPOUSE" "PARENT" "CHILD" "SIBLING" "OTHER_FAMILY" "FRIEND" "BUSINESS" "OTHER" The sender's stated relationship to this beneficiary.
Platform data, not client state. It is a record-keeping element for a remittance — a sender-beneficiary relationship is the kind of thing an examiner asks about, and it also feeds risk: a first payment to a newly-added beneficiary described as |
| first_name | string <= 100 characters Required for an |
| last_name | string <= 100 characters Required for an |
| business_name | string <= 200 characters Required for a |
| wallet_network | string^[A-Z][A-Z0-9_]{1,31}$ Optional. Validated against the |
| nickname | string <= 60 characters The end user's own label. Not part of the beneficiary key. |
Responses
Response Schema: application/json
| id required | string^rcp_[a-zA-Z0-9]+$ |
| end_user_id required | string^eu_[a-zA-Z0-9]+$ |
| status required | string (RecipientStatus) Enum: "pending_screening" "approved" "rejected" "unverified" Screening state of a saved beneficiary. Only
|
| beneficiary_key required | string <= 128 characters Stable identity of the beneficiary, derived by the platform from exactly three inputs — name, destination MSISDN and destination country. Velocity and 24-hour aggregation are keyed on it, which is why it cannot be the phone string alone. Stable across edits that touch none of those three; a change to any of them yields a new key and a re-screen. |
| display_name required | string <= 200 characters The beneficiary's name as it will be used for payout. |
| country required | string^[A-Z]{2}$ ISO 3166-1 alpha-2 destination country. |
| phone required | string E.164 destination MSISDN, in full. |
| created_at required | string <date-time> |
| updated_at required | string <date-time> |
| relationship | string (BeneficiaryRelationship) Enum: "SELF" "SPOUSE" "PARENT" "CHILD" "SIBLING" "OTHER_FAMILY" "FRIEND" "BUSINESS" "OTHER" The sender's stated relationship to this beneficiary.
Platform data, not client state. It is a record-keeping element for a remittance — a sender-beneficiary relationship is the kind of thing an examiner asks about, and it also feeds risk: a first payment to a newly-added beneficiary described as |
| nickname | string <= 60 characters The end user's own label for this recipient. Not part of the beneficiary key, and editing it does not re-screen. |
| type | string Enum: "INDIVIDUAL" "BUSINESS" |
| payout_method | string (PayoutMethod) Enum: "MOBILE_MONEY" "BANK_TRANSFER" |
| wallet_network | string^[A-Z][A-Z0-9_]{1,31}$ The mobile-money network this beneficiary is on, as supplied at save. Present only when supplied; never guessed from the MSISDN prefix. |
object moja-status: planned 🔲 Planned — not emitted. Whether the beneficiary name matches the name registered on the destination wallet. Absent when no check has run yet — absent is not a pass. |
Request samples
- Payload
{- "relationship": "SELF",
- "type": "INDIVIDUAL",
- "first_name": "Sample",
- "last_name": "Recipient",
- "business_name": "string",
- "country": "TZ",
- "phone": "+255700000001",
- "payout_method": "MOBILE_MONEY",
- "wallet_network": "NETWORK_A",
- "nickname": "Home"
}Response samples
- 201
- 400
- 401
- 403
- 404
- 409
- 422
- 503
{- "relationship": "SELF",
- "id": "rcp_9Zd5nP6yTc",
- "end_user_id": "eu_7Qm4xK2pLd",
- "status": "pending_screening",
- "beneficiary_key": "bk_5f4a2c8e9d1b7306",
- "display_name": "Sample Recipient",
- "nickname": "Home",
- "type": "INDIVIDUAL",
- "country": "TZ",
- "phone": "+255712345678",
- "payout_method": "MOBILE_MONEY",
- "wallet_network": "NETWORK_A",
- "name_check": {
- "result": "MATCH",
- "checked_at": "2019-08-24T14:15:22Z"
}, - "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}Get a recipient
Returns one saved beneficiary, with the destination MSISDN masked.
Scope: recipients:read.
Authorizations:
path Parameters
| end_user_id required | string^eu_[a-zA-Z0-9]+$ Example: eu_7Qm4xK2pLd |
| recipient_id required | string^rcp_[a-zA-Z0-9]+$ Example: rcp_9Zd5nP6yTc |
Responses
Response Schema: application/json
| id required | string^rcp_[a-zA-Z0-9]+$ |
| end_user_id required | string^eu_[a-zA-Z0-9]+$ |
| status required | string (RecipientStatus) Enum: "pending_screening" "approved" "rejected" "unverified" Screening state of a saved beneficiary. Only
|
| beneficiary_key required | string <= 128 characters Stable identity of the beneficiary, derived by the platform from exactly three inputs — name, destination MSISDN and destination country. Velocity and 24-hour aggregation are keyed on it, which is why it cannot be the phone string alone. Stable across edits that touch none of those three; a change to any of them yields a new key and a re-screen. |
| display_name required | string <= 200 characters The beneficiary's name as it will be used for payout. |
| country required | string^[A-Z]{2}$ ISO 3166-1 alpha-2 destination country. |
| phone required | string E.164 destination MSISDN, in full. |
| created_at required | string <date-time> |
| updated_at required | string <date-time> |
| relationship | string (BeneficiaryRelationship) Enum: "SELF" "SPOUSE" "PARENT" "CHILD" "SIBLING" "OTHER_FAMILY" "FRIEND" "BUSINESS" "OTHER" The sender's stated relationship to this beneficiary.
Platform data, not client state. It is a record-keeping element for a remittance — a sender-beneficiary relationship is the kind of thing an examiner asks about, and it also feeds risk: a first payment to a newly-added beneficiary described as |
| nickname | string <= 60 characters The end user's own label for this recipient. Not part of the beneficiary key, and editing it does not re-screen. |
| type | string Enum: "INDIVIDUAL" "BUSINESS" |
| payout_method | string (PayoutMethod) Enum: "MOBILE_MONEY" "BANK_TRANSFER" |
| wallet_network | string^[A-Z][A-Z0-9_]{1,31}$ The mobile-money network this beneficiary is on, as supplied at save. Present only when supplied; never guessed from the MSISDN prefix. |
object moja-status: planned 🔲 Planned — not emitted. Whether the beneficiary name matches the name registered on the destination wallet. Absent when no check has run yet — absent is not a pass. |
Response samples
- 200
- 401
- 403
- 404
{- "relationship": "SELF",
- "id": "rcp_9Zd5nP6yTc",
- "end_user_id": "eu_7Qm4xK2pLd",
- "status": "pending_screening",
- "beneficiary_key": "bk_5f4a2c8e9d1b7306",
- "display_name": "Sample Recipient",
- "nickname": "Home",
- "type": "INDIVIDUAL",
- "country": "TZ",
- "phone": "+255712345678",
- "payout_method": "MOBILE_MONEY",
- "wallet_network": "NETWORK_A",
- "name_check": {
- "result": "MATCH",
- "checked_at": "2019-08-24T14:15:22Z"
}, - "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}Update a recipient
Updates a saved beneficiary. Omitted fields are unchanged; an explicit
null is 422 validation_error.
Scope: recipients:write.
Editing any component of the beneficiary key — name, MSISDN or
country — produces a new beneficiary_key, resets status to
pending_screening, and re-screens. The new details are screened
before they are written, so the response carries the new decision; if
the screening dependency does not answer, nothing changes and the
response is 503 screening_unavailable. The recipient is never usable
on details that have not been screened. Editing only nickname does
not change the key and does not re-screen: a label is not an identity,
and re-screening on a rename would be noise that trains people to ignore
the state.
Authorizations:
path Parameters
| end_user_id required | string^eu_[a-zA-Z0-9]+$ Example: eu_7Qm4xK2pLd |
| recipient_id required | string^rcp_[a-zA-Z0-9]+$ Example: rcp_9Zd5nP6yTc |
Request Body schema: application/jsonrequired
non-empty | first_name | string <= 100 characters |
| last_name | string <= 100 characters |
| business_name | string <= 200 characters |
| country | string^[A-Z]{2}$ |
| phone | string Destination MSISDN in E.164. |
| payout_method | string (PayoutMethod) Enum: "MOBILE_MONEY" "BANK_TRANSFER" |
| wallet_network | string^[A-Z][A-Z0-9_]{1,31}$ Not part of the beneficiary key, so changing it does not re-screen. |
| relationship | string (BeneficiaryRelationship) Enum: "SELF" "SPOUSE" "PARENT" "CHILD" "SIBLING" "OTHER_FAMILY" "FRIEND" "BUSINESS" "OTHER" The sender's stated relationship to this beneficiary.
Platform data, not client state. It is a record-keeping element for a remittance — a sender-beneficiary relationship is the kind of thing an examiner asks about, and it also feeds risk: a first payment to a newly-added beneficiary described as |
| nickname | string <= 60 characters |
Responses
Response Schema: application/json
| id required | string^rcp_[a-zA-Z0-9]+$ |
| end_user_id required | string^eu_[a-zA-Z0-9]+$ |
| status required | string (RecipientStatus) Enum: "pending_screening" "approved" "rejected" "unverified" Screening state of a saved beneficiary. Only
|
| beneficiary_key required | string <= 128 characters Stable identity of the beneficiary, derived by the platform from exactly three inputs — name, destination MSISDN and destination country. Velocity and 24-hour aggregation are keyed on it, which is why it cannot be the phone string alone. Stable across edits that touch none of those three; a change to any of them yields a new key and a re-screen. |
| display_name required | string <= 200 characters The beneficiary's name as it will be used for payout. |
| country required | string^[A-Z]{2}$ ISO 3166-1 alpha-2 destination country. |
| phone required | string E.164 destination MSISDN, in full. |
| created_at required | string <date-time> |
| updated_at required | string <date-time> |
| relationship | string (BeneficiaryRelationship) Enum: "SELF" "SPOUSE" "PARENT" "CHILD" "SIBLING" "OTHER_FAMILY" "FRIEND" "BUSINESS" "OTHER" The sender's stated relationship to this beneficiary.
Platform data, not client state. It is a record-keeping element for a remittance — a sender-beneficiary relationship is the kind of thing an examiner asks about, and it also feeds risk: a first payment to a newly-added beneficiary described as |
| nickname | string <= 60 characters The end user's own label for this recipient. Not part of the beneficiary key, and editing it does not re-screen. |
| type | string Enum: "INDIVIDUAL" "BUSINESS" |
| payout_method | string (PayoutMethod) Enum: "MOBILE_MONEY" "BANK_TRANSFER" |
| wallet_network | string^[A-Z][A-Z0-9_]{1,31}$ The mobile-money network this beneficiary is on, as supplied at save. Present only when supplied; never guessed from the MSISDN prefix. |
object moja-status: planned 🔲 Planned — not emitted. Whether the beneficiary name matches the name registered on the destination wallet. Absent when no check has run yet — absent is not a pass. |
Request samples
- Payload
{- "first_name": "string",
- "last_name": "string",
- "business_name": "string",
- "country": "string",
- "phone": "string",
- "payout_method": "MOBILE_MONEY",
- "wallet_network": "NETWORK_A",
- "relationship": "SELF",
- "nickname": "string"
}Response samples
- 200
- 400
- 401
- 403
- 404
- 409
- 422
- 503
{- "relationship": "SELF",
- "id": "rcp_9Zd5nP6yTc",
- "end_user_id": "eu_7Qm4xK2pLd",
- "status": "pending_screening",
- "beneficiary_key": "bk_5f4a2c8e9d1b7306",
- "display_name": "Sample Recipient",
- "nickname": "Home",
- "type": "INDIVIDUAL",
- "country": "TZ",
- "phone": "+255712345678",
- "payout_method": "MOBILE_MONEY",
- "wallet_network": "NETWORK_A",
- "name_check": {
- "result": "MATCH",
- "checked_at": "2019-08-24T14:15:22Z"
}, - "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}Remove a recipient
Removes the beneficiary from the end user's book. Idempotent: deleting
an already-deleted recipient returns 204.
Scope: recipients:write.
This removes the saved entry only. Payments already made to this beneficiary, and the screening record behind them, are retained — they are transaction and compliance records with their own retention rules, and a user removing an address-book entry does not and cannot delete them.
Authorizations:
path Parameters
| end_user_id required | string^eu_[a-zA-Z0-9]+$ Example: eu_7Qm4xK2pLd |
| recipient_id required | string^rcp_[a-zA-Z0-9]+$ Example: rcp_9Zd5nP6yTc |
Responses
Response samples
- 401
- 403
- 404
{- "error": {
- "code": "string",
- "message": "string",
- "payment_id": "string",
- "request_id": "string",
- "details": [
- {
- "field": "string",
- "message": "string"
}
], - "end_user_id": "string",
- "next_available_at": "2019-08-24T14:15:22Z"
}
}[Planned] Register a device for notifications
Registers a device so the platform can notify this end user.
Scope: end_users:write.
Provider-agnostic by construction. push_token is an opaque string;
this contract names no push provider and carries no provider-specific
field, so changing or adding a delivery provider is not a contract
change. platform describes the device class, not a transport.
The token is write-only. It is never returned by any endpoint, never
echoed in an error, and never logged. Re-registering the same token for
the same end user returns the existing registration (200) rather than
accumulating duplicates.
What a notification may contain. A delivered notification carries an opaque reference only — no name, no amount, no currency, no MSISDN, no corridor, no status text. A lock screen is a public surface and push payloads traverse a third party. The app receives the reference and fetches the detail over this authenticated API, which is also what keeps the notification honest when the underlying state has since changed.
Authorizations:
path Parameters
| end_user_id required | string^eu_[a-zA-Z0-9]+$ Example: eu_7Qm4xK2pLd |
Request Body schema: application/jsonrequired
| platform required | string (DevicePlatform) Enum: "IOS" "ANDROID" "WEB" Device class. Deliberately not a push provider: this contract names no notification vendor, so the platform can change or add delivery providers without a contract change. |
| push_token required | string <= 4096 characters Opaque provider token for this device. Write-only: never returned by any endpoint, never echoed in an error, never logged. |
| device_label | string <= 60 characters Optional end-user-visible label for a device list. Must not contain personal data beyond what the user typed. |
Responses
Response Schema: application/json
| id required | string^dev_[a-zA-Z0-9]+$ |
| platform required | string (DevicePlatform) Enum: "IOS" "ANDROID" "WEB" Device class. Deliberately not a push provider: this contract names no notification vendor, so the platform can change or add delivery providers without a contract change. |
| created_at required | string <date-time> |
| device_label | string <= 60 characters |
| last_seen_at | string <date-time> |
Response Schema: application/json
| id required | string^dev_[a-zA-Z0-9]+$ |
| platform required | string (DevicePlatform) Enum: "IOS" "ANDROID" "WEB" Device class. Deliberately not a push provider: this contract names no notification vendor, so the platform can change or add delivery providers without a contract change. |
| created_at required | string <date-time> |
| device_label | string <= 60 characters |
| last_seen_at | string <date-time> |
Request samples
- Payload
{- "platform": "IOS",
- "push_token": "opaque-device-token-000000",
- "device_label": "string"
}Response samples
- 200
- 201
- 400
- 401
- 403
- 404
- 422
{- "id": "dev_2Fk7wQ4mNb",
- "platform": "IOS",
- "device_label": "string",
- "created_at": "2019-08-24T14:15:22Z",
- "last_seen_at": "2019-08-24T14:15:22Z"
}[Planned] Deregister a device
Removes a device registration — on sign-out, on token rotation, or on
"log out everywhere". Idempotent: 204 whether or not it was present.
Scope: end_users:write.
Authorizations:
path Parameters
| end_user_id required | string^eu_[a-zA-Z0-9]+$ Example: eu_7Qm4xK2pLd |
| device_id required | string^dev_[a-zA-Z0-9]+$ Example: dev_2Fk7wQ4mNb |
Responses
Response samples
- 401
- 403
- 404
{- "error": {
- "code": "string",
- "message": "string",
- "payment_id": "string",
- "request_id": "string",
- "details": [
- {
- "field": "string",
- "message": "string"
}
], - "end_user_id": "string",
- "next_available_at": "2019-08-24T14:15:22Z"
}
}[Planned] List an end user's support cases
🔲 Contract published, not yet implemented.
Returns this end user's cases, newest first, so a customer can see what they have already raised. A support channel a customer can write to but not read from produces duplicate cases and a customer who believes they were ignored.
Scope: end_users:write.
Authorizations:
path Parameters
| end_user_id required | string^eu_[a-zA-Z0-9]+$ Example: eu_7Qm4xK2pLd |
query Parameters
| status | Array of strings (SupportCaseStatus) <= 4 items Items Enum: "open" "in_progress" "resolved" "closed" Return only cases in these statuses. Repeat to match several. |
Responses
Response Schema: application/json
required | Array of objects (SupportCase) Cases, ordered |
Response samples
- 200
- 401
- 403
- 404
{- "data": [
- {
- "id": "sup_4Ln8vC3hJk",
- "end_user_id": "eu_7Qm4xK2pLd",
- "case_number": "CASE-2026-0004567",
- "category": "PAYMENT_ISSUE",
- "status": "open",
- "payment_id": "pay_abc123def456",
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}
]
}[Planned] Open a support case
Creates a durable support case for an end user and returns its reference. Intended for in-app intake, and durable so that a case survives the app session it was raised in.
Scope: end_users:write.
message is stored verbatim as part of the case record. Do not place
credentials, card numbers, document images or API keys in it, and do not
prompt a user for them in the surface that feeds it — a support case is
read by humans and retained.
Authorizations:
path Parameters
| end_user_id required | string^eu_[a-zA-Z0-9]+$ Example: eu_7Qm4xK2pLd |
Request Body schema: application/jsonrequired
| category required | string (SupportCaseCategory) Enum: "PAYMENT_ISSUE" "RECIPIENT_ISSUE" "VERIFICATION_ISSUE" "ACCOUNT_ISSUE" "OTHER" |
| message required | string <= 2000 characters What the end user reported, stored verbatim on a durable case record read by humans. Never place credentials, card numbers, document images or API keys in it. |
| payment_id | string^pay_[a-zA-Z0-9]+$ The payment the case is about, when there is one. |
| contact_email | string <email> Reply address, when it differs from the end user's profile email. |
Responses
Response Schema: application/json
| id required | string^sup_[a-zA-Z0-9]+$ |
| end_user_id required | string^eu_[a-zA-Z0-9]+$ |
| case_number required | string Human-quotable reference to give the end user. Stable for the life of the case. |
| category required | string (SupportCaseCategory) Enum: "PAYMENT_ISSUE" "RECIPIENT_ISSUE" "VERIFICATION_ISSUE" "ACCOUNT_ISSUE" "OTHER" |
| status required | string (SupportCaseStatus) Enum: "open" "in_progress" "resolved" "closed" Treat an unrecognised value as open. |
| created_at required | string <date-time> |
| payment_id | string^pay_[a-zA-Z0-9]+$ |
| updated_at | string <date-time> |
Request samples
- Payload
{- "category": "PAYMENT_ISSUE",
- "message": "string",
- "payment_id": "pay_abc123def456",
- "contact_email": "test.sender@example.com"
}Response samples
- 201
- 400
- 401
- 403
- 404
- 422
{- "id": "sup_4Ln8vC3hJk",
- "end_user_id": "eu_7Qm4xK2pLd",
- "case_number": "CASE-2026-0004567",
- "category": "PAYMENT_ISSUE",
- "status": "open",
- "payment_id": "pay_abc123def456",
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}Partner account bootstrap and readiness. POST /signup is public
(no API key); readiness and client-config require scope read.
Self-serve sandbox partner signup
Provisions instant sandbox access for a new partner and returns a
one-time sk_sandbox_* API key. No compliance step — sandbox moves no
real money. The new partner account has status: SANDBOX and is not
ready for production. This operation can never issue a production key
or a live-capable account.
Public by design. There is no API key before signup, so this operation needs none. It is rate-limited per client IP address.
Request Body schema: application/jsonrequired
| name required | string non-empty Display name for the new sandbox partner |
| contact_email | string <email> Optional contact email for the partner |
Responses
Response Schema: application/json
| id required | string Partner id ( |
| name required | string |
| status required | string Always |
required | Array of objects |
| created_at required | string <date-time> |
| updated_at required | string <date-time> |
required | object |
| contact_email | string <email> |
Request samples
- Payload
{- "name": "Acme Remit",
- "contact_email": "user@example.com"
}Response samples
- 201
- 400
- 429
{- "id": "string",
- "name": "string",
- "status": "SANDBOX",
- "contact_email": "user@example.com",
- "corridor_entitlements": [
- {
- "corridor": "string",
- "enabled": true
}
], - "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z",
- "api_key": {
- "id": "string",
- "environment": "sandbox",
- "key_prefix": "string",
- "secret": "string"
}
}This partner's own production go-live readiness
The partner's composite production go-live projection — the same rows
the money-path gate reasons from, so can_send_live here always matches
whether a live payment would actually clear the gate.
Tenant-scoped by construction. The partner is taken from the authenticated API key, never from a path or body field.
Corridor capability is evaluated for CA-TZ today — the
capability requirement is met when the partner holds an
enabled entitlement to CA-TZ. 🔲 Planned: a corridor-agnostic
default (any enabled catalogue corridor) and the corridor query
parameter to ask about one corridor; today the parameter is ignored.
requirements[].blocking marks the enforced dimensions — Compliance,
Funding, corridor capability — whose failure prevents go-live. Treasury
and commercial are carried for transparency and never block.
If readiness cannot be read, the answer is 500 internal_error, never
an account reported as ready by mistake. Sandbox traffic is not gated, because it moves
no real money.
No corridor entitlement is required to call it: it reports your entitlement rather than using one.
Scope: read.
Authorizations:
query Parameters
| corridor | string (CorridorCode) ^[A-Z]{2}-[A-Z]{2}$ Example: corridor=CA-TZ moja-status: planned 🔲 Planned — ignored today (capability is evaluated for |
Responses
Response Schema: application/json
| environment required | string The environment this projection describes. |
| readiness required | string Enum: "ready" "not_ready" |
| can_send_live required | boolean The readiness fold and corridor capability. True only when a live payment would actually clear the gate. |
required | Array of objects (ReadinessRequirement) |
Response samples
- 200
- 401
- 403
- 500
{- "environment": "string",
- "readiness": "ready",
- "can_send_live": true,
- "requirements": [
- {
- "key": "compliance",
- "status": "string",
- "blocking": true,
- "reason": "capability_not_enabled",
- "message": "Live sending is not yet enabled on any corridor.",
}
]
}Sandbox-only partner actions that move no money state (payout scenario configuration; webhook redelivery of an already-emitted event).
Set sandbox payout scenario for the next payout
Configures the tenant's next sandbox payout outcome
(paid | delayed | failed_before_send | returned). The value is
snapshotted onto the payout row at CREATED. Moves no money.
Scope: write. Sandbox API keys only.
Authorizations:
Request Body schema: application/jsonrequired
| payout_outcome required | string Enum: "paid" "delayed" "failed_before_send" "returned" |
Responses
Response Schema: application/json
| payout_outcome required | string |
Request samples
- Payload
{- "payout_outcome": "paid"
}Response samples
- 200
- 401
- 403
- 422
{- "payout_outcome": "string"
}Re-deliver an already-emitted sandbox webhook
Re-queues an already-delivered event (same event_id, same body, fresh
signature) so the tenant can exercise duplicate handling. Moves no money
state.
Scope: write. Sandbox API keys only.
Authorizations:
path Parameters
| event_id required | string |
Responses
Response samples
- 202
- 401
- 403
- 404
{- "event_id": "string",
- "status": "queued"
}