Hex Pay Docs

Webhooks

Receive automatic notifications when payments are completed

Overview

Webhooks notify your application when a payment reaches SUCCESSFUL status. HexPay sends a signed POST request to your specified URL.

Create Payment with Webhook

Learn how to include webhookURL when creating payments

Webhook Request Structure

Headers

  • Content-Type: application/json
  • X-Signature: <base64_signature> - Ed25519 signature of the request body
  • X-Signature-Kid: <key_id> - ID of the key used for signing

Body

{
  "payload": {
    "paymentID": "0199ea7a-0e5f-7545-9885-a0c22e99060f",
    "status": "SUCCESSFUL",
    "metadata": "eyJvcmRlcklkIjoiMTIzNDUifQ=="
  },
  "signAt": 1733320123
}

Signature Verification

Security Critical: Always verify the signature before processing webhook data. This protects against forged requests and attacks.

Step 1: Get JWKS

GET https://api.hexpay.io/.well-known/jwks.json

Step 2: Verify Signature

const crypto = require('crypto');

function verifySignature(rawBody, signature, kid, jwks) {
  // Find key by kid
  const key = jwks.keys.find(k => k.kid === kid);
  if (!key) throw new Error('Unknown key');
  
  // Decode public key from base64
  const publicKeyBytes = Buffer.from(key.x, 'base64');
  
  // Create Ed25519 public key
  const publicKey = crypto.createPublicKey({
    key: publicKeyBytes,
    format: 'raw',
    type: 'ed25519'
  });
  
  // Verify signature
  const signatureBytes = Buffer.from(signature, 'base64');
  return crypto.verify(null, rawBody, publicKey, signatureBytes);
}
import base64
from nacl.signing import VerifyKey

def verify_signature(raw_body, signature, kid, jwks):
    # Find key by kid
    key = next((k for k in jwks['keys'] if k['kid'] == kid), None)
    if not key:
        raise ValueError('Unknown key')
    
    # Decode public key from base64
    public_key_bytes = base64.b64decode(key['x'])
    verify_key = VerifyKey(public_key_bytes)
    
    # Verify signature
    signature_bytes = base64.b64decode(signature)
    verify_key.verify(raw_body, signature_bytes)
    return True
import (
    "crypto/ed25519"
    "encoding/base64"
)

func verifySignature(rawBody []byte, signature, kid string, jwks *JWKS) error {
    // Find key by kid
    var key *JWK
    for _, k := range jwks.Keys {
        if k.Kid == kid {
            key = &k
            break
        }
    }
    if key == nil {
        return fmt.Errorf("unknown key")
    }
    
    // Decode public key from base64
    publicKeyBytes, err := base64.StdEncoding.DecodeString(key.X)
    if err != nil {
        return err
    }
    
    // Verify signature
    signatureBytes, _ := base64.StdEncoding.DecodeString(signature)
    if !ed25519.Verify(publicKeyBytes, rawBody, signatureBytes) {
        return fmt.Errorf("invalid signature")
    }
    return nil
}

JWKS Management & Key Rotation

Cache JWKS Locally

  • Cache the JWKS response with TTL of 6-12 hours
  • Only fetch on cache miss or unknown kid
  • Never fetch on every webhook

Handle Unknown Keys

When you encounter an unknown kid:

  1. Fetch fresh JWKS from the endpoint
  2. Update your local cache
  3. Retry verification with new keys
  4. If still not found → reject the webhook

Key Rotation Flow

Security Best Practices

Essential Security Checks

  1. Signature: Always verify using the exact raw body and kid
  2. Timestamp: Reject webhooks with signAt older than 30 seconds
  3. HTTPS Only: Use HTTPS for webhook URLs and JWKS fetching
  4. No Fallbacks: Never use a different key if the specified kid is not found

Replay Attack Protection

// Check timestamp (example)
const currentTime = Math.floor(Date.now() / 1000);
const messageAge = currentTime - webhookBody.signAt;

if (messageAge > 30 || messageAge < -5) {
  throw new Error('Message too old or from future');
}

Idempotency

  • Store processed paymentID values
  • Skip reprocessing of duplicate webhooks
  • Essential for handling retries

Handling Failures

When JWKS is Unavailable

  1. Queue the webhook for later processing
  2. Implement exponential backoff for retries
  3. Return HTTP 500 to trigger HexPay retry

Response Requirements

  • Success: Return HTTP 200 only after successful verification and processing
  • Failure: Return 4xx/5xx to trigger automatic retry
  • Timeout: Respond within 30 seconds

Guaranteed Delivery

Automatic Retries

HexPay retries failed webhooks with exponential backoff for up to 12 hours. Ensure your endpoint handles duplicate deliveries gracefully.

Quick Implementation Checklist

  • Verify signature with correct kid
  • Check timestamp (max 30 seconds old)
  • Cache JWKS with proper TTL
  • Handle unknown kids with JWKS refresh
  • Implement idempotency
  • Use HTTPS exclusively
  • Return 200 only on success
  • Handle duplicates gracefully

Monitor Payment Status

Use the payment ID to track payment details via API