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
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:
  1. Identity and onboarding — link or create a Raast merchant, then persist the aggregator merchant token your payment calls will need forever after.
  2. Pay-in product choice — RTP Now vs RTP Later vs Dynamic QR vs Static QR, each with different UX and expiry semantics.
  3. Payouts — same stack, different amount unit, different mental model for status.
  4. Asynchronous truth — customers approve inside banking apps; your system learns the outcome from webhooks, not from the create response alone.
  5. 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.
EnvironmentBase URL
Sandboxhttps://dev.api.getsafepay.com/raastwire
Productionhttps://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.
FlowWhen it fitsAmount unitNotes
RTP NowCustomer can approve immediately in their banking apppaisa (integer)Tight expiry; good for checkout
RTP LaterInvoice or deferred approvalpaisaLonger expiry_in_minutes
Dynamic QRAmount known at checkout; payer unknown until scanpaisa (optional)Render EMVCo data.code
Static QRCounter / reusable collectionn/a at createReconcile 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): amount is paisa — an integer minor unit. 94000 means PKR 940.00.
  • Payouts (/payout): amount is 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 body
The 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:
  1. Read the raw body plus X-SFPY-TIMESTAMP, X-SFPY-SIGNATURE, and X-SFPY-EVENT-ID.
  2. Reject stale timestamps (default ±5 minutes).
  3. Verify HMAC with a timing-safe compare.
  4. Upsert by X-SFPY-EVENT-ID so retries are idempotent.
  5. Return 2xx quickly; do heavy work async.
  6. Invalid signatures → 4xx so 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: requestedpending_authorizationcompleted / failed / rejected.

An end-to-end verification bar

Before you call an integration done, run a sandbox path that a human engineer would trust:
  1. Create and persist a merchant token.
  2. Initiate one pay-in (RTP Now or Dynamic QR) with a known order_id.
  3. Complete or simulate customer approval in the documented sandbox flow.
  4. Receive and verify a webhook; confirm your upsert key is the event id.
  5. Reconcile your ledger row against Safepay’s payment object.
  6. Send a small payout; confirm status arrives on payment.* as PAYOUT.
  7. Intentionally break verify (wrong secret or mutated body) and confirm you return 4xx without 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.*, not payout.*
  • Idempotent request_id policy documented for client teams
  • Reconciliation matches Safepay settlement views
  • Alerts on verify failures and elevated Safepay 4xx

Getting started

  1. Request aggregator access and store your secret key.
  2. Create and link merchants; persist data.token.
  3. Ship one pay-in path plus webhook verify before you add a second product surface.
  4. Add payouts once settlement and event handling are solid.
  5. 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.