Create a webhook endpoint at src/routes/api/webhooks/paystack/+server.ts. Use request.text() to read the raw body before parsing. Compute HMAC SHA512 of the raw body using your PAYSTACK_SECRET_KEY from $env/static/private. Compare the hex digest to the x-paystack-signature header. Return a 200 response immediately, then process the event. SvelteKit server endpoints have no CSRF protection by default, so no exemption is needed.
Create a POST handler in a +server.ts file. SvelteKit routes this to the URL based on its file path:
src/routes/api/webhooks/paystack/+server.ts
# Handles POST requests to /api/webhooks/paystack
Store your Paystack secret key as a private environment variable:
# .env
PAYSTACK_SECRET_KEY=sk_live_xxxxxxxxxxxx
// src/routes/api/webhooks/paystack/+server.ts
import { PAYSTACK_SECRET_KEY } from '$env/static/private';
import { json, error } from '@sveltejs/kit';
import { createHmac } from 'crypto';
export async function POST({ request }) {
// Read raw body BEFORE any JSON parsing
var rawBody = await request.text();
// Verify signature
var signature = request.headers.get('x-paystack-signature');
if (!signature) {
throw error(400, 'Missing signature');
}
var hash = createHmac('sha512', PAYSTACK_SECRET_KEY)
.update(rawBody)
.digest('hex');
if (hash !== signature) {
throw error(400, 'Invalid signature');
}
// Parse event after verification
var event = JSON.parse(rawBody);
// Return 200 immediately — process in background or synchronously
// For production, push to a queue here instead
handleEvent(event).catch(console.error);
return json({ received: true });
}
async function handleEvent(event: { event: string; data: Record }) {
switch (event.event) {
case 'charge.success':
await handleChargeSuccess(event.data);
break;
case 'transfer.success':
await handleTransferSuccess(event.data);
break;
case 'transfer.failed':
await handleTransferFailed(event.data);
break;
case 'subscription.create':
await handleSubscriptionCreate(event.data);
break;
case 'invoice.payment_failed':
await handleInvoicePaymentFailed(event.data);
break;
default:
console.log('Unhandled event:', event.event);
}
}
async function handleChargeSuccess(data: Record) {
var reference = data.reference as string;
var amount = data.amount as number;
var currency = data.currency as string;
var customerEmail = (data.customer as { email: string }).email;
console.log('Payment confirmed:', reference, amount / 100, currency, customerEmail);
// Update your database here
}
async function handleTransferSuccess(data: Record) {
console.log('Transfer successful:', data.reference);
}
async function handleTransferFailed(data: Record) {
console.log('Transfer failed:', data.reference);
}
async function handleSubscriptionCreate(data: Record) {
console.log('Subscription created:', data.subscription_code);
}
async function handleInvoicePaymentFailed(data: Record) {
console.log('Invoice payment failed:', data.subscription);
}
Get weekly developer tips
Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.
No spam. Unsubscribe anytime.
Paystack retries failed webhook deliveries. Your handler may receive the same event more than once. Use a database table to track processed events:
// With a Supabase client, for example
async function isAlreadyProcessed(eventId: string): Promise {
var { data } = await supabase
.from('processed_webhook_events')
.select('id')
.eq('event_id', eventId)
.single();
return !!data;
}
async function markProcessed(eventId: string): Promise {
await supabase
.from('processed_webhook_events')
.insert({ event_id: eventId, processed_at: new Date().toISOString() });
}
// In your handler:
async function handleEvent(event: { id?: string; event: string; data: Record }) {
var eventId = event.id || (event.data.reference as string);
if (await isAlreadyProcessed(eventId)) {
console.log('Duplicate event, skipping:', eventId);
return;
}
await markProcessed(eventId);
// Process event
}
The event object from Paystack may not have a unique id field. Use the transaction reference or a combination of event type and reference as the idempotency key.
Add your webhook URL in the Paystack dashboard:
https://yourdomain.com/api/webhooks/paystackFor local development, use a tunnel to expose your local server:
# With ngrok
npx ngrok http 5173
# Then set webhook URL to: https://abc123.ngrok.io/api/webhooks/paystack
# With Cloudflare Tunnel
npx cloudflared tunnel --url http://localhost:5173
This guide is part of the Paystack integration guides by language and framework series.
Sign up for the McTaba newsletter for more Paystack guides.
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