Skip to content

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.

  1. Browser → Your function Start checkout an order id — never an amount
  2. Your function → Pesepay Initiate the transaction keys never leave here
  3. Pesepay → Your function redirectUrl
  4. Your function → Browser redirectUrl browser goes to checkout
  5. Pesepay → Your function Result callback unsigned, never retried
  6. Your function → Pesepay Confirm the status the answer you trust
A payment through a serverless function

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.

  • 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:

Terminal window
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-engine

Supabase Edge Functions run on Deno, which supports Node’s built-in crypto through the node: prefix — so the standard encryption helpers work unchanged.

supabase/functions/_shared/pesepay.ts
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);
}

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.

supabase/functions/create-payment/index.ts
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' },
});
});
supabase/functions/payment-status/index.ts
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.

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 set verify_jwt = false in supabase/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.
supabase/functions/payment-callback/index.ts
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:

Terminal window
supabase functions deploy create-payment
supabase functions deploy payment-status
supabase functions deploy payment-callback --no-verify-jwt
PlatformNotes
Vercel / Netlify functionsNode runtime — use crypto directly and process.env. No changes to the logic
Firebase Cloud FunctionsSame as above; set keys with firebase functions:secrets:set
Cloudflare WorkersAdd compatibility_flags = ["nodejs_compat"] to wrangler.toml, then import { createCipheriv } from 'node:crypto' as above. Without that flag there is no node:crypto
AWS LambdaNode 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.