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/jsonX-Signature: <base64_signature>- Ed25519 signature of the request bodyX-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.jsonStep 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 Trueimport (
"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:
- Fetch fresh JWKS from the endpoint
- Update your local cache
- Retry verification with new keys
- If still not found → reject the webhook
Key Rotation Flow
Security Best Practices
Essential Security Checks
- Signature: Always verify using the exact raw body and kid
- Timestamp: Reject webhooks with
signAtolder than 30 seconds - HTTPS Only: Use HTTPS for webhook URLs and JWKS fetching
- 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
paymentIDvalues - Skip reprocessing of duplicate webhooks
- Essential for handling retries
Handling Failures
When JWKS is Unavailable
- Queue the webhook for later processing
- Implement exponential backoff for retries
- 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