Authentication
How to authenticate with Phoenix Pay using Ed25519 request signing with timestamp-based replay protection.
Phoenix Pay uses Ed25519 asymmetric key pairs with timestamp-based replay protection for authentication. You generate your own key pair locally, keep the private key, and upload only your public key to Phoenix Pay.
How It Works
┌──────────────┐ ┌──────────────┐
│ │ 1. Sign request with │ │
│ Your App │ your private key │ Phoenix Pay │
│ │─────────────────────────▶│ │
│ │ │ 2. Verify │
│ │◀─────────────────────────│ with your │
│ │ 3. Sign webhook with │ public key │
└──────────────┘ server key └──────────────┘- You → Phoenix Pay: You sign requests with your private key. Phoenix Pay verifies with the public key you uploaded.
- Phoenix Pay → You: Phoenix Pay signs webhooks with its private key. You verify with the public key from the
/signing-keyendpoint.
No shared secrets. No tokens to refresh. No OAuth flows. Your private key never leaves your infrastructure.
Generate Your Key Pair
Generate an Ed25519 key pair on your machine. The private key stays with you — you will only upload the public key to Phoenix Pay.
# Generate private key
openssl genpkey -algorithm Ed25519 -out private.pem
# Extract public key
openssl pkey -in private.pem -pubout -out public.pem
# View the public key (this is what you upload)
cat public.pem# Requires OpenSSL (install via winget or chocolatey)
# winget install ShiningLight.OpenSSL
# Generate private key
openssl genpkey -algorithm Ed25519 -out private.pem
# Extract public key
openssl pkey -in private.pem -pubout -out public.pem
# View the public key (this is what you upload)
Get-Content public.pemimport { utils } from '@noble/ed25519';
import { writeFileSync } from 'fs';
// Generate key pair
const privateKey = utils.randomPrivateKey();
const publicKey = await utils.getExtendedPublicKeyAsync(privateKey);
// Save keys
writeFileSync('private.key', Buffer.from(privateKey).toString('hex'));
writeFileSync('public.key', Buffer.from(publicKey).toString('hex'));
console.log('Public key (upload this):', Buffer.from(publicKey).toString('hex'));
console.log('Private key saved to private.key — keep this secret!');from nacl.signing import SigningKey
# Generate key pair
signing_key = SigningKey.generate()
verify_key = signing_key.verify_key
# Save keys
with open('private.key', 'w') as f:
f.write(signing_key.encode().hex())
with open('public.key', 'w') as f:
f.write(verify_key.encode().hex())
print(f"Public key (upload this): {verify_key.encode().hex()}")
print("Private key saved to private.key — keep this secret!")Never share your private key. It should never leave your server. Do not commit it to version control, log it, or transmit it over the network. If compromised, revoke the key immediately in the admin dashboard.
Upload Your Public Key
Log into the Admin Dashboard
Your platform operator provisions your tenant and provides admin dashboard access.
Register Your Public Key
Navigate to API Keys and click Register Key. Paste your public key (the contents of public.pem or the hex string). You'll receive a Key ID — save this, you'll include it in every API request.
You can register multiple keys (e.g., for key rotation or separate keys per environment). Each key has a mode — sandbox or live — which determines which PSP credentials are used.
Signing Requests
Every API request must include three headers:
| Header | Description |
|---|---|
X-Key-Id | Your key ID from the admin dashboard |
X-Timestamp | Current Unix timestamp in seconds |
X-Signature | Base64-encoded Ed25519 signature of the signing payload |
Signing Payload
The signature is computed over a timestamp-prefixed payload:
{X-Timestamp}.{raw_request_body}For GET requests with no body, sign the timestamp followed by an empty string:
{X-Timestamp}.The timestamp must be within 5 minutes of the server time. Requests with stale timestamps are rejected to prevent replay attacks.
Code Examples
import { sign } from '@noble/ed25519';
import { readFileSync } from 'fs';
const KEY_ID = 'your-key-id';
const PRIVATE_KEY = Buffer.from(readFileSync('private.key', 'utf8'), 'hex');
async function signedFetch(url, options = {}) {
const body = options.body || '';
const timestamp = Math.floor(Date.now() / 1000).toString();
const signingPayload = `${timestamp}.${body}`;
const signature = await sign(
new TextEncoder().encode(signingPayload),
PRIVATE_KEY
);
return fetch(url, {
...options,
headers: {
...options.headers,
'Content-Type': 'application/json',
'X-Key-Id': KEY_ID,
'X-Timestamp': timestamp,
'X-Signature': Buffer.from(signature).toString('base64'),
},
});
}
// Usage — POST request
const res = await signedFetch('https://pay.phoenixverse.io/api/deposits', {
method: 'POST',
body: JSON.stringify({
reference_id: 'order-12345',
amount: 5000,
currency: 'USDT',
channel: 'crypto_address',
}),
});
// Usage — GET request (empty body)
const deposit = await signedFetch(
'https://pay.phoenixverse.io/api/deposits/01912e4a-7b3c...'
);import base64
import json
import time
import requests
from nacl.signing import SigningKey
KEY_ID = "your-key-id"
PRIVATE_KEY = SigningKey(bytes.fromhex(open("private.key").read().strip()))
def signed_request(method, url, body=None):
body_str = json.dumps(body) if body else ""
timestamp = str(int(time.time()))
signing_payload = f"{timestamp}.{body_str}"
signature = PRIVATE_KEY.sign(signing_payload.encode()).signature
return requests.request(method, url,
data=body_str,
headers={
"Content-Type": "application/json",
"X-Key-Id": KEY_ID,
"X-Timestamp": timestamp,
"X-Signature": base64.b64encode(signature).decode(),
},
)
# Usage
res = signed_request("POST", "https://pay.phoenixverse.io/api/deposits", {
"reference_id": "order-12345",
"amount": 5000,
"currency": "USDT",
"channel": "crypto_address",
})key_id = "your-key-id"
private_key = File.read!("private.key") |> String.trim() |> Base.decode16!(case: :lower)
body = Jason.encode!(%{
reference_id: "order-12345",
amount: 5000,
currency: "USDT",
channel: "crypto_address"
})
timestamp = System.os_time(:second) |> to_string()
signing_payload = "#{timestamp}.#{body}"
signature =
:crypto.sign(:eddsa, :none, signing_payload, [private_key, :ed25519])
|> Base.encode64()
Req.post!("https://pay.phoenixverse.io/api/deposits",
body: body,
headers: [
{"content-type", "application/json"},
{"x-key-id", key_id},
{"x-timestamp", timestamp},
{"x-signature", signature}
]
)package main
import (
"crypto/ed25519"
"encoding/base64"
"encoding/hex"
"fmt"
"net/http"
"os"
"strings"
"time"
)
func signedRequest(method, url, body string, keyID string) (*http.Response, error) {
seedHex, _ := os.ReadFile("private.key")
seed, _ := hex.DecodeString(strings.TrimSpace(string(seedHex)))
privateKey := ed25519.NewKeyFromSeed(seed)
timestamp := fmt.Sprintf("%d", time.Now().Unix())
signingPayload := fmt.Sprintf("%s.%s", timestamp, body)
signature := ed25519.Sign(privateKey, []byte(signingPayload))
sigB64 := base64.StdEncoding.EncodeToString(signature)
req, _ := http.NewRequest(method, url, strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Key-Id", keyID)
req.Header.Set("X-Timestamp", timestamp)
req.Header.Set("X-Signature", sigB64)
return http.DefaultClient.Do(req)
}Verifying Webhooks
When Phoenix Pay sends a webhook to your callback URL, it includes signature headers. Verify them using Phoenix Pay's public signing key.
Fetch the public key once from:
GET https://pay.phoenixverse.io/api/.well-known/signing-keyThen verify incoming webhooks — see Webhook Verification for complete examples in Node.js, Python, Elixir, and Go.
Key Management
| Action | How |
|---|---|
| Register a new key | Upload public key in admin dashboard → receive Key ID |
| Rotate keys | Register a new key, update your app, then revoke the old one |
| Revoke a key | Revoke in admin dashboard — requests signed with that key are immediately rejected |
| Multiple keys | Register separate keys for staging vs production, or per-service |
Key Modes
Each API key has a mode that determines which PSP credentials are used:
| Mode | Description |
|---|---|
sandbox | Routes payments to PSP sandbox/test environments |
live | Routes payments to live PSP environments with real money |
This means you can test your integration with a sandbox key without affecting production.
Error Responses
| Status | Meaning |
|---|---|
401 Unauthorized | Missing headers, unknown key ID, stale timestamp, or invalid signature |
403 Forbidden | Valid signature but insufficient permissions for this operation |
{
"error": "unauthorized",
"message": "Invalid request signature"
}