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 management | Both parties must store the same secret | Only Phoenix Pay holds the private key |
| Key rotation | Must coordinate rotation with every tenant | Rotate the signing key; tenants fetch the new public key automatically |
| Multi-tenant | Each tenant needs a unique shared secret | One signing key for all tenants; the public key is public |
| Non-repudiation | Either party could have generated the signature | Only 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:
curl https://pay.phoenixverse.io/api/.well-known/signing-key{
"algorithm": "Ed25519",
"public_key": "MCowBQYDK2VwAyEAx1Fz..."
}Cache this value — it only changes during key rotation.
Webhook Payload
Every webhook sends a JSON 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"
}| Field | Type | Description |
|---|---|---|
event | string | Always "payment.status_changed" |
intent_id | string | Phoenix Pay intent UUID |
reference_id | string | Your original reference ID |
display_ref | string | Human-readable reference |
type | string | "deposit" or "withdrawal" |
status | string | New status: created, pending, completed, failed, expired |
amount | integer | Amount in cents (5000 = 50.00) |
currency | string | Currency code |
channel | string | Channel used |
payment_method | string or null | Sub-method if applicable |
error_code | string or null | Error code on failure |
error_detail | string or null | Failure description |
timestamp | string | ISO 8601 timestamp |
HTTP Headers
Each webhook request includes two signature-related headers:
| Header | Description | Example |
|---|---|---|
X-Phoenix-Pay-Signature | Base64-encoded Ed25519 signature | sG9fa8h3k... |
X-Phoenix-Pay-Timestamp | Unix timestamp (seconds) when signed | 1741694700 |
Content-Type | Always application/json | application/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
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);
}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
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
endpackage 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:
| Attempt | Delay | Total Elapsed |
|---|---|---|
| 1st | Immediate | 0s |
| 2nd | 5 seconds | ~5s |
| 3rd | 30 seconds | ~35s |
| Final | 3 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:
- Track by
intent_id+status— skip if already processed - Use
reference_id— look up in your system, check if the transition was already applied - Deduplicate on insert — use a unique constraint on
(intent_id, status)
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:
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.
Recommended Architecture
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- Primary: Receive and process webhooks in real time
- Safety net: Poll Phoenix Pay for payments stuck in non-terminal states