Off-Ramp Payments (Pay a Phone)
An off-ramp payment pays a phone number. SafeBank resolves the number to a recipient, quotes the routes that can carry the money to that recipient's own payout destinations, and — once the recipient consents — pays out through the chosen rail. If the number resolves to nobody, the money waits as a pending claim until the recipient onboards.
This is the POST /v1/offramp/payments surface (scopes ramps:read /
ramps:write). It is distinct from the legacy simulated
/v1/ramps module and from /v1/payments (the Safe-debit
machine) — neither is touched by it.
v1 funding is INTERNAL_BALANCE only. ONCHAIN_DEPOSIT funding ships
with the escrow-indexer increment and SAFE_PROPOSAL with the settlement
orchestrator increment; requesting either today returns an honest
400 FUNDING_MODE_NOT_AVAILABLE, never a silent stub.
The four delivery moments
Every payment reports one of four honest moments — never the word "instant":
| Moment | Meaning | Payment statuses |
|---|---|---|
| Accepted | SafeBank validated and recorded the request | CREATED … POLICY_CHECK |
| Secured | Funds are confirmed / reserved / escrow-locked | SOURCE_FUNDS_CONFIRMED, FUNDS_LOCKED |
| Submitted | The payout provider accepted the request | PROVIDER_SUBMITTED, PROVIDER_PROCESSING |
| Delivered | The provider reported terminal success | DELIVERED |
A payment is never marked delivered on a 2xx create response from a provider
— only on the provider's terminal success status, confirmed by webhook or
poll. DELIVERED is terminal and never un-delivers.
Create a payment
curl -X POST https://api.sandbox.safebank.ai/v1/offramp/payments \
-H "X-SafeBank-Api-Key: $SAFEBANK_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "phone": "+15559876543", "amountUsd": "25.00" }'
Optional body fields: chainId (default 84532), fundingMode
(INTERNAL_BALANCE today), requestedDestinationId (must be one of the
recipient's ACTIVE destinations — server-verified, 403 DESTINATION_NOT_ELIGIBLE otherwise), speedPreference
(FASTEST | CHEAPEST).
When the phone resolves to an onboarded recipient with a usable destination, the response carries the payment plus its active route quotes:
{
"paymentId": "cpm_1a2b3c",
"status": "AWAITING_RECIPIENT_CONFIRMATION",
"recipientPhoneMasked": "+1 ••• 6543",
"recipientTenantId": "tnt_9f8e7d",
"claimId": null,
"sourceChainKey": "eip155:84532",
"sourceAssetId": "eip155:84532/erc20:0x036cbd53842c5426634e7929541ec2318f3dcf7e",
"sourceAmountBaseUnits": "25000000",
"sourceFundingMode": "INTERNAL_BALANCE",
"requestedDestinationId": "pd_4d5e6f",
"selectedRouteQuoteId": null,
"holdReasons": [],
"failureReason": null,
"deliveredAt": null,
"createdAt": "2026-08-21T17:03:12.000Z",
"quotes": [
{
"routeId": "rq_7g8h9i",
"paymentId": "cpm_1a2b3c",
"destinationId": "pd_4d5e6f",
"provider": "paypal",
"sourceAmountBaseUnits": "25000000",
"sourceAssetId": "eip155:84532/erc20:0x036cbd53842c5426634e7929541ec2318f3dcf7e",
"destinationAmountMinor": "25000000",
"destinationCurrency": "USD",
"fees": { "providerFeeMinor": "250000", "networkFeeMinor": "0" },
"exchangeRate": "1",
"estimatedDeliverySeconds": 60,
"requiresBridge": false,
"requiresConversion": false,
"requiresPrefunding": false,
"status": "ACTIVE",
"quoteExpiresAt": "2026-08-21T17:05:12.000Z"
}
]
}
Amounts ride as strings in 6-decimal base units end-to-end (USDC parity):
"25000000" is $25.00. The recipient's phone appears only masked; the full
number is never returned.
Other intake outcomes:
- Unknown phone →
status: "PENDING_CLAIM"with aclaimId— see Pending claims. - Recipient onboarded but no usable destination →
status: "PAYOUT_SELECTION_REQUIRED"withholdReasons: ["PAYOUT_DESTINATION_NOT_SELECTED"]. - Policy hold →
status: "COMPLIANCE_HOLD"with the reason codes. - Policy reject at intake (unfunded) →
status: "CANCELED".
Route quotes and recipient consent
Route quotes are immutable, versioned rows: a requote supersedes the old
set, quotes are never edited in place, and execution pays exactly the quote
that was confirmed. Quotes expire (quoteExpiresAt, 120 s by default); an
expired quote cannot be selected or confirmed — the payment funnels back
through policy for a fresh quote set.
Consent belongs to the recipient:
POST /v1/offramp/payments/{id}/select-routeandPOST /v1/offramp/payments/{id}/confirmare recipient-only. A sender calling them gets403 RECIPIENT_CONSENT_REQUIRED— the sender can never steer money onto a rail the recipient didn't choose.confirmlocks funds (FUNDS_LOCKED) and hands the payment to the settlement orchestrator. Immediately before submitting to the provider, the quote, the destination's ACTIVE status, and the full policy verdict are all re-checked; a failure unlocks and requotes rather than paying a stale route.- Fallback needs prior consent. Rerouting to a different destination is
only ever possible for destinations where the recipient has set
allowAutomaticFallback: truebeforehand. v1 never reroutes automatically — a provider failure lands the payment inFAILEDhonestly. - The sender's one lever is
POST /v1/offramp/payments/{id}/cancel, and only while the payment is unfunded. A funded payment exits throughREFUND_PENDING, neverCANCELED— escrowed value is never stranded.
# recipient credentials
curl -X POST https://api.sandbox.safebank.ai/v1/offramp/payments/cpm_1a2b3c/select-route \
-H "X-SafeBank-Api-Key: $RECIPIENT_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "routeQuoteId": "rq_7g8h9i" }'
curl -X POST https://api.sandbox.safebank.ai/v1/offramp/payments/cpm_1a2b3c/confirm \
-H "X-SafeBank-Api-Key: $RECIPIENT_API_KEY"
Destination rails
Recipients control where money lands via
/v1/payout-destinations (requires an ACTIVE
BUSINESS_IDENTITY credential). Types accepted by
POST /v1/payout-destinations today:
| Type | Identifier | Notes |
|---|---|---|
ONCHAIN_WALLET | asset + network + address | Address shape validated per network; stored verbatim |
PAYPAL | email or phone (exactly one) | |
VENMO | phone or handle (exactly one) | US / USD only (DESTINATION_RAIL_CONSTRAINT otherwise) |
US_BANK_ACH | routingNumber + accountNumber | Creatable; no provider adapter carries it yet, so it won't produce routes until its rail ships |
SEPA | iban | Same — creatable, not yet routable |
SAFEBANK_BALANCE | none | Internal rail; ACTIVE immediately, fee-free |
Cash App is defined as a destination type but is not accepted by the
create endpoint (the request fails validation) — it is designed to be added
through a provider link session, which ships with the Cash App
increment. Other rail types not listed above return
DESTINATION_RAIL_NOT_AVAILABLE.
External-rail identifiers (emails, phone numbers, bank accounts, IBANs) are
stored only encrypted; API responses carry a masked label. Routing considers
exclusively destinations with status: "ACTIVE". Changing where money goes
always means creating a new destination — identifiers are immutable.
Pending claims (unknown recipients)
Paying a phone number SafeBank can't resolve opens a pending claim: the
payment parks in PENDING_CLAIM and the money is reserved for whoever
verifies control of that number and completes onboarding. The claim's
on-chain identifier is an opaque random 32-byte id — nothing phone-derived
ever touches the chain. Claims expire (14 days by default); an expired funded
claim refunds the sender. Full flow: Claims.
Holds and what clears them
A held payment carries machine-readable holdReasons. Holds are recoverable;
the two REJECT codes terminate the attempt instead (unfunded → CANCELED,
funded → REFUND_PENDING).
| Reason | Verdict | Cleared by |
|---|---|---|
RECIPIENT_NOT_ONBOARDED | HOLD | Recipient completes KYB verification |
PAYOUT_DESTINATION_NOT_SELECTED | HOLD | Recipient adds/verifies a destination |
CREDENTIAL_EXPIRED / CREDENTIAL_REVOKED / CREDENTIAL_SUSPENDED | HOLD | Recipient re-verifies; new ACTIVE credential |
CREDENTIAL_CHANGED_REVIEW_REQUIRED | HOLD | Operator review |
PHONE_REVERIFICATION_REQUIRED | HOLD | Phone alias re-verified (rebound/suspended numbers) |
TRANSACTION_MONITORING_REVIEW | HOLD | Operator review |
PROVIDER_DESTINATION_REVOKED | HOLD | Recipient chooses/adds a different destination |
PROVIDER_UNAVAILABLE | HOLD | Provider service recovers |
INSUFFICIENT_ROUTE_LIQUIDITY | HOLD | Operator tops up provider liquidity |
POLICY_SUBSYSTEM_ERROR | HOLD | Internal outage passes; the gate fails closed — an outage neither releases nor bounces money |
SANCTIONS_REVIEW_REQUIRED | REJECT | Terminal for the attempt |
POLICY_DENIED | REJECT | Tenant policy cap; terminal for the attempt |
Held payments re-enter the policy check when the blocking condition is re-evaluated; compliance holds are released through operator review, and approval re-runs the policy composer — it never bypasses it.
Provider modes
Each payout provider runs in one mode, set server-side per provider:
off | mock | sandbox | live. Unset means off — an unconfigured provider
fails closed and its routes are simply ineligible; there is no silent
fallback.
Mock mode binds a deterministic in-process adapter: no timers, no
randomness, no vendor calls. The payout outcome is scripted by the last two
digits of the amount in base units (e.g. …00 → submitted → pending →
succeeded; …03 → blocked → failed; …07 → succeeded then returned), so the
sandbox exercises the full state machine — including holds, failures, and
post-success returns — reproducibly and without credentials. Mock providers
move no real value.
Payment status reference
CREATED → RECIPIENT_RESOLUTION ─┬─▶ PENDING_CLAIM (unknown phone)
└─▶ SOURCE_FUNDS_CONFIRMED → POLICY_CHECK
POLICY_CHECK ─┬─▶ ROUTE_QUOTED → AWAITING_RECIPIENT_CONFIRMATION → FUNDS_LOCKED
├─▶ PAYOUT_SELECTION_REQUIRED / COMPLIANCE_HOLD (holds)
└─▶ CANCELED / REFUND_PENDING (reject)
FUNDS_LOCKED → PROVIDER_SUBMITTED → PROVIDER_PROCESSING → DELIVERED
│ │
└── ACTION_REQUIRED ─┘ (provider hold/unclaimed/blocked)
FAILED → REFUND_PENDING → REFUNDED
| Status | Meaning |
|---|---|
CREATED | Request recorded |
AWAITING_SOURCE_FUNDS / SOURCE_FUNDS_CONFIRMING | On-chain funding legs (ship with the escrow-indexer increment) |
SOURCE_FUNDS_CONFIRMED | Funds available (immediate for internal balance) |
RECIPIENT_RESOLUTION | Phone alias being resolved |
PENDING_CLAIM | Recipient unknown — money waits on a claim |
PAYOUT_SELECTION_REQUIRED | Recipient must choose/verify a destination |
POLICY_CHECK | Policy composer evaluating (also the re-entry hub for cleared holds and expired quotes) |
COMPLIANCE_HOLD | Held with holdReasons; released through review |
ROUTE_QUOTED | Active quotes exist |
AWAITING_RECIPIENT_CONFIRMATION | Waiting for recipient select + confirm |
FUNDS_LOCKED | Funds committed; execution starting |
PROVIDER_SUBMITTED | Provider accepted the payout request |
PROVIDER_PROCESSING | Provider processing |
ACTION_REQUIRED | Provider reports unclaimed / held / blocked |
DELIVERED | Terminal provider success — terminal, never reverts |
FAILED | Payout attempt failed honestly |
REFUND_PENDING / REFUNDED | Funded exit path |
CANCELED | Unfunded exit path |
Key endpoints
| Method | Path | Scope | Who |
|---|---|---|---|
POST | /v1/offramp/payments | ramps:write | Sender |
GET | /v1/offramp/payments | ramps:read | Sender or recipient |
GET | /v1/offramp/payments/{id} | ramps:read | Sender or recipient |
GET | /v1/offramp/payments/{id}/routes | ramps:read | Sender or recipient |
POST | /v1/offramp/payments/{id}/select-route | ramps:write | Recipient only |
POST | /v1/offramp/payments/{id}/confirm | ramps:write | Recipient only |
POST | /v1/offramp/payments/{id}/cancel | ramps:write | Sender only (unfunded) |
See the REST reference for full request/response schemas.