Skip to main content
Paycrest supports two payment directions: off-ramp (stablecoin → fiat) and on-ramp (fiat → stablecoin). Both flows follow a similar lifecycle through the protocol.

Off-ramp Lifecycle (Stablecoin → Fiat)

1

initiated

Sender creates a payment order via the API or Gateway contract. The order is recorded and a receive address (deposit address) is returned. Funds have not yet arrived.
2

deposited

Stablecoins are detected at the receive address. The protocol confirms the deposit and begins matching.
3

pending

The aggregator has assigned the order to a suitable liquidity provider. The provider’s provision node is processing the fiat payout.
4

fulfilling

The provision node is actively disbursing fiat to the recipient’s bank account or mobile wallet via a local PSP.
5

validated

The provider has confirmed successful fiat delivery. The order is waiting for onchain settlement.
6

settling

Onchain settlement is in progress — the escrowed stablecoins are being released to the provider.
7

settled

The order is fully closed. Stablecoins have been released to the provider; fiat has been delivered to the recipient.
Performance: The majority of orders complete in under 30 seconds from deposit to completion. If fulfillment fails after a deposit is received, the sender is automatically refunded.
expired vs refunded: These are two distinct failure modes. expired means the receive address (off-ramp) or virtual account (on-ramp) was never funded — no deposit was received. refunded means a deposit was received but the order could not be fulfilled (e.g. no available provider, PSP unavailability, rate constraints) — and the deposited funds have been returned to the sender.

On-ramp Lifecycle (Fiat → Stablecoin)

1

initiated

Sender creates an on-ramp order via the API. The protocol returns provider account details (virtual bank account or mobile wallet) for the user to deposit fiat into.
2

pending

The aggregator has matched the order to a provider. Waiting for the user’s fiat deposit to be confirmed by the provider.
3

fulfilling

Provider has confirmed fiat receipt. The protocol is preparing to release stablecoins to the recipient’s wallet address.
4

validated

Fiat receipt confirmed and stablecoin release authorized.
5

settling

Onchain transfer of stablecoins to the recipient’s wallet is in progress.
6

settled

Stablecoins have been delivered to the recipient’s wallet. The order is fully closed.
expired means the virtual account was never funded — no fiat deposit was received. refunded means fiat was received but the order could not be completed, and the deposited funds have been returned.

Order Statuses

The complete set of order status values:

Webhook Events

Paycrest sends webhooks to your configured endpoint as an order progresses. Most events are named after the status the order just entered (payment_order.<status>); a small number report an event that is not a status change, and are marked below. Failed deliveries are retried automatically with exponential backoff, and every delivery is queryable and replayable via the sender webhook endpoints.
Not all statuses emit webhooks. Intermediate states like initiated, fulfilling, and cancelled do not trigger webhook events.

Compliance holds

payment_order.compliance_hold fires when an on-ramp order’s fiat has been received but Paycrest cannot settle it while a compliance review is open. It is sent at most once per order. A deposit from an account that is not the customer’s own (the payer must match the customer in source.kyc) is usually not reviewed: Paycrest returns it in full to the account it came from within minutes, and you receive payment_order.refunded without a payment_order.compliance_hold. Paycrest holds such a deposit for review instead when it cannot be returned automatically, or when the deposit pattern needs a closer look. Two things make this event different from the rest:
  • It is not a status change. The order deliberately remains pending, so data.status reads pending, not compliance_hold. If you route webhooks by data.status, this event will look like a duplicate pending — branch on event instead.
  • It is not final. A held order does not expire. When the review ends, the order either settles (payment_order.settled) or the deposit is returned to the account it came from (payment_order.refunded). A review can take several days.
Polling instead of webhooks? While a deposit is held, the order reads "status": "pending" with "statusDetail": "under_review", so you can tell it from an order that has not been paid. A deposit returned to the account it came from ends refunded with a refundReason: payer_mismatch when it came from an account not in the customer’s name, or payer_unverified when the payer could not be confirmed. Both fields are on the v2 order and in v2 webhooks. Do not prompt the customer to pay again: a second deposit into the same virtual account will be held as well. What you tell your own customer is your decision; contact Paycrest support if you need to know where a review stands.
Not every held order produces this event. Where a hold concerns your account rather than the counterparty, no webhook is sent; you will observe only the order’s final payment_order.settled or payment_order.refunded.

Webhook Payload (v1)

Webhook Payload (v2)

The v2 payload adds a direction field and uses polymorphic source/destination objects that vary based on whether the order is an off-ramp or on-ramp:

Webhook Verification

Verify the authenticity of webhook payloads using your API Secret to compute an HMAC-SHA256 signature and compare it against the X-Paycrest-Signature header. Always verify against the raw request body bytes — do not parse and re-serialize the JSON, as key ordering may differ. The X-Paycrest-Signature value is a hex string. Use timing-safe comparison on the UTF-8 bytes of the hex strings (Buffer.from(hex, 'utf8')), not Buffer.from(hex, 'hex'). Trim and lowercase the header first.

Timeframes & SLAs

Error Handling

There are two distinct failure paths: expired — No deposit was ever received. The receive address (off-ramp) or virtual account (on-ramp) expired without being funded. The order closes with no funds moved. Common causes:
  • User abandoned the payment flow
  • Deposit was sent to the wrong address or after the address expired
refunded — A deposit was received but fulfillment failed. The deposited funds are automatically returned to the sender. For an off-ramp order already created onchain, the refund is amount + senderFee: the transaction fee has already paid for the network. Common causes:
  • No provider available at the requested rate
  • PSP unavailability in the corridor
  • Deposited amount doesn’t match the quoted rate within tolerance
Monitor payment_order.refunded and payment_order.expired webhook events and implement retry logic in your integration. You can also poll GET /v2/sender/orders/:id directly.