Phoenix Pay
Concepts

Webhooks

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

Why Ed25519 (Not HMAC)?

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

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; the 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:

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

Cache this value — it only changes during key rotation.

Webhook Payload

Every webhook sends a JSON payload:

Webhook payload
{
  "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"
}
FieldTypeDescription
eventstringAlways "payment.status_changed"
intent_idstringPhoenix Pay intent UUID
reference_idstringYour original reference ID
display_refstringHuman-readable reference
typestring"deposit" or "withdrawal"
statusstringNew status: created, pending, completed, failed, expired
amountintegerAmount in cents (5000 = 50.00)
currencystringCurrency code
channelstringChannel used
payment_methodstring or nullSub-method if applicable
error_codestring or nullError code on failure
error_detailstring or nullFailure description
timestampstringISO 8601 timestamp

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 signed1741694700
Content-TypeAlways application/jsonapplication/json

Signature Verification

Extract Headers and Body

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

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.

Check Timestamp Freshness

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}

Verify the Signature

Use the Ed25519 public key from /api/.well-known/signing-key to verify.

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

  // Check timestamp freshness
  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,
    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
import json
from nacl.signing import VerifyKey
from nacl.exceptions import BadSignatureError

def verify_webhook(headers, body):
    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: 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 :crypto.verify(:eddsa, :none, signing_input, signature, [public_key, :ed25519]),
      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 {
	ts, err := strconv.ParseInt(timestamp, 10, 64)
	if err != nil {
		return errors.New("invalid timestamp")
	}
	if math.Abs(float64(time.Now().Unix()-ts)) > 300 {
		return errors.New("webhook timestamp too old")
	}

	signingInput := fmt.Sprintf("%s.%s", timestamp, string(body))
	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")
	}

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

Retry Policy

If your endpoint fails to respond with 2xx within 10 seconds, Phoenix Pay retries:

AttemptDelayTotal Elapsed
1stImmediate0s
2nd5 seconds~5s
3rd30 seconds~35s
Final3 minutes~3m 35s

Your endpoint must respond within 10 seconds. Acknowledge immediately and process asynchronously if needed.

Idempotency

Your webhook handler may receive the same event more than once. Make it idempotent:

  1. Track by intent_id + status — skip if already processed
  2. Use reference_id — look up in your system, check if the transition was already applied
  3. Deduplicate on insert — use a unique constraint on (intent_id, status)
Idempotent webhook handler
app.post('/webhooks/phoenix-pay', async (req, res) => {
  const event = req.body;

  const existing = await db.webhookEvents.findOne({
    intentId: event.intent_id,
    status: event.status,
  });

  if (existing) {
    return res.status(200).json({ received: true, duplicate: true });
  }

  await processPaymentUpdate(event);
  await db.webhookEvents.create({
    intentId: event.intent_id,
    status: event.status,
    processedAt: new Date(),
  });

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

Always return 200 for duplicates. Returning an error causes Phoenix Pay to retry.

Polling as Fallback

If webhooks are missed, poll for status:

Poll by payment ID
curl https://pay.phoenixverse.io/api/deposits/{id} \
  -H "X-Key-Id: your-key-id" \
  -H "X-Timestamp: $(date +%s)" \
  -H "X-Signature: <signature>"

Phoenix Pay also runs background sync every 5 minutes, polling PSPs for status updates on all pending payments.

For maximum reliability, implement both:

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

              Background Sync
              (every 5 min)          Your Polling Job
                    │              (for stuck payments)

              Status Updated
  1. Primary: Receive and process webhooks in real time
  2. Safety net: Poll Phoenix Pay for payments stuck in non-terminal states