Phoenix Pay
Concepts

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

StatusDescriptionTerminal?
pendingPayment record created, PSP request may still be in progressNo
awaiting_paymentPSP has accepted the request and is waiting for the customer to pay (deposit) or for funds to be sent (payout)No
processingPSP is processing the payment (e.g., blockchain confirmations, bank transfer in progress)No
partialA partial amount has been received (common in crypto payments)No
settledPayment completed successfully. Full amount received (deposit) or sent (payout).Yes
failedPayment failed (PSP rejected, insufficient funds, etc.)Yes
expiredPayment window expired before the customer completed paymentYes
cancelledPayment was cancelled by the PSP or the systemYes

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

FromAllowed To
pendingawaiting_payment, processing, settled, partial, failed, expired, cancelled
awaiting_paymentprocessing, settled, partial, failed, expired, cancelled
processingsettled, partial, failed, expired, cancelled
partialsettled, 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.

Partial payment webhook
{
  "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:

PSPExpiry Window
NOWPayments~20 minutes (varies by currency)
Chapa1 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

  1. Validates the transition -- checks that the new status is reachable from the current status using the state machine rules above
  2. Records a payment event -- creates a timestamped, deduplicated event record (used for audit trails and replay)
  3. Updates the payment -- sets the new status, received amount, and other fields
  4. 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 LOCKED semantics 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:

  1. Queries all payments in non-terminal states (pending, awaiting_payment, processing) that are between 5 minutes and 24 hours old
  2. Fetches the current status from the PSP for each payment (in batches of 50)
  3. 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.

Get payment events
curl https://pay.phoenixverse.io/api/payments/01912e4a-7b3c-7def-8a90-1234567890ab/events \
  -H "Authorization: Bearer <token>"
Response
{
  "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:

FieldDescription
psp_statusThe raw status string from the PSP
normalized_statusThe Phoenix Pay normalized status
sourceHow the update arrived: creation, webhook, or sync
signature_validWhether the PSP's webhook signature was valid (if applicable)
inserted_atWhen the event was recorded

On this page