Phoenix Pay
Concepts

Webhooks

How Phoenix Pay delivers Ed25519-signed webhooks and how to verify them.

Webhooks

Phoenix Pay sends outbound webhooks to your configured callback URL whenever a payment status changes. Each webhook is signed with an Ed25519 asymmetric signature, allowing you to verify authenticity using the public key -- no shared secrets required.

Why Ed25519 (Not HMAC)?

Most payment providers use HMAC (shared secret) for webhook verification. Phoenix Pay uses Ed25519 asymmetric signatures instead, for these reasons:

HMAC (Shared Secret)Ed25519 (Asymmetric)
Secret managementBoth parties must store the same secretOnly Phoenix Pay holds the private key
Key rotationMust coordinate rotation with every tenantRotate the signing key; tenants fetch the new public key automatically
Multi-tenantEach tenant needs a unique shared secretOne signing key for all tenants; public key is... public
Non-repudiationEither party could have generated the signatureOnly Phoenix Pay could have signed the payload

Ed25519 provides non-repudiation: if a webhook passes signature verification, it provably came from Phoenix Pay. This is important for third-party tenants who need audit-grade proof of payment events.

Public Key Endpoint

Fetch the public signing key from the well-known endpoint. This key is used to verify all outbound webhook signatures.

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

The public_key is a base64-encoded Ed25519 public key. Cache this value in your application -- it only changes during key rotation, which is an infrequent operation.

Fetch the public key over HTTPS and cache it. Do not hard-code it -- while it changes rarely, your application should be able to refresh it in case of key rotation.

Webhook Payload

Every webhook sends a JSON payload with the following structure:

Webhook payload
{
  "event": "payment.status_changed",
  "payment_id": "01912e4a-7b3c-7def-8a90-1234567890ab",
  "reference_id": "order-12345",
  "type": "deposit",
  "status": "settled",
  "amount": "50.00",
  "received_amount": "50.00",
  "currency": "USDT",
  "psp": "nowpayments",
  "timestamp": "2026-03-11T12:45:00Z"
}
FieldTypeDescription
eventstringAlways payment.status_changed
payment_idstringPhoenix Pay payment UUID
reference_idstringYour original reference ID from the create request
typestringdeposit or payout
statusstringThe new status (see Payment Lifecycle)
amountstringThe originally requested amount (decimal string)
received_amountstring or nullThe actual amount received (may differ for partial payments)
currencystringThe currency code
pspstringThe PSP that processed the payment (nowpayments or chapa)
timestampstringISO 8601 timestamp of when the event occurred

HTTP Headers

Each webhook request includes two signature-related headers:

HeaderDescriptionExample
X-Phoenix-Pay-SignatureBase64-encoded Ed25519 signaturesG9fa8h3k...
X-Phoenix-Pay-TimestampUnix timestamp (seconds) when the webhook was signed1741694700
Content-TypeAlways application/jsonapplication/json

Signature Verification

Follow these steps to verify a webhook signature:

Extract Headers and Body

Read the X-Phoenix-Pay-Signature and X-Phoenix-Pay-Timestamp headers, and the raw request body as a string.

You must use the raw request body for verification, not a parsed-and-re-serialized version. JSON serialization is not deterministic -- field order and whitespace may differ, which will cause signature verification to fail.

Check Timestamp Freshness

Compare the timestamp to the current time. Reject webhooks older than 5 minutes to prevent replay attacks.

const age = Math.floor(Date.now() / 1000) - parseInt(timestamp);
if (age > 300) {
  // Reject -- too old
}

Reconstruct Signing Input

The signing input is the timestamp and body concatenated with a period:

{timestamp}.{body}

For example:

1741694700.{"event":"payment.status_changed","payment_id":"01912e4a-..."}

Verify the Signature

Use the Ed25519 public key (from /api/.well-known/signing-key) to verify the signature against the signing input.

Verification Examples

verify-webhook.js
const { createPublicKey, verify } = require('crypto');

function verifyWebhook(req) {
  const signature = req.headers['x-phoenix-pay-signature'];
  const timestamp = req.headers['x-phoenix-pay-timestamp'];
  const body = req.rawBody; // Must be the raw string, not parsed JSON

  // Check timestamp freshness (5 minute window)
  const age = Math.floor(Date.now() / 1000) - parseInt(timestamp);
  if (age > 300) {
    throw new Error('Webhook timestamp too old');
  }

  // Reconstruct signing input
  const signingInput = `${timestamp}.${body}`;

  // Create public key object from base64
  const publicKey = createPublicKey({
    key: Buffer.from(PUBLIC_KEY_BASE64, 'base64'),
    format: 'der',
    type: 'spki',
  });

  // Verify Ed25519 signature
  const isValid = verify(
    null, // Ed25519 does not use a separate hash algorithm
    Buffer.from(signingInput),
    publicKey,
    Buffer.from(signature, 'base64')
  );

  if (!isValid) {
    throw new Error('Invalid webhook signature');
  }

  return JSON.parse(body);
}
verify_webhook.py
import time
import base64
from nacl.signing import VerifyKey
from nacl.exceptions import BadSignatureError

def verify_webhook(headers, body):
    """
    Verify a Phoenix Pay webhook signature.

    Args:
        headers: Request headers dict
        body: Raw request body as string

    Returns:
        Parsed JSON payload if valid

    Raises:
        ValueError: If signature is invalid or timestamp is stale
    """
    signature_b64 = headers["X-Phoenix-Pay-Signature"]
    timestamp = headers["X-Phoenix-Pay-Timestamp"]

    # Check timestamp freshness
    age = int(time.time()) - int(timestamp)
    if age > 300:
        raise ValueError("Webhook timestamp too old")

    # Reconstruct signing input
    signing_input = f"{timestamp}.{body}"

    # 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("utf-8"), signature)
    except BadSignatureError:
        raise ValueError("Invalid webhook signature")

    return json.loads(body)

Install the PyNaCl library: pip install pynacl

webhook_verifier.ex
defmodule MyApp.WebhookVerifier do
  @max_age_seconds 300

  def verify(headers, raw_body, public_key_base64) do
    with {:ok, signature_b64} <- get_header(headers, "x-phoenix-pay-signature"),
         {:ok, timestamp} <- get_header(headers, "x-phoenix-pay-timestamp"),
         :ok <- check_freshness(timestamp),
         :ok <- verify_signature(timestamp, raw_body, signature_b64, public_key_base64) do
      {:ok, Jason.decode!(raw_body)}
    end
  end

  defp check_freshness(timestamp) do
    age = System.os_time(:second) - String.to_integer(timestamp)

    if age > @max_age_seconds,
      do: {:error, :timestamp_too_old},
      else: :ok
  end

  defp verify_signature(timestamp, body, signature_b64, public_key_b64) do
    signing_input = "#{timestamp}.#{body}"
    public_key = Base.decode64!(public_key_b64)
    signature = Base.decode64!(signature_b64)

    if Ed25519.valid_signature?(signature, signing_input, public_key),
      do: :ok,
      else: {:error, :invalid_signature}
  end

  defp get_header(headers, name) do
    case List.keyfind(headers, name, 0) do
      {_, value} -> {:ok, value}
      nil -> {:error, {:missing_header, name}}
    end
  end
end
verify_webhook.go
package webhook

import (
	"crypto/ed25519"
	"encoding/base64"
	"errors"
	"fmt"
	"math"
	"strconv"
	"time"
)

func VerifyWebhook(
	signatureB64 string,
	timestamp string,
	body []byte,
	publicKeyB64 string,
) error {
	// Check timestamp freshness
	ts, err := strconv.ParseInt(timestamp, 10, 64)
	if err != nil {
		return errors.New("invalid timestamp")
	}

	age := time.Now().Unix() - ts
	if math.Abs(float64(age)) > 300 {
		return errors.New("webhook timestamp too old")
	}

	// Reconstruct signing input
	signingInput := fmt.Sprintf("%s.%s", timestamp, string(body))

	// Decode public key and signature
	publicKey, err := base64.StdEncoding.DecodeString(publicKeyB64)
	if err != nil {
		return errors.New("invalid public key encoding")
	}

	signature, err := base64.StdEncoding.DecodeString(signatureB64)
	if err != nil {
		return errors.New("invalid signature encoding")
	}

	// Verify Ed25519 signature
	if !ed25519.Verify(publicKey, []byte(signingInput), signature) {
		return errors.New("invalid webhook signature")
	}

	return nil
}

Retry Policy

If your webhook endpoint fails to respond with a 2xx status code within 10 seconds, Phoenix Pay retries delivery with the following schedule:

AttemptDelay After FailureTotal Time Elapsed
1st attemptImmediate0s
2nd attempt5 seconds~5s
3rd attempt30 seconds~35s
Final attempt3 minutes~3m 35s

After 3 failed attempts, Phoenix Pay stops retrying for that particular webhook delivery. The payment record's callback_delivered field will remain false, and callback_attempts will reflect the number of attempts made.

Your endpoint must respond within 10 seconds. Do not perform long-running operations synchronously in your webhook handler. Instead, acknowledge the webhook immediately and process the payment update asynchronously.

What Counts as a Failure

  • HTTP status code outside the 2xx range
  • Connection timeout (endpoint unreachable)
  • Response takes longer than 10 seconds

What Counts as Success

  • Any HTTP 2xx response (200, 201, 202, 204, etc.)

Idempotency

Your webhook endpoint may receive the same event more than once due to retries. Your handler must be idempotent -- processing the same webhook twice should have no adverse effects.

Recommended strategies:

  1. Track by payment_id + status -- If you have already processed a status change for a given payment, skip it
  2. Use reference_id -- Look up the payment in your system by reference_id and check if the status transition has already been applied
  3. Idempotency key -- Store the payment_id and status combination and deduplicate on insert
Idempotent webhook handler
app.post('/webhooks/phoenix-pay', async (req, res) => {
  const event = req.body;

  // Check if we already processed this status change
  const existing = await db.webhookEvents.findOne({
    paymentId: event.payment_id,
    status: event.status,
  });

  if (existing) {
    // Already processed -- return 200 so Phoenix Pay stops retrying
    return res.status(200).json({ received: true, duplicate: true });
  }

  // Process the status change
  await processPaymentUpdate(event);

  // Record that we processed it
  await db.webhookEvents.create({
    paymentId: event.payment_id,
    status: event.status,
    processedAt: new Date(),
  });

  res.status(200).json({ received: true });
});

Always return a 200 response for webhooks you have already processed, even if they are duplicates. Returning an error code will cause Phoenix Pay to retry delivery.

Polling as Fallback

If your webhook endpoint is temporarily unavailable, you can poll for payment status as a fallback. Use the following endpoints:

Poll by payment ID
curl https://pay.phoenixverse.io/api/deposits/{id} \
  -H "Authorization: Bearer <token>"
Poll by reference ID
curl https://pay.phoenixverse.io/api/deposits/ref/{reference_id} \
  -H "Authorization: Bearer <token>"

Additionally, Phoenix Pay runs a background sync job every 5 minutes that polls PSPs for status updates on all pending payments. This means even if both your webhook and the PSP's webhook fail, payment statuses will eventually converge.

For maximum reliability, implement both mechanisms:

PSP Webhook ──▶ Phoenix Pay ──▶ Your Webhook Endpoint
                    │                    │
                    │                    ▼
                    │              Process Update

              Background Sync          │
              (every 5 min)            │
                    │                  │
                    ▼                  ▼
              Status Updated    Your Polling Job
                                (every N minutes,
                                 for pending payments)
  1. Primary: Receive and process webhooks in real time
  2. Safety net: Run a periodic job that polls Phoenix Pay for any payments stuck in non-terminal states for longer than expected

On this page