Accept Payments with Paystack in Svelte and SvelteKit
To accept Paystack payments in SvelteKit, create a server endpoint at src/routes/api/pay/+server.ts that initializes transactions using your secret key from $env/static/private. Your Svelte page loads Paystack Inline.js, calls the endpoint, and opens the popup with the access code. After payment, another endpoint verifies the transaction.
Why SvelteKit for Paystack
SvelteKit is the full-stack framework for Svelte. Like Nuxt for Vue and Next.js for React, SvelteKit gives you server-side capabilities alongside your Svelte frontend. Server endpoints (+server.ts) and load functions (+page.server.ts) run on the server, so your Paystack secret key never reaches the browser.
If you use plain Svelte without SvelteKit, you need a separate backend (Express, Django, etc.) for Paystack integration. SvelteKit handles everything in one project.
Project Setup
Store your Paystack keys in a .env file:
# .env
PAYSTACK_SECRET_KEY=sk_test_xxxxxxxxxxxx
PUBLIC_PAYSTACK_KEY=pk_test_xxxxxxxxxxxx
SvelteKit makes private env variables available through $env/static/private and public ones through $env/static/public. Private variables are only accessible in server-side code (+server.ts, +page.server.ts, +layout.server.ts).
Server Endpoint: Initialize Transaction
// src/routes/api/pay/+server.ts
import { PAYSTACK_SECRET_KEY } from '$env/static/private';
import { json, error } from '@sveltejs/kit';
export async function POST({ request }) {
var body = await request.json();
var email = body.email;
var amount = body.amount;
var reference = 'svelte_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8);
var response = await fetch('https://api.paystack.co/transaction/initialize', {
method: 'POST',
headers: {
Authorization: 'Bearer ' + PAYSTACK_SECRET_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: email,
amount: Math.round(amount * 100),
reference: reference,
}),
});
var data = await response.json();
if (!data.status) {
throw error(400, data.message);
}
return json({
access_code: data.data.access_code,
authorization_url: data.data.authorization_url,
reference: data.data.reference,
});
}
Inline Popup Checkout
<!-- src/routes/checkout/+page.svelte -->
<svelte:head>
<script src="https://js.paystack.co/v2/inline.js"></script>
</svelte:head>
<script>
var email = '';
var amount = 5000;
var loading = false;
async function handlePay() {
if (!email) return;
loading = true;
try {
var res = await fetch('/api/pay', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: email, amount: amount }),
});
var data = await res.json();
if (!data.access_code) {
alert('Initialization failed');
loading = false;
return;
}
var popup = new window.PaystackPop();
popup.checkout({
accessCode: data.access_code,
onSuccess: async function(transaction) {
var verifyRes = await fetch('/api/verify?reference=' + transaction.reference);
var result = await verifyRes.json();
if (result.verified) {
window.location.href = '/payment/success?ref=' + transaction.reference;
}
loading = false;
},
onCancel: function() {
loading = false;
},
});
} catch (err) {
alert('Network error');
loading = false;
}
}
</script>
<div>
<h1>Checkout</h1>
<input bind:value={email} type="email" placeholder="Email address" />
<p>Amount: NGN {amount.toLocaleString()}</p>
<button on:click={handlePay} disabled={loading || !email}>
{loading ? 'Processing...' : 'Pay Now'}
</button>
</div>
Svelte's reactivity means you do not need useState or signals. Just assign to the variable and the UI updates automatically.
Server Endpoint: Verify Transaction
// src/routes/api/verify/+server.ts
import { PAYSTACK_SECRET_KEY } from '$env/static/private';
import { json, error } from '@sveltejs/kit';
export async function GET({ url }) {
var reference = url.searchParams.get('reference');
if (!reference) {
throw error(400, 'Reference required');
}
var response = await fetch(
'https://api.paystack.co/transaction/verify/' + encodeURIComponent(reference),
{
headers: { Authorization: 'Bearer ' + PAYSTACK_SECRET_KEY },
}
);
var data = await response.json();
if (data.status && data.data.status === 'success') {
return json({
verified: true,
amount: data.data.amount / 100,
currency: data.data.currency,
reference: data.data.reference,
});
}
throw error(400, 'Payment not verified');
}
Redirect Checkout with Load Function
For redirect checkout, the customer leaves your app. On return, use a SvelteKit load function to verify:
// src/routes/payment/callback/+page.server.ts
import { PAYSTACK_SECRET_KEY } from '$env/static/private';
export async function load({ url }) {
var reference = url.searchParams.get('reference') || url.searchParams.get('trxref');
if (!reference) {
return { verified: false, error: 'No reference' };
}
var response = await fetch(
'https://api.paystack.co/transaction/verify/' + encodeURIComponent(reference),
{
headers: { Authorization: 'Bearer ' + PAYSTACK_SECRET_KEY },
}
);
var data = await response.json();
if (data.status && data.data.status === 'success') {
return {
verified: true,
amount: data.data.amount / 100,
currency: data.data.currency,
reference: data.data.reference,
};
}
return { verified: false, error: 'Payment not verified' };
}
<!-- src/routes/payment/callback/+page.svelte -->
<script>
export var data;
</script>
{#if data.verified}
<h1>Payment Confirmed</h1>
<p>{data.currency} {data.amount}</p>
<p>Reference: {data.reference}</p>
{:else}
<h1>Verification Failed</h1>
<p>{data.error}</p>
{/if}
The load function runs on the server, so the verification happens before the page renders. The customer sees the result instantly.
Production Checklist
- Switch keys. Use live Paystack keys in your production environment.
- Amount validation. Look up prices from your database in the server endpoint. Do not trust frontend amounts.
- Webhooks. Set up a webhook endpoint for reliable payment confirmation. See Handle Paystack Webhooks in SvelteKit.
- HTTPS. Required for both Paystack and production deployments.
- Idempotent fulfillment. Both verification and webhook can confirm the same payment. Use a database flag.
Key Takeaways
- ✓SvelteKit server endpoints replace the need for a separate backend. Your Paystack secret key stays in $env/static/private.
- ✓Create +server.ts files for API endpoints. They run on the server only.
- ✓Load Paystack Inline.js with svelte:head or onMount. Open the popup in your Svelte component.
- ✓For redirect checkout, set the callback_url to a SvelteKit route that verifies using a load function.
- ✓Amounts must be in the smallest currency unit (kobo for NGN, pesewas for GHS, cents for KES/ZAR/USD).
- ✓SvelteKit form actions provide an alternative to fetch-based checkout for progressive enhancement.
Frequently Asked Questions
- Do I need a separate backend with SvelteKit?
- No. SvelteKit server endpoints (+server.ts) and load functions (+page.server.ts) run on the server. Your Paystack secret key stays in $env/static/private and never reaches the browser.
- Can I use plain Svelte without SvelteKit for Paystack?
- Yes, but you need a separate backend for initialization and verification since plain Svelte has no server-side capabilities. SvelteKit is recommended for a complete solution.
- Should I use form actions or fetch for the checkout?
- Both work. Form actions give you progressive enhancement (the checkout works without JavaScript for redirect checkout). Fetch is needed for the inline popup since it requires JavaScript. Most developers use fetch for the popup flow.
- How does SvelteKit handle environment variables for Paystack?
- $env/static/private variables are only available in server-side code. $env/static/public variables are available everywhere. Use private for the secret key and public for the publishable key.
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