Phoenix Pay

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.

Get access token
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"
Response
{
  "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.

Create a deposit
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"
  }'
Response (201 Created)
{
  "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:

PSPFlowWhat to do
NOWPayments (crypto)Address-basedDisplay pay_address and pay_amount to your user. They send crypto directly to that address.
Chapa (fiat)Redirect-basedRedirect 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:

Fetch signing key
curl https://pay.phoenixverse.io/api/.well-known/signing-key
Response
{
  "algorithm": "Ed25519",
  "public_key": "MCowBQYDK2VwAyEA..."
}

Then implement signature verification in your webhook handler:

webhook-handler.js
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 });
});
webhook_handler.py
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}), 200
webhook_controller.ex
defmodule 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
end

Your 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.

Get deposit by ID
curl https://pay.phoenixverse.io/api/deposits/01912e4a-7b3c-7def-8a90-1234567890ab \
  -H "Authorization: Bearer eyJhbGciOiJSUzI1NiIs..."
Get deposit by reference ID
curl https://pay.phoenixverse.io/api/deposits/ref/order-12345 \
  -H "Authorization: Bearer eyJhbGciOiJSUzI1NiIs..."
Response
{
  "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

On this page