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 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; 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. This key is used to verify all outbound webhook signatures.
curl https://pay.phoenixverse.io/api/.well-known/signing-key{
"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:
{
"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"
}| Field | Type | Description |
|---|---|---|
event | string | Always payment.status_changed |
payment_id | string | Phoenix Pay payment UUID |
reference_id | string | Your original reference ID from the create request |
type | string | deposit or payout |
status | string | The new status (see Payment Lifecycle) |
amount | string | The originally requested amount (decimal string) |
received_amount | string or null | The actual amount received (may differ for partial payments) |
currency | string | The currency code |
psp | string | The PSP that processed the payment (nowpayments or chapa) |
timestamp | string | ISO 8601 timestamp of when the event occurred |
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 the webhook was signed | 1741694700 |
Content-Type | Always application/json | application/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
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);
}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
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
endpackage 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:
| Attempt | Delay After Failure | Total Time Elapsed |
|---|---|---|
| 1st attempt | Immediate | 0s |
| 2nd attempt | 5 seconds | ~5s |
| 3rd attempt | 30 seconds | ~35s |
| Final attempt | 3 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:
- Track by
payment_id+status-- If you have already processed a status change for a given payment, skip it - Use
reference_id-- Look up the payment in your system byreference_idand check if the status transition has already been applied - Idempotency key -- Store the
payment_idandstatuscombination and deduplicate on insert
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:
curl https://pay.phoenixverse.io/api/deposits/{id} \
-H "Authorization: Bearer <token>"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.
Recommended Architecture
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)- Primary: Receive and process webhooks in real time
- Safety net: Run a periodic job that polls Phoenix Pay for any payments stuck in non-terminal states for longer than expected