Phoenix Pay
Concepts

Payment Lifecycle

Understand the intent/attempt model, status transitions, and how Phoenix Pay guarantees consistency.

Intents and Attempts

Phoenix Pay separates the what from the how:

  • Intent — the payment request. "Deposit 5000 cents of USDT via crypto address."
  • Attempt — a single try at fulfilling that intent through a PSP. If an attempt fails, a new one can be created against the same intent.
┌──────────────────────────────────┐
│            Intent                │
│  reference_id: "order-123"       │
│  type: deposit                   │
│  amount: 5000  (= 50.00 USDT)   │
│  channel: crypto_address         │
│  status: pending                 │
├──────────────────────────────────┤
│  Attempt #1                      │
│    psp: nowpayments              │
│    status: pending               │
│    pay_address: TXrk4d5x...     │
└──────────────────────────────────┘

As a developer, you mostly interact with the intent — it represents your payment. Attempts are visible in the timeline for debugging and audit purposes.

Intent Statuses

StatusDescriptionTerminal?
createdIntent recorded, PSP attempt not yet startedNo
pendingAn attempt is active — awaiting user action or PSP confirmationNo
completedPayment successful. Funds received (deposit) or sent (withdrawal).Yes
failedPayment failed. PSP rejected, insufficient funds, etc.Yes
expiredPayment window closed before the user completed paymentYes

Terminal states (completed, failed, expired) are final. No further transitions are possible once an intent reaches a terminal state.

State Machine

┌──────────┐    ┌──────────┐    ┌───────────┐
│ created  │───▶│ pending  │───▶│ completed │
└────┬─────┘    └──┬───┬───┘    └───────────┘
     │             │   │
     │             │   │        ┌───────────┐
     ├─────────────┘   └───────▶│  failed   │
     │                          └───────────┘

     │                          ┌───────────┐
     └─────────────────────────▶│  expired  │
                                └───────────┘
FromAllowed To
createdpending, completed, failed, expired
pendingcompleted, failed, expired

The Create Response: Actions

When you create a deposit or withdrawal, Phoenix Pay doesn't return the full payment object. Instead, it returns an action that tells your application what to do next.

Example create response
{
  "intent_id": "01912e4a-7b3c-7def-8a90-1234567890ab",
  "action": "redirect",
  "url": "https://checkout.chapa.co/checkout/payment/abc123"
}

Action Types

ActionWhenWhat to DoExtra Fields
redirectPSP has a checkout page (e.g., Chapa fiat)Redirect the user to urlurl
awaitPayment is being processed or user must send fundsDisplay the message to the user. If pay_address is present, show the crypto address and amount.message, pay_address, pay_currency, pay_amount, expires_at
collectPSP needs more info from the user (e.g., OTP)Show a form and submit user input via the Step endpointcollect, attempt_id
completedPayment succeeded immediatelyUpdate your records — done

* Present when the PSP generated a crypto payment address.

Always check the action field to decide what to do — never hard-code behavior based on the PSP or channel.

Happy Path: Crypto Deposit

Create Deposit

Call POST /api/deposits with channel: "crypto_address". The response has action: "await" with pay_address, pay_amount, pay_currency, and expires_at.

Display Address

Show pay_address and pay_amount to your user. They send crypto from their wallet before expires_at.

Payment Confirmed

The PSP detects incoming funds and notifies Phoenix Pay. Your callback URL receives a signed webhook with status: "completed".

Happy Path: Fiat Deposit (Redirect)

Create Deposit

Call POST /api/deposits with channel: "checkout". The response has action: "redirect" with a url.

Redirect User

Send the user to url. They complete payment on the PSP's hosted checkout page.

Webhook Delivered

The PSP confirms payment. Phoenix Pay sends a signed webhook to your callback URL with status: "completed".

Happy Path: Withdrawal

Create Withdrawal

Call POST /api/payouts with a withdrawal channel and recipient details in fields.

  • Chapa direct_payout usually returns action: "await"
  • NOWPayments crypto_payout may return action: "collect" first if payout 2FA verification is enabled

Optional Verification Step

If the create response returns action: "collect", submit the requested code via POST /api/attempts/:attempt_id/step.

Transfer Processed

The PSP sends funds to the recipient. Phoenix Pay sends a webhook with status: "completed".

Multi-Step Flows

Some channels require multiple user interactions. For example, Chapa's OTP channel asks the user to enter a verification code after the initial request.

When the create response returns action: "collect":

  1. Read collect.type (e.g., "otp", "pin") and collect.hint (e.g., "Enter the OTP sent to your phone")
  2. Show the appropriate input form
  3. Submit user input via POST /api/attempts/:attempt_id/step
  4. Handle the next action (may be another collect, await, or completed)
Collect response
{
  "intent_id": "01912e4a-...",
  "action": "collect",
  "attempt_id": "01912e4b-...",
  "collect": {
    "type": "otp",
    "hint": "Enter the OTP sent to your phone"
  }
}
Step submit request
{
  "input": {
    "otp": "123456"
  }
}

Edge Cases

Expiry

Each deposit has an expiry window set by the PSP. If the user doesn't complete payment in time, both the attempt and intent transition to expired.

PSPChannelTypical Expiry
NOWPaymentscrypto_address~20 minutes (varies by currency)
Chapacheckout1 hour

Failed Payments

An intent transitions to failed when:

  • The PSP explicitly rejects the payment
  • A withdrawal is declined by the receiving bank
  • The PSP reports an irrecoverable error

The error_code and error_detail fields on the intent describe what went wrong. The full attempt history is available via the timeline endpoint.

Idempotency

Phoenix Pay handles duplicate events gracefully:

  • Same status — If a status update arrives for an intent already in that status, it's a no-op.
  • Terminal state — All updates to a terminal intent are ignored.
  • Duplicate webhooks — Each PSP webhook is deduplicated by SHA256 hash of the raw payload.
  • Concurrent webhooksSELECT ... FOR UPDATE SKIP LOCKED prevents race conditions.

You can safely retry any request to Phoenix Pay without causing duplicate charges or inconsistent state.

Background Sync

Phoenix Pay runs a background sync job every 5 minutes that:

  1. Finds all intents in non-terminal states (created, pending) between 5 minutes and 24 hours old
  2. Fetches the current status from the PSP for each active attempt (batches of 50)
  3. Applies any status changes through the normal transition system

This catches missed PSP webhooks automatically.

Timeline:
────────────────────────────────────────────────────▶
0m         5m         10m        15m

Intent    Webhook    Sync       Sync
Created   (missed)   (detects   (no-op,
                     change)    already done)

Intent Timeline

Every attempt and inbound webhook event is recorded, forming an immutable audit log for each payment.

Get intent timeline
curl https://pay.phoenixverse.io/api/intents/01912e4a-7b3c-7def-8a90-1234567890ab/events \
  -H "X-Key-Id: your-key-id" \
  -H "X-Timestamp: $(date +%s)" \
  -H "X-Signature: <signature>"
Response
{
  "attempts": [
    {
      "id": "01912e4b-1111-7def-...",
      "attempt_no": 1,
      "psp_id": "nowpayments",
      "capability_id": "nowpayments:crypto_address:nil:multi:v1",
      "status": "completed",
      "psp_external_id": "np_12345",
      "error_code": null,
      "error_detail": null,
      "started_at": "2026-03-11T12:30:00Z",
      "finished_at": "2026-03-11T12:45:00Z",
      "inserted_at": "2026-03-11T12:30:00Z"
    }
  ],
  "webhook_events": [
    {
      "id": "01912e4b-2222-7def-...",
      "psp_id": "nowpayments",
      "event_type": "deposit_completed",
      "provider_event_id": "np_event_abc",
      "received_at": "2026-03-11T12:44:00Z",
      "processed_at": "2026-03-11T12:44:01Z"
    }
  ]
}

Attempt Fields

FieldTypeDescription
idstring (UUID v7)Unique attempt identifier
attempt_nointegerSequential attempt number (1 = first try)
psp_idstringWhich PSP handled this attempt ("nowpayments", "chapa")
capability_idstringThe specific PSP capability used
statusstringAttempt status: initiated, awaiting_input, pending, completed, failed, expired
psp_external_idstring or nullThe PSP's own transaction ID
error_codestring or nullError code if the attempt failed
error_detailstring or nullHuman-readable failure description
started_atstring (ISO 8601)When the attempt began
finished_atstring (ISO 8601) or nullWhen the attempt completed or failed