To receive Paystack webhooks on Cloudflare Workers, create a Worker that reads the raw request body with request.text(), computes an HMAC-SHA512 hash using the Web Crypto API (crypto.subtle.importKey and crypto.subtle.sign), compares it to the X-Paystack-Signature header, and returns a 200 response. Workers have zero cold starts and run at the edge, making them fast and reliable webhook receivers.
Cloudflare Workers run JavaScript at the edge, on Cloudflare's network of data centers in over 300 cities. When Paystack sends a webhook, it hits the nearest Cloudflare edge node, which processes the request without routing it to a centralized server. The result is extremely fast response times, usually under 50ms, and zero cold starts.
For webhook handling, Workers have three practical advantages:
wrangler deploy) pushes your code to every edge location. No regions to choose, no load balancers to configure.The main trade-off is the runtime environment. Workers use the V8 JavaScript engine directly, not Node.js. This means you do not have access to Node.js built-in modules like crypto, fs, or buffer. For HMAC signature verification, you must use the Web Crypto API, which is asynchronous and more verbose than Node.js crypto.createHmac. It works fine; it just looks different.
The Web Crypto API is the standard browser cryptography API, and Cloudflare Workers implement it fully. To compute an HMAC-SHA512 hash (which is what Paystack uses for webhook signatures), you need two steps: import your secret as a CryptoKey, then use it to sign the request body.
Here is a reusable function that handles both steps:
async function verifyPaystackSignature(
body: string,
signature: string,
secret: string
): Promise<boolean> {
// Encode the secret and body as Uint8Arrays
const encoder = new TextEncoder();
const keyData = encoder.encode(secret);
const bodyData = encoder.encode(body);
// Import the secret as an HMAC key
const cryptoKey = await crypto.subtle.importKey(
'raw',
keyData,
{ name: 'HMAC', hash: 'SHA-512' },
false,
['sign']
);
// Sign the body
const signatureBuffer = await crypto.subtle.sign(
'HMAC',
cryptoKey,
bodyData
);
// Convert the signature to a hex string
const hashArray = Array.from(new Uint8Array(signatureBuffer));
const hashHex = hashArray
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
// Compare with the provided signature
return hashHex === signature;
}
This looks more complex than Node.js crypto.createHmac('sha512', secret).update(body).digest('hex'), but it does exactly the same thing. The Web Crypto API is asynchronous because it was designed for browsers where blocking the main thread is not acceptable. In a Worker, the async overhead is negligible.
One thing to be careful about: the hex comparison at the end. A simple === comparison is technically vulnerable to timing attacks, where an attacker can determine how many characters matched by measuring response time. In practice, timing attacks against webhook signatures are extremely difficult to pull off over the internet. But if you want to be thorough, you can implement a constant-time comparison:
function timingSafeEqual(a: string, b: string): boolean {
if (a.length !== b.length) return false;
let result = 0;
for (let i = 0; i < a.length; i++) {
result |= a.charCodeAt(i) ^ b.charCodeAt(i);
}
return result === 0;
}
Get weekly developer tips
Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.
No spam. Unsubscribe anytime.
Here is a production-ready Paystack webhook handler as a Cloudflare Worker. It handles signature verification, event routing, and error handling:
// src/index.ts
export interface Env {
PAYSTACK_WEBHOOK_SECRET: string;
}
export default {
async fetch(
request: Request,
env: Env
): Promise<Response> {
// Only accept POST requests
if (request.method !== 'POST') {
return new Response(
JSON.stringify({ error: 'Method not allowed' }),
{ status: 405, headers: { 'Content-Type': 'application/json' } }
);
}
const secret = env.PAYSTACK_WEBHOOK_SECRET;
if (!secret) {
console.error('PAYSTACK_WEBHOOK_SECRET not configured');
return new Response(
JSON.stringify({ error: 'Server misconfigured' }),
{ status: 500, headers: { 'Content-Type': 'application/json' } }
);
}
// Get the signature header
const signature = request.headers.get('x-paystack-signature');
if (!signature) {
return new Response(
JSON.stringify({ error: 'Missing signature' }),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
);
}
// Read the raw body
const rawBody = await request.text();
// Verify the signature
const isValid = await verifySignature(rawBody, signature, secret);
if (!isValid) {
console.warn('Invalid Paystack webhook signature');
return new Response(
JSON.stringify({ error: 'Invalid signature' }),
{ status: 401, headers: { 'Content-Type': 'application/json' } }
);
}
// Parse the verified payload
const payload = JSON.parse(rawBody);
console.log('Verified Paystack event: ' + payload.event);
// Process the event
try {
await processEvent(payload);
} catch (err) {
console.error('Error processing webhook:', err);
// Still return 200 to prevent retries
}
return new Response(
JSON.stringify({ received: true }),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
);
},
};
async function verifySignature(
body: string,
signature: string,
secret: string
): Promise<boolean> {
const encoder = new TextEncoder();
const keyData = encoder.encode(secret);
const bodyData = encoder.encode(body);
const cryptoKey = await crypto.subtle.importKey(
'raw',
keyData,
{ name: 'HMAC', hash: 'SHA-512' },
false,
['sign']
);
const signatureBuffer = await crypto.subtle.sign(
'HMAC',
cryptoKey,
bodyData
);
const hashArray = Array.from(new Uint8Array(signatureBuffer));
const hashHex = hashArray
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
return hashHex === signature;
}
async function processEvent(payload: any): Promise<void> {
switch (payload.event) {
case 'charge.success':
console.log(
'Payment successful: ' + payload.data.reference
+ ' Amount: ' + payload.data.amount
);
// Your business logic here
break;
case 'transfer.success':
console.log(
'Transfer completed: ' + payload.data.transfer_code
);
break;
case 'transfer.failed':
console.log(
'Transfer failed: ' + payload.data.transfer_code
);
break;
default:
console.log('Unhandled event: ' + payload.event);
}
}
Wrangler is Cloudflare's CLI for managing Workers. Set up your project:
npm create cloudflare@latest paystack-webhook-worker
cd paystack-webhook-worker
Your wrangler.toml configuration:
name = "paystack-webhook"
main = "src/index.ts"
compatibility_date = "2024-01-01"
# Do NOT put secrets in wrangler.toml
# Use wrangler secret put instead
Add your Paystack webhook secret as an encrypted secret:
npx wrangler secret put PAYSTACK_WEBHOOK_SECRET
Wrangler will prompt you to enter the value. It is encrypted at rest and only available to your Worker at runtime through the env parameter. Unlike environment variables that might appear in configuration files or deployment logs, secrets set with wrangler secret put are never visible in plaintext in the Cloudflare dashboard or in your source code.
Deploy the Worker:
npx wrangler deploy
After deployment, Wrangler prints the Worker URL. It looks like https://paystack-webhook.your-subdomain.workers.dev. Register this URL as your webhook endpoint in the Paystack dashboard.
To use a custom domain, add a route in your wrangler.toml:
routes = [
{ pattern = "api.yourdomain.com/webhooks/paystack", zone_name = "yourdomain.com" }
]
This routes requests to api.yourdomain.com/webhooks/paystack to your Worker. The domain must be on Cloudflare's DNS for this to work.
Cloudflare Workers has a generous free plan (100,000 requests per day), but there is a catch that matters for webhook handlers: the free plan limits you to 10ms of CPU time per invocation. That is 10 milliseconds of actual computation, not wall clock time. Waiting for a fetch() call does not count against CPU time, but JSON parsing, HMAC computation, and string operations do.
For a minimal webhook handler that only verifies the signature and returns 200, 10ms is usually enough. But if your handler parses the payload, checks a database, and processes the event, you will exceed 10ms on many invocations. When you exceed the CPU limit, the Worker is terminated and Paystack gets an error response.
The paid plan ($5/month for Workers Paid) gives you 30 seconds of CPU time per invocation, which is more than enough for any webhook handler. If you are processing real payments, the $5/month is a trivial cost compared to losing webhook events because your handler keeps getting killed at the 10ms mark.
Other limits to keep in mind:
For webhook handlers that need to do heavy processing (database writes, sending emails, calling external APIs), the pattern is the same as any serverless platform: respond to Paystack immediately, then process the event asynchronously.
Cloudflare Workers Queues is the native solution. You push a message to a queue from your webhook handler, and a separate consumer Worker processes it. The consumer can take longer and retry on failure without affecting webhook response times.
// Webhook handler (producer)
export interface Env {
PAYSTACK_WEBHOOK_SECRET: string;
PAYMENT_QUEUE: Queue;
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
// ... signature verification code ...
const rawBody = await request.text();
// After verifying signature:
const payload = JSON.parse(rawBody);
// Push to queue for async processing
await env.PAYMENT_QUEUE.send({
event: payload.event,
data: payload.data,
receivedAt: new Date().toISOString(),
});
return new Response(
JSON.stringify({ received: true }),
{ status: 200 }
);
},
};
// Queue consumer (separate Worker or same Worker)
export default {
async queue(
batch: MessageBatch,
env: Env
): Promise<void> {
for (const message of batch.messages) {
const payload = message.body as any;
console.log('Processing: ' + payload.event);
try {
// Heavy processing here
await processPayment(payload, env);
message.ack();
} catch (err) {
console.error('Processing failed:', err);
message.retry();
}
}
},
};
Configure the queue in wrangler.toml:
[[queues.producers]]
queue = "paystack-events"
binding = "PAYMENT_QUEUE"
[[queues.consumers]]
queue = "paystack-events"
max_batch_size = 10
max_retries = 3
Workers Queues automatically retries failed messages and lets you set a dead-letter queue for messages that fail all retries. This gives you the same reliability guarantees as AWS SQS but within the Cloudflare ecosystem.
Wrangler includes a local development mode that simulates the Workers runtime:
npx wrangler dev
This starts a local server (usually on port 8787) that runs your Worker code. You can test your webhook handler by sending a signed request:
# Generate a test signature and send the request
# (Use a test script or curl)
# test-webhook.sh
SECRET="your_test_secret"
BODY='{"event":"charge.success","data":{"reference":"test_123","amount":500000,"currency":"NGN","status":"success","customer":{"email":"test@example.com"}}}'
# Generate HMAC-SHA512 signature
SIGNATURE=$(echo -n "$BODY" | openssl dgst -sha512 -hmac "$SECRET" | awk '{print $2}')
curl -X POST http://localhost:8787 -H "Content-Type: application/json" -H "x-paystack-signature: $SIGNATURE" -d "$BODY"
For local development, set secrets using a .dev.vars file in your project root:
# .dev.vars (add to .gitignore!)
PAYSTACK_WEBHOOK_SECRET=your_test_secret
Wrangler automatically loads .dev.vars during local development. Make sure this file is in your .gitignore so you do not accidentally commit your secret.
For end-to-end testing with actual Paystack events, use Cloudflare Tunnel (cloudflared) to expose your local dev server to the internet, then register the tunnel URL in your Paystack test mode dashboard.
Most webhook handlers need to write to a database. From Cloudflare Workers, you have several options:
For a Paystack integration that needs to update order status, D1 or an external PostgreSQL database (through Supabase or Neon) are the most practical choices. Here is a quick example using D1:
export interface Env {
PAYSTACK_WEBHOOK_SECRET: string;
DB: D1Database;
}
async function handleChargeSuccess(
data: any,
db: D1Database
): Promise<void> {
// Check idempotency
const existing = await db
.prepare('SELECT id FROM payments WHERE reference = ?')
.bind(data.reference)
.first();
if (existing) {
console.log('Already processed: ' + data.reference);
return;
}
// Insert the payment record
await db
.prepare(
'INSERT INTO payments (reference, amount, currency, status, customer_email, created_at) VALUES (?, ?, ?, ?, ?, ?)'
)
.bind(
data.reference,
data.amount,
data.currency,
data.status,
data.customer.email,
new Date().toISOString()
)
.run();
console.log('Payment recorded: ' + data.reference);
}
The idempotency check is important. Paystack may send the same webhook event more than once, and your handler should not process the same payment twice. For the full idempotency pattern, see Idempotent Webhook Handlers.
KES 2,999
The definitive guide to building payment systems with Paystack — from your first transaction to production-grade payment infrastructure across Africa. 9 modules, 47 lessons. Free preview available.
Start Paystack Course