Paystack Webhooks on Cloudflare Workers
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.
Why Cloudflare Workers for Paystack Webhooks
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:
- Zero cold starts. Unlike Lambda or Vercel functions that may take 100ms to several seconds to boot up after an idle period, Workers are always warm. Paystack never waits for your handler to start up.
- Global distribution. Your webhook endpoint is deployed everywhere, not just in one region. Paystack's servers in Lagos hit the nearest Cloudflare node, which might be in Lagos itself.
- Simple deployment. One command (
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.
HMAC-SHA512 with the Web Crypto API
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;
}
Complete Cloudflare Worker Handler
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);
}
}
Project Setup with Wrangler
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.
Free Plan Limits and Why They Matter
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:
- Memory: 128 MB on both free and paid plans. More than enough for webhook processing.
- Request body size: 100 MB maximum. Paystack payloads are a few KB at most.
- Subrequests: 50 per request on the free plan, 1,000 on paid. A "subrequest" is any fetch() call your Worker makes to an external service (database, API, etc.).
Async Processing with Workers Queues
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.
Testing Locally with Wrangler Dev
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.
Connecting to a Database from Workers
Most webhook handlers need to write to a database. From Cloudflare Workers, you have several options:
- Cloudflare D1: Cloudflare's serverless SQLite database. Works natively with Workers. Good for small to medium applications. Free tier available.
- Cloudflare KV: A key-value store. Good for idempotency checks (storing processed transaction references) but not for complex queries.
- External databases (Supabase, PlanetScale, Neon): Connect over HTTPS using the database provider's HTTP API or REST client. Workers can make fetch() calls to any external service.
- Hyperdrive: Cloudflare's connection pooler for TCP-based databases like PostgreSQL and MySQL. Gives you connection pooling without managing it yourself.
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.
Key Takeaways
- ✓Cloudflare Workers do not have Node.js built-in modules. You must use the Web Crypto API (crypto.subtle) for HMAC-SHA512 signature verification instead of crypto.createHmac.
- ✓Workers have zero cold starts and execute at the edge, typically within 50ms. This makes them one of the fastest options for receiving Paystack webhooks.
- ✓The request.text() method gives you the raw body as a string, exactly what you need for HMAC verification. No body parsing configuration needed.
- ✓Store your Paystack webhook secret using Wrangler secrets (wrangler secret put), not in your wrangler.toml file. Secrets are encrypted at rest.
- ✓Workers have a 30-second CPU time limit on the paid plan and 10ms on the free plan. The free plan is too restrictive for webhook handlers that do any database work. Use the paid plan.
- ✓For heavy processing, use Workers Queues or Durable Objects to handle work asynchronously after returning 200 to Paystack.
Frequently Asked Questions
- Can I use the Cloudflare Workers free plan for Paystack webhooks?
- The free plan gives you 100,000 requests per day, which is plenty. However, the 10ms CPU time limit is very tight. A minimal handler that only verifies the signature and returns 200 will usually fit within 10ms, but any database operations or complex processing will exceed it. For production payment processing, use the paid plan at $5/month for 30 seconds of CPU time per invocation.
- Why can I not use Node.js crypto in Cloudflare Workers?
- Cloudflare Workers run on the V8 JavaScript engine directly, not on Node.js. V8 does not include Node.js built-in modules like crypto, fs, or buffer. Instead, Workers provide the Web Crypto API (crypto.subtle), which is the standard browser cryptography API. It supports all the algorithms you need for Paystack signature verification, including HMAC-SHA512. The API is asynchronous, which makes the code slightly more verbose.
- How do I debug a failing Paystack webhook on Workers?
- Use wrangler tail to stream real-time logs from your deployed Worker. Every console.log and console.error from your handler appears in the stream. You can also use the Cloudflare dashboard to view recent invocations and their logs. For local debugging, use wrangler dev to run the Worker locally and send test requests with curl.
- Can Workers connect to my PostgreSQL database directly?
- Workers cannot make raw TCP connections on the free plan. To connect to PostgreSQL, use either Cloudflare Hyperdrive (which provides connection pooling over TCP) on the paid plan, or use an HTTP-based database client like Supabase or Neon that exposes a REST API. Most managed database providers now offer HTTP endpoints specifically for edge function access.
- What happens if my Worker crashes while processing a webhook?
- If the Worker throws an unhandled exception before returning a response, Paystack receives a 500 error and will retry the webhook. If you returned a 200 before the crash (using a try/catch around processing logic), Paystack considers the webhook delivered and will not retry. This is why you should always return 200 first and handle errors in your processing code, logging them for later investigation.
Ready to build real-world apps?
Join the McTaba Labs full-stack marathon (4 months full-time · 6 months part-time). Learn M-Pesa, USSD, and WhatsApp engineering while shipping 8 production apps.
Apply to the McTaba Marathon