Payment Lifecycle
Understand the payment state machine, status transitions, and how Phoenix Pay guarantees consistency.
Payment Lifecycle
Every payment in Phoenix Pay -- whether a deposit or a payout -- follows a well-defined state machine. Understanding this lifecycle is essential for correctly handling payment status updates in your application.
Status Overview
| Status | Description | Terminal? |
|---|---|---|
pending | Payment record created, PSP request may still be in progress | No |
awaiting_payment | PSP has accepted the request and is waiting for the customer to pay (deposit) or for funds to be sent (payout) | No |
processing | PSP is processing the payment (e.g., blockchain confirmations, bank transfer in progress) | No |
partial | A partial amount has been received (common in crypto payments) | No |
settled | Payment completed successfully. Full amount received (deposit) or sent (payout). | Yes |
failed | Payment failed (PSP rejected, insufficient funds, etc.) | Yes |
expired | Payment window expired before the customer completed payment | Yes |
cancelled | Payment was cancelled by the PSP or the system | Yes |
Terminal states (settled, failed, expired, cancelled) are final. Once a payment reaches a terminal state, no further transitions are possible. Attempting to update a terminal payment results in a no-op.
State Machine
The following diagram shows all valid state transitions:
┌─────────────────────────────────────────────┐
│ ▼
┌─────────┐ ┌───┴──────────────┐ ┌────────────┐ ┌──────────┐
│ pending │───▶│ awaiting_payment │───▶│ processing │───▶│ settled │
└────┬─────┘ └───┬──────────┬──┘ └──┬───┬─────┘ └──────────┘
│ │ │ │ │
│ │ │ │ │ ┌──────────┐
│ │ └──────────┼───┼──────────▶│ partial │
│ │ │ │ └────┬─────┘
│ │ │ │ │
│ │ │ │ ▼
│ │ │ │ ┌──────────┐
├──────────────┼─────────────────────┘ └──────────▶│ failed │
│ │ └──────────┘
│ │
│ │ ┌──────────┐
├──────────────┼────────────────────────────────────▶│ expired │
│ │ └──────────┘
│ │
│ │ ┌──────────┐
└──────────────┴────────────────────────────────────▶│cancelled │
└──────────┘Valid Transitions
| From | Allowed To |
|---|---|
pending | awaiting_payment, processing, settled, partial, failed, expired, cancelled |
awaiting_payment | processing, settled, partial, failed, expired, cancelled |
processing | settled, partial, failed, expired, cancelled |
partial | settled, failed, expired |
Any transition not in this table is silently rejected as a no-op.
Happy Path: Deposits
Deposit Created
Your application calls POST /api/deposits with a reference ID, amount, and currency. Phoenix Pay selects the appropriate PSP, creates the payment with the PSP, and returns the payment record with status awaiting_payment.
For crypto deposits (NOWPayments), the response includes a pay_address and pay_amount that you display to your user. For fiat deposits (Chapa), it includes a checkout_url to redirect the user to.
Awaiting Payment
The customer sends the payment. The PSP detects incoming funds and notifies Phoenix Pay via an inbound webhook or background sync.
Processing (Optional)
Some PSPs report an intermediate "processing" state (e.g., waiting for blockchain confirmations). Not all payments pass through this state.
Settled
The PSP confirms the payment is complete. The received_amount is populated with the actual amount received. Phoenix Pay sends an outbound webhook to your callback URL.
Happy Path: Payouts
Payout Created
Your application calls POST /api/payouts with a reference ID, amount, currency, destination, and account details. Phoenix Pay submits the payout to the PSP and returns the payment record.
Processing
The PSP processes the outbound transfer (e.g., bank transfer, mobile money disbursement).
Settled
The PSP confirms the funds have been sent. Phoenix Pay sends an outbound webhook to your callback URL.
Edge Cases
Partial Payments (Crypto)
Cryptocurrency payments can result in a partial status when the customer sends less than the requested amount. This is common due to:
- Network fees deducted from the sent amount
- User error in entering the amount
- Exchange rate fluctuations during the payment window
A partial payment can still transition to settled if the remaining amount is received before expiry.
{
"event": "payment.status_changed",
"payment_id": "01912e4a-7b3c-7def-8a90-1234567890ab",
"reference_id": "order-12345",
"type": "deposit",
"status": "partial",
"amount": "50.00",
"received_amount": "48.75",
"currency": "USDT",
"psp": "nowpayments",
"timestamp": "2026-03-11T13:00:00Z"
}Your application must decide how to handle partial payments. Common strategies include: accepting the partial amount, waiting for the full amount, or triggering a refund. Phoenix Pay does not make this decision for you.
Expiry
Each deposit has an expires_at timestamp set by the PSP. If the customer does not complete payment before this time, the status transitions to expired. Typical expiry windows:
| PSP | Expiry Window |
|---|---|
| NOWPayments | ~20 minutes (varies by currency) |
| Chapa | 1 hour |
Failed Payments
A payment transitions to failed when:
- The PSP explicitly rejects the payment
- A payout is declined by the receiving bank
- The PSP reports an irrecoverable error
The specific failure reason is recorded in the payment event log, accessible via GET /api/payments/:payment_id/events.
The apply_status() Function
All status transitions in Phoenix Pay flow through a single function: apply_status(). This is the core of the payment engine, called by both:
- Webhook handler -- when a PSP sends an inbound webhook
- Background sync -- when the scheduled sync worker polls PSPs for updates
This single-function design guarantees consistent behavior regardless of how the status update arrives.
What apply_status() Does
- Validates the transition -- checks that the new status is reachable from the current status using the state machine rules above
- Records a payment event -- creates a timestamped, deduplicated event record (used for audit trails and replay)
- Updates the payment -- sets the new status, received amount, and other fields
- Enqueues a callback -- schedules an outbound webhook to your callback URL via an Oban job
All four steps happen within a single database transaction, ensuring atomicity.
Idempotency
Phoenix Pay is designed to handle duplicate status updates gracefully:
- Same status, same payment -- If a status update arrives for a payment already in that status, it is silently ignored (no-op).
- Terminal state -- If a payment has already reached a terminal state, all further updates are ignored.
- Duplicate events -- Each payment event is keyed by a hash of
psp:external_id:raw_status. The same raw PSP event can never be recorded twice, thanks to a unique database constraint. - Concurrent webhooks -- Phoenix Pay uses
SELECT ... FOR UPDATE SKIP LOCKEDsemantics to safely handle concurrent webhook deliveries for the same payment.
You can safely retry or replay any request to Phoenix Pay without causing duplicate charges, double-credits, or inconsistent state.
Background Sync
Phoenix Pay runs a background sync job every 5 minutes that:
- Queries all payments in non-terminal states (
pending,awaiting_payment,processing) that are between 5 minutes and 24 hours old - Fetches the current status from the PSP for each payment (in batches of 50)
- Calls
apply_status()for any status changes
This provides resilience against missed PSP webhooks. If a webhook fails to arrive, the background sync will eventually detect the status change and update the payment.
Timeline:
────────────────────────────────────────────────────▶
0m 5m 10m 15m 20m
Payment Webhook Sync Sync Sync
Created (missed) (detects (no-op, (no-op,
change) already already
settled) settled)Payment Events
Every status transition is recorded as a PaymentEvent. These events form an immutable audit log for each payment.
curl https://pay.phoenixverse.io/api/payments/01912e4a-7b3c-7def-8a90-1234567890ab/events \
-H "Authorization: Bearer <token>"{
"data": [
{
"id": "01912e4b-1111-7def-8a90-aabbccddeeff",
"payment_id": "01912e4a-7b3c-7def-8a90-1234567890ab",
"psp_status": "waiting",
"normalized_status": "awaiting_payment",
"source": "creation",
"inserted_at": "2026-03-11T12:30:00Z"
},
{
"id": "01912e4b-2222-7def-8a90-aabbccddeeff",
"payment_id": "01912e4a-7b3c-7def-8a90-1234567890ab",
"psp_status": "finished",
"normalized_status": "settled",
"source": "webhook",
"inserted_at": "2026-03-11T12:45:00Z"
}
]
}Each event records:
| Field | Description |
|---|---|
psp_status | The raw status string from the PSP |
normalized_status | The Phoenix Pay normalized status |
source | How the update arrived: creation, webhook, or sync |
signature_valid | Whether the PSP's webhook signature was valid (if applicable) |
inserted_at | When the event was recorded |