Webhook API
API reference for webhook verification and payload format.
Public Signing Key
GET /api/.well-known/signing-keyReturns the Ed25519 public key used to sign all outbound webhooks. This is a public endpoint — no authentication required.
curl https://pay.phoenixverse.io/api/.well-known/signing-key{
"algorithm": "Ed25519",
"public_key": "MCowBQYDK2VwAyEAx1Fz..."
}| Field | Type | Description |
|---|---|---|
algorithm | string | Always "Ed25519" |
public_key | string | Base64-encoded Ed25519 public key |
Cache this key in your application. It changes only during key rotation, which is infrequent.
Webhook Delivery
Phoenix Pay sends an HTTP POST to your tenant's callback_url whenever a payment transitions to a new status.
HTTP Headers
| Header | Description |
|---|---|
Content-Type | application/json |
X-Phoenix-Pay-Signature | Base64-encoded Ed25519 signature |
X-Phoenix-Pay-Timestamp | Unix timestamp (seconds) when the signature was created |
Payload Format
{
"event": "payment.status_changed",
"intent_id": "01912e4a-7b3c-7def-8a90-1234567890ab",
"reference_id": "order-12345",
"display_ref": "DEP-20260311-A1B2C3",
"type": "deposit",
"status": "completed",
"amount": 5000,
"currency": "USDT",
"channel": "crypto_address",
"payment_method": null,
"error_code": null,
"error_detail": null,
"timestamp": "2026-03-11T12:45:00Z"
}Payload Fields
| Field | Type | Description |
|---|---|---|
event | string | Always "payment.status_changed" |
intent_id | string | Phoenix Pay intent UUID (UUID v7) |
reference_id | string | Your original reference ID from the create request |
display_ref | string | Auto-generated human-readable reference |
type | string | "deposit" or "withdrawal" |
status | string | The new status. See Payment Lifecycle. |
amount | integer | Amount in cents (5000 = 50.00) |
currency | string | Currency code |
channel | string | The channel used for this payment |
payment_method | string or null | Sub-method if specified |
error_code | string or null | Error code (present when status is "failed") |
error_detail | string or null | Human-readable failure reason |
timestamp | string | ISO 8601 timestamp of when Phoenix Pay generated this webhook |
Events
Currently, Phoenix Pay emits a single event type:
| Event | Description | Trigger |
|---|---|---|
payment.status_changed | A payment's status has changed | Any status transition on a deposit or withdrawal |
This fires for every transition:
created→pending— PSP accepted the requestpending→completed— payment succeededpending→failed— payment failedcreated/pending→expired— payment window expired
You may receive multiple webhooks for a single payment as it progresses through states (e.g., created → pending → completed).
Signature Verification
Every webhook is signed. Verify by:
Extract the signature and timestamp
Read X-Phoenix-Pay-Signature and X-Phoenix-Pay-Timestamp from the request headers.
Reconstruct the signing input
Concatenate the timestamp and raw body with a period separator:
{timestamp}.{raw_json_body}Verify the Ed25519 signature
Use the public key from /api/.well-known/signing-key to verify the base64-decoded signature against the signing input.
Always verify using the raw request body as a string. Do not parse the JSON and re-serialize it — key ordering and whitespace may differ, causing verification to fail.
For complete verification code in Node.js, Python, Elixir, and Go, see the Webhook Verification Guide.
Retry Behavior
| Attempt | Delay | Description |
|---|---|---|
| 1st | Immediate | Initial delivery |
| 2nd | 5 seconds | First retry |
| 3rd | 30 seconds | Second retry |
| 4th (final) | 3 minutes | Final retry |
A delivery is successful if your endpoint responds with any 2xx HTTP status code within 10 seconds.
A delivery fails if your endpoint returns non-2xx, doesn't respond within 10 seconds, or is unreachable.
After all retries are exhausted, the webhook is abandoned. Use the polling endpoints to check for missed updates.
Configuring Your Callback URL
Set your webhook callback URL via the tenant configuration endpoint:
TIMESTAMP=$(date +%s)
BODY='{"callback_url":"https://myapp.example.com/webhooks/phoenix-pay"}'
curl -X PUT https://pay.phoenixverse.io/api/config \
-H "X-Key-Id: your-key-id" \
-H "X-Timestamp: $TIMESTAMP" \
-H "X-Signature: <signature-of-$TIMESTAMP.$BODY>" \
-H "Content-Type: application/json" \
-d "$BODY"Your callback URL must use HTTPS. Phoenix Pay will not deliver webhooks to HTTP endpoints in production.
Requirements for Your Endpoint
- Must be publicly accessible over HTTPS
- Must respond with a 2xx status code within 10 seconds
- Must accept POST requests with
Content-Type: application/json - Should be idempotent (may receive the same event more than once)
For PSP-facing webhooks, Phoenix Pay also treats duplicate provider deliveries as successful no-ops. If NOWPayments or Chapa sends the same webhook again, Phoenix Pay acknowledges it instead of re-processing the payment.