Verify Paystack Payments in Svelte and SvelteKit
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.
How Verification Works in SvelteKit
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:
- Server endpoint (+server.ts): Create a GET endpoint that your Svelte component calls via fetch after the popup callback.
- Load function (+page.server.ts): Verify during SSR on the redirect callback page. The result is available before the page renders.
Both approaches are valid. Use the endpoint for popup checkout and the load function for redirect checkout.
Verification Server Endpoint
// 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);
}
Verify After the Popup
<!-- 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}
Verify on the Redirect Callback Page (SSR)
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.
Amount Validation
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
Idempotent 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.
Key Takeaways
- ✓SvelteKit server endpoints and load functions handle verification securely. The Paystack secret key stays in $env/static/private.
- ✓Use a +server.ts endpoint for popup verification and a +page.server.ts load function for redirect callback verification.
- ✓Load functions verify during SSR so the customer sees the result immediately on the callback page.
- ✓Always validate the verified amount against your database. A successful status is necessary but not sufficient.
- ✓Make fulfillment idempotent. Both popup verification and webhooks can confirm the same transaction.
- ✓Return only sanitized fields from the verification response. Do not expose the full Paystack response.
Frequently Asked Questions
- Should I use a server endpoint or a load function for verification?
- Use a server endpoint (+server.ts) when verifying after the popup callback via fetch. Use a load function (+page.server.ts) when verifying on the redirect callback page for SSR. Both approaches are secure and keep the secret key on the server.
- Can I verify Paystack payments from a Svelte component?
- Not directly. The Verify API requires your secret key. Your Svelte component calls your SvelteKit server endpoint, which calls Paystack. The component never touches the Paystack API directly.
- What if the customer closes the browser after paying?
- The verification call from your frontend will never fire. This is why webhooks are essential. Your webhook handler on the server receives the charge.success event independently and can fulfill the order.
- How many times can I call the Paystack Verify API?
- As many times as you need. The API is read-only and idempotent. It always returns the current state of the transaction. Paystack does not charge for verification calls.
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