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
| Status | Description | Terminal? |
|---|---|---|
created | Intent recorded, PSP attempt not yet started | No |
pending | An attempt is active — awaiting user action or PSP confirmation | No |
completed | Payment successful. Funds received (deposit) or sent (withdrawal). | Yes |
failed | Payment failed. PSP rejected, insufficient funds, etc. | Yes |
expired | Payment window closed before the user completed payment | Yes |
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 │
└───────────┘| From | Allowed To |
|---|---|
created | pending, completed, failed, expired |
pending | completed, 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.
{
"intent_id": "01912e4a-7b3c-7def-8a90-1234567890ab",
"action": "redirect",
"url": "https://checkout.chapa.co/checkout/payment/abc123"
}Action Types
| Action | When | What to Do | Extra Fields |
|---|---|---|---|
redirect | PSP has a checkout page (e.g., Chapa fiat) | Redirect the user to url | url |
await | Payment is being processed or user must send funds | Display 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 |
collect | PSP needs more info from the user (e.g., OTP) | Show a form and submit user input via the Step endpoint | collect, attempt_id |
completed | Payment succeeded immediately | Update 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_payoutusually returnsaction: "await" - NOWPayments
crypto_payoutmay returnaction: "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":
- Read
collect.type(e.g.,"otp","pin") andcollect.hint(e.g., "Enter the OTP sent to your phone") - Show the appropriate input form
- Submit user input via
POST /api/attempts/:attempt_id/step - Handle the next action (may be another
collect,await, orcompleted)
{
"intent_id": "01912e4a-...",
"action": "collect",
"attempt_id": "01912e4b-...",
"collect": {
"type": "otp",
"hint": "Enter the OTP sent to your phone"
}
}{
"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.
| PSP | Channel | Typical Expiry |
|---|---|---|
| NOWPayments | crypto_address | ~20 minutes (varies by currency) |
| Chapa | checkout | 1 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 webhooks —
SELECT ... FOR UPDATE SKIP LOCKEDprevents 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:
- Finds all intents in non-terminal states (
created,pending) between 5 minutes and 24 hours old - Fetches the current status from the PSP for each active attempt (batches of 50)
- 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.
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>"{
"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
| Field | Type | Description |
|---|---|---|
id | string (UUID v7) | Unique attempt identifier |
attempt_no | integer | Sequential attempt number (1 = first try) |
psp_id | string | Which PSP handled this attempt ("nowpayments", "chapa") |
capability_id | string | The specific PSP capability used |
status | string | Attempt status: initiated, awaiting_input, pending, completed, failed, expired |
psp_external_id | string or null | The PSP's own transaction ID |
error_code | string or null | Error code if the attempt failed |
error_detail | string or null | Human-readable failure description |
started_at | string (ISO 8601) | When the attempt began |
finished_at | string (ISO 8601) or null | When the attempt completed or failed |