Skip to main content
The Sender API lets your app create payment orders in two directions:
  • Off-ramp — user sends stablecoins, recipient receives local fiat (bank or mobile wallet)
  • On-ramp — user deposits fiat into a virtual account, recipient receives stablecoins
Both flows use the same endpoint: POST /v2/sender/orders. Direction comes from the source and destination types you pass. Fastest path to production: one authenticated create call. The API validates bank or mobile account details and resolves an acceptable rate inside that request—you are not required to call verify-account or the public rates URL first. The sections after Create a payment order cover what happens next (response shape, webhooks). Optional: prefetch quotes and account names explains how to polish checkout when you want prefetching—not because it is required.
Prefer natural language? Create and track orders from Claude Code, Cursor, or VS Code with the Sender Agent Guide (MCP).

Flow Overview

Getting Started

1. Obtain API Credentials

Sign up as a Sender at app.paycrest.io and complete the KYB process. Once verified, you’ll have:
  • API Key — included in every request as the API-Key header
  • API Secret — used to verify webhook signatures; keep this secret

2. Configure Tokens (Optional)

In the dashboard settings, you can set defaults per token/network. Off-ramp: default refundAddress (crypto) and feeAddress; you can also pass source.refundAddress on the request. On-ramp: you supply source.refundAccount (fiat bank or mobile details for refunds) in the order payload—configure related defaults in the dashboard where available.

Create a payment order

POST /v2/sender/orders Pass a source and a destination object. The type field on each determines the direction. Off-ramp and on-ramp use the same endpoint; examples below include curl for both directions. The extra JavaScript and Python snippets are off-ramp-shaped—mirror the on-ramp curl payload (source.type: "fiat", destination.type: "crypto", refundAccount, etc.) in your language.
Starknet / Tron: set network to starknet or tron. Crypto addresses (source.refundAddress on off-ramp, destination.recipient.address on on-ramp) must match the network: Starknet felt hex (0x + 63–64 hex digits) or Tron Base58 (T…). Do not pass EVM checksum addresses on these networks. See Supported Stablecoins.
User sends stablecoins; recipient receives fiat. Set source.type = "crypto" and destination.type = "fiat".

Request Fields


KES mobile money (M-Pesa, Till, Paybill)

Kenya off-ramp to mobile rails uses institution SAFAKEPC (Safaricom M-Pesa) or another KES mobile_money code from GET /institutions/KES. Till and Paybill are not separate institution codes—you pass them in destination.recipient.metadata. Example — Till
Example — Paybill
KYC on off-ramp: optional destination.kyc applies to the recipient only. Do not send sender or provider KYB on off-ramp create. GET / webhooks (v2): for off-ramp orders, destination.recipient.metadata returns the metadata you supplied on create (same shape as above). destination.kyc returns destination KYC when stored. There is no v1-style flat metadata fallback on v2 GET. Verify-account: Till and Paybill identifiers are not normalized as mobile phone numbers. Use the exact till or paybill values you will send on the order.

Handle the create response

The create response includes a providerAccount whose shape depends on direction: off-ramp gives a crypto receive address; on-ramp gives virtual account / fiat transfer instructions.

Off-ramp — send tokens to receiveAddress

Send exactly amount + senderFee + transactionFee (all returned in the response) in the specified token to providerAccount.receiveAddress before validUntil.

On-ramp — present virtual account to user

Display the providerAccount details to your user. They must transfer exactly amountToTransfer in currency to accountIdentifier before validUntil. (There is no onchain token transfer from your app for this path—the user moves fiat through their bank or mobile wallet.)

Monitor completion

Listen for webhooks or poll GET /v2/sender/orders/:id.

Webhook Events

Configure your webhook URL in the Sender Dashboard. Most webhooks are named for the status the order entered (payment_order.<status>) and all include a direction field ("offramp" or "onramp"). Branch on event rather than data.status — a few events, noted below, are not status changes:
You can notify the user of a successful off-ramp at payment_order.validated — that’s when the provider has confirmed fiat delivery. For on-ramp, use payment_order.settled — that’s when stablecoins are confirmed onchain.
Do not prompt a retry on payment_order.compliance_hold. The order will not settle and stays pending until its virtual account expires (you then also receive payment_order.expired). A second deposit into the same virtual account will be held too. Stop expecting settlement and contact support about the order — see Compliance holds.

Verify Webhook Signatures

Verify the X-Paycrest-Signature header using HMAC-SHA256 with your API Secret: The header value is a hex string (64 characters for SHA-256). Compare it to your computed hex using timing-safe equality on the UTF-8 bytes of those strings (do not Buffer.from(hex, 'hex') for the header — that compares raw digest bytes and does not match how the aggregator compares signatures). Normalize the header with trim and lowercase before comparing.

Webhook delivery logs & retry

Every webhook we send is recorded as a delivery you can inspect and replay — so a briefly-down endpoint no longer means a lost event. Automatic retries. A delivery that fails — either a transport error or a non-2xx response — is retried automatically with exponential backoff. Retries continue for a cumulative 24-hour window, after which the delivery is marked expired and we email your sender account once. Return a 2xx status as soon as you’ve accepted the event to stop retries. Each delivery has a status: The trigger field records what produced an attempt: automatic (first send), cron_retry (automatic backoff retry), or manual_sync / manual_async (a retry you initiated). Inspect deliveries. List your deliveries (newest first), filtered by status, event, event_id, order_id, or a from/to date range:
Fetch a single delivery to see the frozen request payload and the captured response body (truncated to 4KB):
Replay a delivery. Retry replays the original frozen payload. Use ?sync=true to replay immediately and get the HTTP result inline:
Omit sync to queue a new delivery for the retry worker instead — the response is 202 with the new delivery’s id:
All three endpoints are scoped to the authenticated sender; another sender’s delivery returns 404. See List Webhook Deliveries, Get Webhook Delivery, and Retry Webhook Delivery.

Polling


Optional: Prefetch quotes and account names

Use this when you want clearer UX before the user confirms—not because the API requires extra calls.

Resolve the account display name early

POST /v2/verify-account runs the same checks again on create, but calling it first cuts down “invalid account” surprises and returns a canonical accountName for your form.
  • Off-ramp — verify the fiat recipient you will send in destination.recipient (same institution and accountIdentifier as the order).
  • On-ramp — verify source.refundAccount (where fiat refunds go), not the crypto wallet in destination.recipient.
For KES Till or Paybill, pass the same accountIdentifier and metadata.channel you will use on create. Phone-style normalization does not apply to till or paybill numbers. NGN bank:
KES M-Pesa — you may send local (07…) or international (254…) MSISDN; the API normalizes to dial code 254 before verification:
For KES Till/Paybill, pass metadata (e.g. "channel": "Till") so phone normalization is skipped — see Verify Account. A 200 OK response returns the resolved name in data. If data is a real name (e.g. "JOHN DOE"), use that exact string as accountName on the order. If data is "OK", name lookup is not available for that corridor—supply your own accountName.

Prefetch a quote for the UI

GET /v2/rates/{network}/{from}/{amount}/{to} is public (no API key). from and to are the fiat code and token symbol in either order. Use it when you want to show a rate before submit, or when you will pass rate on create (it must stay within market tolerance). If you omit rate on create, the API still picks an acceptable rate.
The JSON data object includes buy and sell (unless you pass ?side=buy or ?side=sell). Use data.sell.rate for off-ramp and data.buy.rate for on-ramp when displaying a quote. provider_id is optional and must be exactly 8 letters (A–Z or a–z) if you pin a provider.

Sender Fees

You can optionally charge your users a fee on each order. It’s settled atomically onchain — no offchain billing required. You can configure a default fee in Sender Dashboard settings, or pass a per-request fee via senderFee (fixed amount) or senderFeePercent. Do not send both senderFee and senderFeePercent on the same request. Fee address rule: when the effective sender fee is greater than zero (from the request or a profile-configured fee), a fee recipient address is required — either configured on the sender profile for that token, or passed as senderFeeAddress on the create payload. When the effective fee is zero, senderFeeAddress is not required. Example — with sender fee (fee address required):
Fixed fee alternative:
Example — no sender fee (fee address not required):
Off-ramp: the total stablecoin amount the user sends to providerAccount.receiveAddress is amount + senderFee + transactionFee, all returned in the create response. On-ramp: the user pays fiat using providerAccount.amountToTransfer (and currency); fee line items in the response still describe what applies on settlement—follow the fields returned for your order.

Testing

Paycrest runs on mainnet only. Test with the minimum order size: $0.50 on any supported chain. Use small amounts until you’ve verified your integration end-to-end. Smoke test: credentials + POST /v2/sender/orders + handle providerAccount + one webhook or poll. Add verify-account and GET /v2/rates when you are polishing the checkout experience—they are not required to go live.

v1 Legacy API

The v1 API is fully supported for existing integrations. It supports off-ramp only and uses a flat request schema.
POST /v1/sender/orders
The response includes receiveAddress (string) and validUntil. Send tokens to receiveAddress.