All posts
Blog
What it actually takes to ship Raast as an aggregator
Raast looks simple until you hit paisa vs rupees, Bearer auth that isn't, and payouts that arrive on payment.* webhooks. A practical playbook for shipping Safepay Raast end to end.

Ziyad Parekh@ziyadparekh

What it actually takes to ship Raast as an aggregator
Pakistan’s instant payment rail, Raast, is already changing how people move money. For a marketplace, a tuition platform, or a payouts product, that should be good news: lower friction than cards for many local use cases, and a clearer path to bank-linked collection and disbursement.
The catch is the same one every serious payments integration hits. A mostly correct Raast integration is still a failure. Amounts, auth headers, debtor identifiers, and webhook verification have to be right on every request — not “right enough to demo.”
Safepay’s Raastwire API is built so aggregators can run RTP, QR pay-ins, and payouts on one credential set, with status delivered through the same operational model you already use for payments. This post is the playbook we wish teams had before their first production incident: the journey, the glue work, and the failure modes that show up only after the happy-path curl works.
If you want the checklist form of this journey, start with our Raast integration guide. What follows is the engineering narrative behind it.
The problem isn’t calling an API
Calling
POST /payments is the easy part. Shipping Raast end to end means stitching together work that spans products and teams:- Identity and onboarding — link or create a Raast merchant, then persist the aggregator merchant token your payment calls will need forever after.
- Pay-in product choice — RTP Now vs RTP Later vs Dynamic QR vs Static QR, each with different UX and expiry semantics.
- Payouts — same stack, different amount unit, different mental model for status.
- Asynchronous truth — customers approve inside banking apps; your system learns the outcome from webhooks, not from the create response alone.
- Verification — HMAC over the raw body, idempotent event handling, reconciliation that survives retries.
Miss any one of those and you don’t get a “soft” bug. You get mismatched ledgers, silent payouts, or verify failures that look like network noise.
Environments and the auth header that isn’t Bearer
Every Raastwire call authenticates with an aggregator secret key header. There is no
Authorization: Bearer path here. Teams that paste a Stripe- or GitHub-shaped client and swap the base URL will get rejected until they change the header.| Environment | Base URL |
|---|---|
| Sandbox | https://dev.api.getsafepay.com/raastwire |
| Production | https://api.getsafepay.com/raastwire |
const headers = {
'Content-Type': 'application/json',
'X-SFPY-AGGREGATOR-SECRET-KEY': process.env.SAFEPAY_SECRET_KEY!,
};
const baseUrl =
process.env.SAFEPAY_ENV === 'production'
? 'https://api.getsafepay.com/raastwire'
: 'https://dev.api.getsafepay.com/raastwire';
const aggregatorId = process.env.SAFEPAY_AGGREGATOR_ID!;
Treat the secret like production card data: vault it, rotate it, never log it. If you restrict keys later, make sure the permissions still cover the Raast actions you call in production.
Merchant onboarding is where state management starts
Before you can collect, you need an aggregator merchant linked to a Raast merchant identity. The create response includes a
data.token. That token is not a temporary session. It is your aggregator_merchant_identifier for payments and QR. Lose it and you rebuild mapping tables under pressure.const createMerchantResponse = await fetch(
`${baseUrl}/v1/aggregators/${aggregatorId}/merchants`,
{
method: 'POST',
headers,
body: JSON.stringify({
merchant_external_id: 'sec_0c3de397-441f-471f-bcd7-b6d948e1c307',
iban: 'PK62ABPA0010000222380013',
name: 'Acme Foods Saddar',
enabled: true,
raast_merchant_id: raastMerchantId,
rate_card: {
ratecard_kind: 'RateCardKind_fixed',
fixed_rate: 3000, // paisa
tax_region: 'PK',
tax_rate: 0.1,
},
}),
},
);
if (!createMerchantResponse.ok) {
const errorBody = await createMerchantResponse.json();
throw new Error(
`Safepay Raast error: ${createMerchantResponse.status} ${errorBody.message}`,
);
}
const merchantPayload = await createMerchantResponse.json();
// Persist this. Payments and QR will not invent it for you.
const aggregatorMerchantIdentifier = merchantPayload.data.token;
If the Raast merchant does not exist yet, finish KYC/KYB first, then link
raast_merchant_id and enable initiation. Same-day approval is common when the Raast merchant is already onboarded; new merchants need the paperwork path. Details live in the integration journey.Choosing a pay-in shape
One credential set covers the pay-in surface. The product decision is about customer experience and timing, not about different auth stacks.
| Flow | When it fits | Amount unit | Notes |
|---|---|---|---|
| RTP Now | Customer can approve immediately in their banking app | paisa (integer) | Tight expiry; good for checkout |
| RTP Later | Invoice or deferred approval | paisa | Longer expiry_in_minutes |
| Dynamic QR | Amount known at checkout; payer unknown until scan | paisa (optional) | Render EMVCo data.code |
| Static QR | Counter / reusable collection | n/a at create | Reconcile via list + metadata |
RTP Now: the checkout-shaped path
// Amount is in paisa (PKR minor units). 94000 = PKR 940.00
const rtpResponse = await fetch(
`${baseUrl}/v1/aggregators/${aggregatorId}/payments`,
{
method: 'POST',
headers,
body: JSON.stringify({
request_id: crypto.randomUUID(),
amount: 94000,
aggregator_merchant_identifier: aggregatorMerchantIdentifier,
order_id: 'ORDER-RAAST-001',
type: 'RTP_NOW',
expiry_in_minutes: 45,
debitor_iban: 'PK89SCBL0000001234651601',
// Or: debitor_raast_id / debitor_vault_token
}),
},
);
if (!rtpResponse.ok) {
const errorPayload = await rtpResponse.json();
throw new Error(
`RTP request failed: ${rtpResponse.status} ${errorPayload.message}`,
);
}
const paymentPayload = await rtpResponse.json();
// Treat create success as “request accepted,” not “money settled.”
For RTP Later, keep the same endpoint and switch
type to RTP_LATER with a longer expiry. The debtor identifier rules stay the same; only the approval window changes. See RTP Now for the use-case framing.Dynamic QR: when you know the amount, not the payer
const qrResponse = await fetch(
`${baseUrl}/v1/aggregators/${aggregatorId}/qrs`,
{
method: 'POST',
headers,
body: JSON.stringify({
type: 'DYNAMIC',
aggregator_merchant_identifier: aggregatorMerchantIdentifier,
order_id: 'ORDER-4921',
request_id: crypto.randomUUID(),
amount: 7500, // paisa; omit to let the payer choose
expiry_in_minutes: 180,
}),
},
);
if (!qrResponse.ok) {
const errorPayload = await qrResponse.json();
throw new Error(
`Dynamic QR failed: ${qrResponse.status} ${errorPayload.message}`,
);
}
const qrPayload = await qrResponse.json();
// Render data.code (EMVCo string) with any QR library
Static QR is the reusable cousin: create once, then track inbound payments by amount, metadata, or listing payments on that QR. Guide: Dynamic QR.
The amount unit bug that keeps shipping
This is the most common production mistake we see in Raast integrations, and it is almost always a copy-paste error between pay-in and payout helpers.
- Pay-ins (
/payments,/qrs):amountis paisa — an integer minor unit.94000means PKR 940.00. - Payouts (
/payout):amountis a PKR string."200"means PKR 200.
If your shared
Money type assumes “always minor units,” payouts will underpay or overpay by 100× depending on which direction you normalize wrong. Put the unit in the type name (Paisa, PkrString) and make the compiler refuse to mix them.Payouts: same stack, different status story
// Payout amount is a PKR string (not paisa). "200" = PKR 200.
const payoutResponse = await fetch(
`${baseUrl}/v1/aggregators/${aggregatorId}/payout`,
{
method: 'POST',
headers,
body: JSON.stringify({
request_id: crypto.randomUUID(),
amount: '200',
creditor_iban: 'PK25ALFH0216001008658216',
}),
},
);
if (!payoutResponse.ok) {
const error = await payoutResponse.json();
throw new Error(`Payout failed: ${payoutResponse.status} ${error.message}`);
}
Status does not arrive on a
payout.* event family. Listen on payment.* and branch when the type is PAYOUT or SETTLEMENT_PAYOUT. Teams that only subscribed to a guessed payout.* topic watch a quiet queue while money moves.Idempotency: reuse
request_id only with the exact same payload. Changing fields under a recycled id produces confusing duplicates or rejects that are hard to debug after the fact. Guide: Payouts.Webhooks are the source of truth
Raast is asynchronous. Customers approve or reject inside banking apps. Safepay pushes the resulting status to your backend. If your create-payment handler marks an order paid before webhook confirmation, you will eventually ship goods against a declined request.
Subscribe carefully, then verify every delivery. Docs: Webhook concepts · Webhooks delivery.
The signed message is:
X-SFPY-TIMESTAMP + '.' + raw request bodyThe secret from create/rotate is base64 — decode it before HMAC. Never re-serialize JSON before verifying. Pretty-printing, key reordering, or parsing-then-
JSON.stringify will break signatures in ways that look intermittent.import crypto from 'node:crypto';
export function verifySafepayWebhook(opts: {
secretBase64: string;
rawBody: Buffer | string;
signatureHeader: string; // X-SFPY-SIGNATURE, e.g. sha256=...
timestampHeader: string; // X-SFPY-TIMESTAMP, RFC 3339
toleranceMs?: number; // default 5 minutes
}): boolean {
const toleranceMs = opts.toleranceMs ?? 5 * 60 * 1000;
const ts = Date.parse(opts.timestampHeader);
if (Number.isNaN(ts) || Math.abs(Date.now() - ts) > toleranceMs) {
return false;
}
const secret = Buffer.from(opts.secretBase64, 'base64');
const body =
typeof opts.rawBody === 'string' ? Buffer.from(opts.rawBody) : opts.rawBody;
const mac = crypto.createHmac('sha256', secret);
mac.update(opts.timestampHeader);
mac.update('.');
mac.update(body);
const expected = `sha256=${mac.digest('hex')}`;
const a = Buffer.from(expected);
const b = Buffer.from(opts.signatureHeader);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
Handler checklist that survives production traffic:
- Read the raw body plus
X-SFPY-TIMESTAMP,X-SFPY-SIGNATURE, andX-SFPY-EVENT-ID. - Reject stale timestamps (default ±5 minutes).
- Verify HMAC with a timing-safe compare.
- Upsert by
X-SFPY-EVENT-IDso retries are idempotent. - Return
2xxquickly; do heavy work async. - Invalid signatures →
4xxso you do not invite retry storms on bad traffic.
Where integrations stall (and how to unstick them)
These are the stalls we see after the first sandbox success:
Auth shaped like another API. Bearer tokens, Basic auth wrappers, or SDK defaults that inject
Authorization will fail until the header is exactly X-SFPY-AGGREGATOR-SECRET-KEY.Merchant token treated as ephemeral. If
data.token lives only in memory for the onboarding request, the next payment call has nothing durable to send.Amount helpers shared carelessly. Paisa integers and PKR strings collide the first time payouts share a money utility with pay-ins.
Webhook body buffered as JSON. Frameworks that parse the body before your verify middleware see valid JSON and invalid signatures. Capture the raw buffer first.
Payout listeners on the wrong event namespace. Money moves; your ops dashboard stays empty because you never subscribed to
payment.* with a PAYOUT type filter.request_id recycled with new fields. Idempotency then works against you. Document a client policy: new payload ⇒ new id.Create response treated as settlement. Especially with RTP Later and QR, settlement is a webhook story. Design order state machines accordingly:
requested → pending_authorization → completed / failed / rejected.An end-to-end verification bar
Before you call an integration done, run a sandbox path that a human engineer would trust:
- Create and persist a merchant token.
- Initiate one pay-in (RTP Now or Dynamic QR) with a known
order_id. - Complete or simulate customer approval in the documented sandbox flow.
- Receive and verify a webhook; confirm your upsert key is the event id.
- Reconcile your ledger row against Safepay’s payment object.
- Send a small payout; confirm status arrives on
payment.*asPAYOUT. - Intentionally break verify (wrong secret or mutated body) and confirm you return
4xxwithout marking paid.
If any step is “we’ll monitor in production,” the integration is not finished.
Ops checklist
- Secrets only in vault; rotation runbook tested
- Sandbox end-to-end: merchant → RTP or QR → webhook → reconcile
- Amount units reviewed in code review (paisa vs PKR string)
- Webhook verify uses raw body; no JSON re-stringify
- Payout listeners on
payment.*, notpayout.* - Idempotent
request_idpolicy documented for client teams - Reconciliation matches Safepay settlement views
- Alerts on verify failures and elevated Safepay
4xx
Getting started
- Request aggregator access and store your secret key.
- Create and link merchants; persist
data.token. - Ship one pay-in path plus webhook verify before you add a second product surface.
- Add payouts once settlement and event handling are solid.
- Harden ops: rotation, reconciliation, alerts.
Docs to keep open while you build:
Raast is ready for Pakistani businesses when the integration is. The API will meet you halfway — the other half is the glue work above, done with the same rigor you already apply to money.