To verify Paystack payments in SvelteKit, create a server endpoint that calls the Paystack Verify Transaction API with your secret key from $env/static/private. Call this endpoint from your Svelte component after the popup callback, or use a load function on the redirect callback page for SSR verification.
After a customer completes payment, you need to confirm the transaction actually succeeded. The Paystack Verify Transaction API is the only reliable way. It requires your secret key, so it must run on the server.
SvelteKit gives you two server-side options:
Both approaches are valid. Use the endpoint for popup checkout and the load function for redirect checkout.
// 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 is 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) {
throw error(400, data.message || 'Verification failed');
}
var txn = data.data;
// Validate amount against database
// var order = await db.orders.findOne({ reference: reference });
// if (!order || txn.amount !== order.amountInKobo) {
// throw error(400, 'Amount mismatch');
// }
if (txn.status === 'success') {
// Mark order as paid (idempotent)
// await db.orders.updateOne({ reference, status: 'pending' }, { status: 'paid' });
return json({
verified: true,
amount: txn.amount / 100,
currency: txn.currency,
reference: txn.reference,
channel: txn.channel,
paidAt: txn.paid_at,
});
}
throw error(400, 'Payment status: ' + txn.status);
}
Get weekly developer tips
Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.
No spam. Unsubscribe anytime.
<!-- In your checkout page -->
<script>
var verifyState = 'idle'; // idle | verifying | success | failed
var verifyResult = null;
async function onPaystackSuccess(transaction) {
verifyState = 'verifying';
try {
var res = await fetch('/api/verify?reference=' + transaction.reference);
var data = await res.json();
if (data.verified) {
verifyState = 'success';
verifyResult = data;
} else {
verifyState = 'failed';
}
} catch (err) {
verifyState = 'failed';
}
}
</script>
{#if verifyState === 'verifying'}
<p>Verifying your payment...</p>
{:else if verifyState === 'success'}
<h2>Payment Confirmed</h2>
<p>{verifyResult.currency} {verifyResult.amount}</p>
<p>Reference: {verifyResult.reference}</p>
{:else if verifyState === 'failed'}
<p>Verification failed. Contact support.</p>
{/if}
For redirect checkout, use a load function to verify during SSR:
// 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 found in URL' };
}
try {
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') {
// Update database (idempotent)
return {
verified: true,
amount: data.data.amount / 100,
currency: data.data.currency,
reference: data.data.reference,
channel: data.data.channel,
};
}
return { verified: false, error: 'Payment status: ' + (data.data?.status || 'unknown') };
} catch (err) {
return { verified: false, error: 'Verification request failed' };
}
}
<!-- src/routes/payment/callback/+page.svelte -->
<script>
export var data;
</script>
{#if data.verified}
<h1>Payment Successful</h1>
<p>Amount: {data.currency} {data.amount}</p>
<p>Reference: {data.reference}</p>
<p>Paid via: {data.channel}</p>
<a href="/">Back to Home</a>
{:else}
<h1>Verification Failed</h1>
<p>{data.error}</p>
<p>If you were charged, contact support.</p>
{/if}
Because the load function runs on the server during SSR, the HTML arrives with the verification result already rendered. No loading spinner needed.
Checking status === "success" is not enough. An attacker could initialize a transaction for 1 NGN instead of 10,000 NGN. Your server must compare the verified amount against your database:
// In your verification logic
var txn = data.data;
// Look up the order
var order = await db.orders.findOne({ reference: reference });
if (!order) {
throw error(404, 'Order not found');
}
if (txn.amount !== order.amountInKobo || txn.currency !== order.currency) {
// Log potential fraud
console.error('Amount mismatch', {
expected: order.amountInKobo,
received: txn.amount,
reference: reference,
});
throw error(400, 'Amount mismatch');
}
// Amount matches, proceed with fulfillment
The popup verification, the redirect callback, and the webhook can all confirm the same payment. Make your fulfillment logic idempotent so processing the same transaction twice has no extra effect:
// Update only if not already paid
var result = await db.orders.updateOne(
{ reference: reference, status: 'pending' },
{ status: 'paid', paidAt: txn.paid_at }
);
if (result.modifiedCount === 0) {
// Already fulfilled, just return success without re-granting
return json({ verified: true, amount: txn.amount / 100 });
}
// First time confirming, grant access, send email, etc.
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