Phoenix Pay
Getting Started

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-key endpoint.

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 key pair
# 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
Generate key pair (PowerShell)
# 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.pem
generate-keys.js
import { 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!');
generate_keys.py
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:

HeaderDescription
X-Key-IdYour key ID from the admin dashboard
X-TimestampCurrent Unix timestamp in seconds
X-SignatureBase64-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

sign-request.js
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...'
);
sign_request.py
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",
})
sign_request.ex
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}
  ]
)
sign_request.go
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-key

Then verify incoming webhooks — see Webhook Verification for complete examples in Node.js, Python, Elixir, and Go.

Key Management

ActionHow
Register a new keyUpload public key in admin dashboard → receive Key ID
Rotate keysRegister a new key, update your app, then revoke the old one
Revoke a keyRevoke in admin dashboard — requests signed with that key are immediately rejected
Multiple keysRegister separate keys for staging vs production, or per-service

Key Modes

Each API key has a mode that determines which PSP credentials are used:

ModeDescription
sandboxRoutes payments to PSP sandbox/test environments
liveRoutes payments to live PSP environments with real money

This means you can test your integration with a sandbox key without affecting production.

Error Responses

StatusMeaning
401 UnauthorizedMissing headers, unknown key ID, stale timestamp, or invalid signature
403 ForbiddenValid signature but insufficient permissions for this operation
401 response
{
  "error": "unauthorized",
  "message": "Invalid request signature"
}