PolyPay logoPolyPay.ai
Advanced6 min read

Webhook Security Best Practices

Verify PolyPay webhooks with API-key-independent Ed25519 v2 signatures.

Webhook authentication: Ed25519 v2

v2 uses a PolyPay platform signing key, so no merchant API Key needs to be selected. Verify X-Webhook-Signature-Version, X-Webhook-Key-Id, X-Webhook-Merchant-Id, X-Webhook-Environment, X-Timestamp, X-Nonce, and X-Webhook-Signature-V2, then obtain the public key from /api/v1/pay/public/webhook-jwks. The signed bytes join polypay-webhook-v2, kid, timestamp, nonce, merchant ID, environment, and the raw body with newline characters.

Pre-Launch Checklist

  • Allow HTTPS callback URLs only
  • Validate timestamp and limit replay window
  • Validate Ed25519 v2 and the signed merchant and environment audience
  • Implement idempotent event handling
  • Store callback errors in audit logs

Key Rotation Policy

PolyPay publishes the current and retained Ed25519 rotation keys through JWKS. Select by X-Webhook-Key-Id, cache briefly, and refresh once for an unknown kid before rejecting it.

Idempotent Processing

Track `event_id` or the on-chain `hash` as a unique event key. Return 200 for duplicates to prevent double delivery or duplicate bookkeeping.

Amount and currency fields

`fiat_amount` is the original USD order total, `amount` is the crypto quantity, `exchange_rate` is the locked USD value of one crypto unit, and `currency` is the crypto payment asset such as USDT or USDC—not the invoice fiat currency. `order_no` is the merchant order ID and `event_id` is the idempotency key. `hash` is the on-chain transaction hash but may be empty for an admin-marked payment (status=7).

Diagnosing receiver rejections

HTTP 409 means the request arrived but the receiver rejected its business state or data. For amount mismatches, compare the local USD total with fiat_amount. For asset mismatches, compare the expected crypto asset with currency, then compare the token quantity with amount. Correct the mapping or order data before resending. Receivers should return a specific field, expected, actual, and event_id instead of one opaque amount-or-currency message.

Failure Retries

Webhook failure retry count defaults to 0, so each event is automatically delivered only once. You can adjust automatic failure retries on the Webhooks page in the Dashboard; manual resend is not limited by this setting.

Integrations such as WHMCS that supply notify_url on each order can leave the default Webhook URL empty and configure 0–10 failure retries independently. Deliveries use the order callback URL when no default URL is configured.

Verification examples

<?php
use PolyPay\PolyPay;
use PolyPay\WebhookHandler;
use PolyPay\Exception\SignatureException;

$polypay = new PolyPay(getenv('POLYPAY_API_KEY') ?: '');

try {
    $event = $polypay
        ->webhookV2('MCH_YOUR_ID', 'production')
        ->handle();

    $status = WebhookHandler::resolveStatus($event);
    if ($status === 'paid') {
        handleOrderPaidIdempotently($event);
    }

    http_response_code(200);
    echo 'OK';
} catch (SignatureException $e) {
    http_response_code($e->getHttpStatus());
    echo 'Unauthorized';
}

Rate limits and safe retries

When a request exceeds its current policy, PolyPay returns a real HTTP 429. Application responses use error code 40001; a CDN or WAF may instead return HTML or plain text, so clients must check HTTP status before parsing by Content-Type.

HTTP/1.1 429 Too Many Requests
Retry-After: 10
RateLimit-Limit: 60
RateLimit-Remaining: 0
RateLimit-Reset: 10

{"code":40001,"message":"RateLimitExceeded","data":{"retry_after":10}}

Honor Retry-After first; it may be seconds or an HTTP-date. If absent, use jittered exponential backoff. Automatically retry only replay-safe or idempotency-protected requests, with a bounded retry count.

Dashboard webhook tests and manual resends may be rate limited. Receivers should verify, persist, and return 2xx quickly, then process asynchronously. Returning 429 is treated as a failed delivery attempt.