Bonaventure OgetoBy Bonaventure Ogeto|

Handle Paystack Webhooks in Svelte and SvelteKit

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.

SvelteKit Webhook Endpoint Setup

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

The Webhook Handler

// 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);
}

Idempotency: Handle Duplicate Events

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.

Register the Webhook URL

Add your webhook URL in the Paystack dashboard:

  1. Log in to your Paystack dashboard
  2. Go to Settings → API Keys & Webhooks
  3. Add your webhook URL: https://yourdomain.com/api/webhooks/paystack

For 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

Production Checklist

  1. Use live keys. Switch PAYSTACK_SECRET_KEY to your live key in production.
  2. Queue heavy work. For database writes that might be slow, push events to a queue (BullMQ, Inngest, etc.) rather than processing synchronously.
  3. Log all events. Store every incoming webhook payload for debugging. You will need it.
  4. Add idempotency. Track processed event references in your database.
  5. Test locally. Use ngrok or Cloudflare Tunnel to test before deploying.

Ship Payments Faster

This guide is part of the Paystack integration guides by language and framework series.

Sign up for the McTaba newsletter for more Paystack guides.

Key Takeaways

  • SvelteKit server endpoints (+server.ts) handle Paystack webhooks cleanly. No separate backend required.
  • You must read the raw body with request.text() before JSON parsing. Parsing first destroys the signature.
  • The x-paystack-signature header contains an HMAC SHA512 hex digest of the raw body signed with your secret key.
  • Return a 200 response before doing any database work. Paystack retries if your endpoint takes too long.
  • Store a processed flag in your database for each webhook event to avoid double-processing on retries.
  • SvelteKit server endpoints do not have CSRF protection, so no exemption configuration is needed.

Frequently Asked Questions

Why does signature verification fail in SvelteKit?
The most common cause is parsing the request body with request.json() before verification. JSON parsing reconstructs the string, which changes whitespace and key ordering, making the hash not match. Always use request.text() to read the raw body first, then JSON.parse() after signature verification.
Do I need to configure CSRF exemption for the webhook endpoint?
No. SvelteKit server endpoints (+server.ts) do not have CSRF protection built in. No exemption is needed. This differs from form actions (+page.server.ts), which have CSRF protection.
How do I test Paystack webhooks locally in SvelteKit?
Use ngrok (npx ngrok http 5173) or Cloudflare Tunnel (npx cloudflared tunnel --url http://localhost:5173) to create a public URL that tunnels to your local dev server. Register that URL as your Paystack webhook URL in the dashboard.
What happens if my webhook handler throws an error?
If your endpoint returns a non-200 status, Paystack will retry the webhook later. This is why you should return 200 immediately and handle errors in your processing code. Log errors so you can investigate, but do not let processing failures prevent the 200 response.

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