Serverless & edge functions
If your app has no traditional backend — a React or Flutter frontend on top of Supabase, Firebase, or a static host — you still need somewhere server-side to hold your keys. A single function is enough.
The shape is the same on every platform: the function is the only thing that ever holds a key, and the only thing that ever talks to Pesepay.
- Browser → Your function Start checkout an order id — never an amount
- Your function → Pesepay Initiate the transaction keys never leave here
- Pesepay → Your function redirectUrl
- Your function → Browser redirectUrl browser goes to checkout
- Pesepay → Your function Result callback unsigned, never retried
- Your function → Pesepay Confirm the status the answer you trust
The example below uses Supabase Edge Functions, but the same three functions map directly onto Vercel and Netlify functions, Cloudflare Workers, Firebase Cloud Functions, or AWS Lambda.
What you need
Section titled “What you need”Directorysupabase/functions/
Directory_shared/
- pesepay.ts crypto + fetch helpers
Directorycreate-payment/
- index.ts called by your frontend
Directorypayment-status/
- index.ts called by your frontend
Directorypayment-callback/
- index.ts called by Pesepay
Keys go in Supabase secrets, never in the repo:
supabase secrets set \ PESEPAY_INTEGRATION_KEY=your_integration_key \ PESEPAY_ENCRYPTION_KEY=your_32_character_encryption_key \ PESEPAY_BASE_URL=https://api.test.sandbox.pesepay.com/payments-engineThe shared helper
Section titled “The shared helper”Supabase Edge Functions run on Deno, which supports Node’s built-in
crypto through the node: prefix — so the
standard encryption helpers work unchanged.
import { createCipheriv, createDecipheriv } from 'node:crypto';import { Buffer } from 'node:buffer';
const KEY = Deno.env.get('PESEPAY_ENCRYPTION_KEY')!;const INTEGRATION_KEY = Deno.env.get('PESEPAY_INTEGRATION_KEY')!;const BASE_URL = Deno.env.get('PESEPAY_BASE_URL')!;
// The IV is the first 16 characters of the encryption key itself.const iv = Buffer.from(KEY.substring(0, 16), 'utf8');const key = Buffer.from(KEY, 'utf8');
export function encrypt(data: unknown): string { const cipher = createCipheriv('aes-256-cbc', key, iv); return cipher.update(JSON.stringify(data), 'utf8', 'base64') + cipher.final('base64');}
export function decrypt<T>(payload: string): T { const decipher = createDecipheriv('aes-256-cbc', key, iv); const json = decipher.update(payload, 'base64', 'utf8') + decipher.final('utf8'); return JSON.parse(json) as T;}
export async function pesepay<T>(path: string, body?: unknown): Promise<T> { const response = await fetch(`${BASE_URL}${path}`, { method: body ? 'POST' : 'GET', headers: { authorization: INTEGRATION_KEY, 'content-type': 'application/json', }, body: body ? JSON.stringify({ payload: encrypt(body) }) : undefined, });
// Error bodies are plain JSON, not encrypted — don't try to decrypt them. if (!response.ok) { const error = await response.json(); throw new Error(`Pesepay ${response.status}: ${error.message}`); }
const { payload } = await response.json(); return decrypt<T>(payload);}1. Initiating a payment
Section titled “1. Initiating a payment”The frontend sends an order id — never an amount, and never a currency the user could tamper with. The function looks the order up, prices it itself, and returns only the redirect URL.
import { createClient } from 'jsr:@supabase/supabase-js@2';import { pesepay } from '../_shared/pesepay.ts';
Deno.serve(async (req) => { const { orderId } = await req.json();
// Service-role client: server-side only, bypasses RLS. const db = createClient( Deno.env.get('SUPABASE_URL')!, Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')! );
const { data: order, error } = await db .from('orders') .select('id, amount, currency_code, description') .eq('id', orderId) .single();
if (error || !order) { return new Response(JSON.stringify({ error: 'Order not found' }), { status: 404 }); }
const transaction = await pesepay<{ referenceNumber: string; redirectUrl: string }>( '/v1/payments/initiate', { amountDetails: { amount: order.amount, currencyCode: order.currency_code }, reasonForPayment: order.description, merchantReference: order.id, resultUrl: `${Deno.env.get('SUPABASE_URL')}/functions/v1/payment-callback`, returnUrl: `https://example.com/orders/${order.id}`, } );
await db .from('orders') .update({ reference_number: transaction.referenceNumber, status: 'PENDING' }) .eq('id', order.id);
return new Response(JSON.stringify({ redirectUrl: transaction.redirectUrl }), { headers: { 'content-type': 'application/json' }, });});2. Checking status
Section titled “2. Checking status”import { pesepay } from '../_shared/pesepay.ts';
Deno.serve(async (req) => { const referenceNumber = new URL(req.url).searchParams.get('referenceNumber');
const result = await pesepay<{ transactionStatus: string }>( `/v1/payments/check-payment?referenceNumber=${referenceNumber}` );
return new Response(JSON.stringify({ status: result.transactionStatus }), { headers: { 'content-type': 'application/json' }, });});Poll this from the frontend while the customer is paying — but treat the callback below, not the poll, as what settles the order.
3. Handling the callback
Section titled “3. Handling the callback”Two things make the callback function different from the other two:
- It must be publicly reachable. Pesepay sends no Supabase JWT, so deploy
it with
--no-verify-jwt(or setverify_jwt = falseinsupabase/config.toml). - Its body is plain JSON, not encrypted, and carries no signature. Don’t decrypt it, and don’t trust it — use it only as a trigger to go and ask Pesepay what really happened. See Verifying callbacks.
import { createClient } from 'jsr:@supabase/supabase-js@2';import { pesepay } from '../_shared/pesepay.ts';
Deno.serve(async (req) => { // Pesepay sends your own integration key in the Authorization header. // A cheap first filter, not proof — the confirmation below is what counts. // Genuine callbacks very occasionally arrive without the header, so the // reconciliation job is what catches anything dropped here. if (req.headers.get('authorization') !== Deno.env.get('PESEPAY_INTEGRATION_KEY')) { return new Response('OK', { status: 200 }); // ignore, don't advertise }
const callback = await req.json(); // plain JSON, unsigned const reference = callback.referenceNumber;
// Re-fetch the authoritative status; never trust the callback body. const result = await pesepay<{ transactionStatus: string; amountDetails: { amount: number } }>( `/v1/payments/check-payment?referenceNumber=${reference}` );
const db = createClient( Deno.env.get('SUPABASE_URL')!, Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')! );
// Idempotent: only move an order that is still pending, so a duplicate // callback can't fulfil the same order twice. await db .from('orders') .update({ status: result.transactionStatus, paid_amount: result.amountDetails.amount }) .eq('reference_number', reference) .eq('status', 'PENDING');
// Always 200. Pesepay does not retry, and a non-200 changes nothing. return new Response('OK', { status: 200 });});Deploy:
supabase functions deploy create-paymentsupabase functions deploy payment-statussupabase functions deploy payment-callback --no-verify-jwtOther platforms
Section titled “Other platforms”| Platform | Notes |
|---|---|
| Vercel / Netlify functions | Node runtime — use crypto directly and process.env. No changes to the logic |
| Firebase Cloud Functions | Same as above; set keys with firebase functions:secrets:set |
| Cloudflare Workers | Add compatibility_flags = ["nodejs_compat"] to wrangler.toml, then import { createCipheriv } from 'node:crypto' as above. Without that flag there is no node:crypto |
| AWS Lambda | Node runtime; keep keys in Secrets Manager or SSM rather than plain environment variables |
Whichever you pick, the two rules don’t change: keys stay server-side, and the callback is a hint to re-check, not a source of truth.