PolyPay
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.

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.

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.