Skip to main content

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":

MomentMeaningPayment statuses
AcceptedSafeBank validated and recorded the requestCREATEDPOLICY_CHECK
SecuredFunds are confirmed / reserved / escrow-lockedSOURCE_FUNDS_CONFIRMED, FUNDS_LOCKED
SubmittedThe payout provider accepted the requestPROVIDER_SUBMITTED, PROVIDER_PROCESSING
DeliveredThe provider reported terminal successDELIVERED

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 phonestatus: "PENDING_CLAIM" with a claimId — see Pending claims.
  • Recipient onboarded but no usable destinationstatus: "PAYOUT_SELECTION_REQUIRED" with holdReasons: ["PAYOUT_DESTINATION_NOT_SELECTED"].
  • Policy holdstatus: "COMPLIANCE_HOLD" with the reason codes.
  • Policy reject at intake (unfunded) → status: "CANCELED".

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-route and POST /v1/offramp/payments/{id}/confirm are recipient-only. A sender calling them gets 403 RECIPIENT_CONSENT_REQUIRED — the sender can never steer money onto a rail the recipient didn't choose.
  • confirm locks 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: true beforehand. v1 never reroutes automatically — a provider failure lands the payment in FAILED honestly.
  • The sender's one lever is POST /v1/offramp/payments/{id}/cancel, and only while the payment is unfunded. A funded payment exits through REFUND_PENDING, never CANCELED — 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:

TypeIdentifierNotes
ONCHAIN_WALLETasset + network + addressAddress shape validated per network; stored verbatim
PAYPALemail or phone (exactly one)
VENMOphone or handle (exactly one)US / USD only (DESTINATION_RAIL_CONSTRAINT otherwise)
US_BANK_ACHroutingNumber + accountNumberCreatable; no provider adapter carries it yet, so it won't produce routes until its rail ships
SEPAibanSame — creatable, not yet routable
SAFEBANK_BALANCEnoneInternal 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).

ReasonVerdictCleared by
RECIPIENT_NOT_ONBOARDEDHOLDRecipient completes KYB verification
PAYOUT_DESTINATION_NOT_SELECTEDHOLDRecipient adds/verifies a destination
CREDENTIAL_EXPIRED / CREDENTIAL_REVOKED / CREDENTIAL_SUSPENDEDHOLDRecipient re-verifies; new ACTIVE credential
CREDENTIAL_CHANGED_REVIEW_REQUIREDHOLDOperator review
PHONE_REVERIFICATION_REQUIREDHOLDPhone alias re-verified (rebound/suspended numbers)
TRANSACTION_MONITORING_REVIEWHOLDOperator review
PROVIDER_DESTINATION_REVOKEDHOLDRecipient chooses/adds a different destination
PROVIDER_UNAVAILABLEHOLDProvider service recovers
INSUFFICIENT_ROUTE_LIQUIDITYHOLDOperator tops up provider liquidity
POLICY_SUBSYSTEM_ERRORHOLDInternal outage passes; the gate fails closed — an outage neither releases nor bounces money
SANCTIONS_REVIEW_REQUIREDREJECTTerminal for the attempt
POLICY_DENIEDREJECTTenant 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
StatusMeaning
CREATEDRequest recorded
AWAITING_SOURCE_FUNDS / SOURCE_FUNDS_CONFIRMINGOn-chain funding legs (ship with the escrow-indexer increment)
SOURCE_FUNDS_CONFIRMEDFunds available (immediate for internal balance)
RECIPIENT_RESOLUTIONPhone alias being resolved
PENDING_CLAIMRecipient unknown — money waits on a claim
PAYOUT_SELECTION_REQUIREDRecipient must choose/verify a destination
POLICY_CHECKPolicy composer evaluating (also the re-entry hub for cleared holds and expired quotes)
COMPLIANCE_HOLDHeld with holdReasons; released through review
ROUTE_QUOTEDActive quotes exist
AWAITING_RECIPIENT_CONFIRMATIONWaiting for recipient select + confirm
FUNDS_LOCKEDFunds committed; execution starting
PROVIDER_SUBMITTEDProvider accepted the payout request
PROVIDER_PROCESSINGProvider processing
ACTION_REQUIREDProvider reports unclaimed / held / blocked
DELIVEREDTerminal provider success — terminal, never reverts
FAILEDPayout attempt failed honestly
REFUND_PENDING / REFUNDEDFunded exit path
CANCELEDUnfunded exit path

Key endpoints

MethodPathScopeWho
POST/v1/offramp/paymentsramps:writeSender
GET/v1/offramp/paymentsramps:readSender or recipient
GET/v1/offramp/payments/{id}ramps:readSender or recipient
GET/v1/offramp/payments/{id}/routesramps:readSender or recipient
POST/v1/offramp/payments/{id}/select-routeramps:writeRecipient only
POST/v1/offramp/payments/{id}/confirmramps:writeRecipient only
POST/v1/offramp/payments/{id}/cancelramps:writeSender only (unfunded)

See the REST reference for full request/response schemas.