Quickstart
Create your first deposit with Phoenix Pay in 5 minutes.
Quickstart
This guide walks you through making your first payment with Phoenix Pay. By the end, you will have created a deposit, set up a webhook endpoint to receive status updates, and polled for the payment result.
Prerequisites: You need a Zitadel service account with client credentials configured for your organization. Contact your platform administrator if you do not have these yet.
Get Your Credentials
Your platform administrator will provide you with:
- Zitadel Issuer URL -- e.g.,
https://auth.phoenixverse.io - Client ID -- your service account's client ID
- Client Secret -- your service account's client secret
- Project ID -- the Phoenix Pay project ID in Zitadel
These credentials identify your organization (tenant) and determine which PSP configurations and payment data you can access.
Get an Access Token
Exchange your client credentials for a JWT access token using the OAuth 2.0 client credentials grant.
curl -X POST https://auth.phoenixverse.io/oauth/v2/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=client_credentials" \
-d "client_id=YOUR_CLIENT_ID" \
-d "client_secret=YOUR_CLIENT_SECRET" \
-d "scope=openid urn:zitadel:iam:org:project:id:YOUR_PROJECT_ID:aud"{
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "Bearer",
"expires_in": 43199
}Tokens expire after the duration specified in expires_in (typically 12 hours). Your application should cache the token and refresh it before expiry. See the Authentication guide for details.
Create Your First Deposit
Use the access token to create a deposit. Phoenix Pay will route the request to the appropriate PSP based on your tenant's configuration and the requested currency.
curl -X POST https://pay.phoenixverse.io/api/deposits \
-H "Authorization: Bearer eyJhbGciOiJSUzI1NiIs..." \
-H "Content-Type: application/json" \
-d '{
"reference_id": "order-12345",
"amount": "50.00",
"currency": "USDT"
}'{
"id": "01912e4a-7b3c-7def-8a90-1234567890ab",
"reference_id": "order-12345",
"type": "deposit",
"status": "awaiting_payment",
"psp": "nowpayments",
"requested_amount": "50.00",
"received_amount": null,
"currency": "USDT",
"pay_currency": "usdttrc20",
"pay_amount": "50.00",
"checkout_url": null,
"pay_address": "TXrk4d5x7Bj3e6g7Y8Zw...",
"expires_at": "2026-03-11T14:30:00Z",
"inserted_at": "2026-03-11T12:30:00Z",
"updated_at": "2026-03-11T12:30:00Z"
}The reference_id must be unique within your tenant. Use it to correlate payments with your own system (e.g., order IDs, transaction IDs). You can look up payments by reference later.
What happens next depends on the PSP:
| PSP | Flow | What to do |
|---|---|---|
| NOWPayments (crypto) | Address-based | Display pay_address and pay_amount to your user. They send crypto directly to that address. |
| Chapa (fiat) | Redirect-based | Redirect your user to checkout_url where they complete payment through Chapa's hosted page. |
Set Up Your Webhook Endpoint
Phoenix Pay sends Ed25519-signed webhooks to your configured callback URL whenever a payment status changes. You need to verify the signature to ensure the webhook is authentic.
First, fetch the public signing key:
curl https://pay.phoenixverse.io/api/.well-known/signing-key{
"algorithm": "Ed25519",
"public_key": "MCowBQYDK2VwAyEA..."
}Then implement signature verification in your webhook handler:
const express = require('express');
const { createPublicKey, verify } = require('crypto');
const app = express();
app.use(express.raw({ type: 'application/json' }));
// Cache this -- fetch once from /api/.well-known/signing-key
const PUBLIC_KEY_BASE64 = 'MCowBQYDK2VwAyEA...';
app.post('/webhooks/phoenix-pay', (req, res) => {
const signature = req.headers['x-phoenix-pay-signature'];
const timestamp = req.headers['x-phoenix-pay-timestamp'];
const body = req.body.toString();
// 1. Reject old timestamps (prevent replay attacks)
const age = Math.floor(Date.now() / 1000) - parseInt(timestamp);
if (age > 300) {
return res.status(400).json({ error: 'timestamp_too_old' });
}
// 2. Reconstruct signing input
const signingInput = `${timestamp}.${body}`;
// 3. Verify Ed25519 signature
const publicKey = createPublicKey({
key: Buffer.from(PUBLIC_KEY_BASE64, 'base64'),
format: 'der',
type: 'spki',
});
const isValid = verify(
null,
Buffer.from(signingInput),
publicKey,
Buffer.from(signature, 'base64')
);
if (!isValid) {
return res.status(401).json({ error: 'invalid_signature' });
}
// 4. Process the webhook
const event = JSON.parse(body);
console.log(`Payment ${event.payment_id}: ${event.status}`);
// Always respond 2xx quickly
res.status(200).json({ received: true });
});import time
import base64
from flask import Flask, request, jsonify
from nacl.signing import VerifyKey
from nacl.exceptions import BadSignatureError
app = Flask(__name__)
# Cache this -- fetch once from /api/.well-known/signing-key
PUBLIC_KEY_BASE64 = "MCowBQYDK2VwAyEA..."
@app.route("/webhooks/phoenix-pay", methods=["POST"])
def handle_webhook():
signature_b64 = request.headers.get("X-Phoenix-Pay-Signature")
timestamp = request.headers.get("X-Phoenix-Pay-Timestamp")
body = request.get_data(as_text=True)
# 1. Reject old timestamps
age = int(time.time()) - int(timestamp)
if age > 300:
return jsonify({"error": "timestamp_too_old"}), 400
# 2. Reconstruct signing input
signing_input = f"{timestamp}.{body}"
# 3. Verify Ed25519 signature
try:
public_key_bytes = base64.b64decode(PUBLIC_KEY_BASE64)
verify_key = VerifyKey(public_key_bytes)
signature = base64.b64decode(signature_b64)
verify_key.verify(signing_input.encode(), signature)
except BadSignatureError:
return jsonify({"error": "invalid_signature"}), 401
# 4. Process the webhook
event = request.get_json(force=True)
print(f"Payment {event['payment_id']}: {event['status']}")
return jsonify({"received": True}), 200defmodule MyApp.PhoenixPayWebhookController do
use MyAppWeb, :controller
# Cache this -- fetch once from /api/.well-known/signing-key
@public_key_base64 "MCowBQYDK2VwAyEA..."
def handle(conn, _params) do
[signature_b64] = get_req_header(conn, "x-phoenix-pay-signature")
[timestamp] = get_req_header(conn, "x-phoenix-pay-timestamp")
{:ok, body, conn} = read_body(conn)
# 1. Reject old timestamps
age = System.os_time(:second) - String.to_integer(timestamp)
if age > 300 do
conn |> put_status(400) |> json(%{error: "timestamp_too_old"})
else
# 2. Reconstruct signing input
signing_input = "#{timestamp}.#{body}"
# 3. Verify Ed25519 signature
public_key = Base.decode64!(@public_key_base64)
signature = Base.decode64!(signature_b64)
if Ed25519.valid_signature?(signature, signing_input, public_key) do
# 4. Process the webhook
event = Jason.decode!(body)
IO.puts("Payment #{event["payment_id"]}: #{event["status"]}")
conn |> put_status(200) |> json(%{received: true})
else
conn |> put_status(401) |> json(%{error: "invalid_signature"})
end
end
end
endYour webhook endpoint must respond with a 2xx status code within 10 seconds. If it fails, Phoenix Pay retries up to 3 times with backoff delays of 5 seconds, 30 seconds, and 3 minutes.
Poll for Status (Optional Fallback)
If you prefer polling over webhooks, or want a fallback mechanism, you can check the payment status at any time.
curl https://pay.phoenixverse.io/api/deposits/01912e4a-7b3c-7def-8a90-1234567890ab \
-H "Authorization: Bearer eyJhbGciOiJSUzI1NiIs..."curl https://pay.phoenixverse.io/api/deposits/ref/order-12345 \
-H "Authorization: Bearer eyJhbGciOiJSUzI1NiIs..."{
"id": "01912e4a-7b3c-7def-8a90-1234567890ab",
"reference_id": "order-12345",
"type": "deposit",
"status": "settled",
"psp": "nowpayments",
"requested_amount": "50.00",
"received_amount": "50.00",
"currency": "USDT",
"pay_currency": "usdttrc20",
"pay_amount": "50.00",
"checkout_url": null,
"pay_address": "TXrk4d5x7Bj3e6g7Y8Zw...",
"expires_at": "2026-03-11T14:30:00Z",
"inserted_at": "2026-03-11T12:30:00Z",
"updated_at": "2026-03-11T12:45:00Z"
}Phoenix Pay runs a background sync job that polls PSPs for status updates every 5 minutes. Even if a PSP webhook is missed, your payment status will eventually be updated. For best results, use both webhooks (for real-time updates) and polling (as a safety net).
Next Steps
Authentication Deep Dive
Learn about token management, refresh strategies, and platform vs. tenant access.
Payment Lifecycle
Understand the full payment state machine and edge cases like partial payments.
Webhook Integration
Complete guide to signature verification, retry behavior, and idempotency.
Full API Reference
All endpoints, parameters, and response formats.