Back to all posts

cat posts/swish-handel-2026-integration-patterns-nodejs.md --category "Payment & E-Commerce" --views 1,263

Swish Handel 2026: Integration Patterns for Node Applications

Swish decommissioned the v1 create endpoints on 1 January 2026, so a current integration PUTs to /api/v2/paymentrequests while still using v1 to retrieve and cancel. The engineering work sits around that call: five-year client certificates rotated out of Azure Key Vault, callbackIdentifier verification, the documented callback retry schedule, refunds that need a fresh UUID on every attempt, and a sweeper for callbacks that never arrive.

  • --author By Falak Mahmood
  • --date February 12, 2026
  • --read 14 min read
  • --views 1,263 views

Updated 27 September 2026: the v1 create endpoints were decommissioned on 1 January 2026, plus callbackIdentifier verification, the callback retry schedule, refunds and cancellations, certificate rotation in Azure, and the MSS/Sandbox differences.

Swish för Handel is the agreement behind Swedish webshop, app and point-of-sale payments, and the developer documentation calls the interface the Swish Commerce API: payment requests and refunds over mutual TLS, results delivered by callback. The happy path takes an afternoon in Node. Surviving a year takes certificate lifecycle plus a reconciliation path for lost callbacks.

The Swish Handel Flow in One Picture

  1. Your server PUTs a payment request over mutual TLS with an instructionUUID you generate. Swish answers 201 Created and a Location header.
  2. Send payerAlias (the payer's phone number) and it is e-commerce: the user opens the Swish app themselves. Omit it and it is m-commerce, and the response also carries a PaymentRequestToken header. Same endpoint either way.
  3. The token reaches the Swish app by app-switch, or inside a QR code the payer scans.
  4. The payer accepts or declines, signing with BankID. Swish POSTs the outcome to your callback URL.
  5. You record the terminal status and release downstream effects.

What Changed on 1 January 2026

Version 1 of the payment request and refund APIs was decommissioned on 1 January 2026, but v1 did not disappear: the changelog is explicit that it is still used for GET and PATCH. A correct integration talks to both.

Operation Method and path
Create payment requestPUT /swish-cpcapi/api/v2/paymentrequests/{instructionUUID}
Retrieve payment requestGET /swish-cpcapi/api/v1/paymentrequests/{id}
Cancel payment requestPATCH /swish-cpcapi/api/v1/paymentrequests/{id}
Create refundPUT /swish-cpcapi/api/v2/refunds/{instructionUUID}
Retrieve refundGET /swish-cpcapi/api/v1/refunds/{id}

Production is https://cpc.getswish.net; the QR generator sits on https://mpc.getswish.net and needs no client certificate. Deprecated endpoints announce themselves with deprecation, sunset and link headers. The payment API is capped at 30 transactions per second, past which you get 429 with Retry-After, honoured plus a small random offset.

Certificates Are the Hardest Operational Part

Swish authenticates merchants with a PKI client certificate over TLS 1.2 or higher. Five facts shape the runbook.

  • The certificate is valid for five years, and renewing it in time is explicitly the merchant's responsibility. Five years is long enough that whoever set it up has usually left.
  • The CSR needs a 4096-bit key. Older, smaller CSRs are not usable with the current service.
  • Issue and revoke at portal.swish.nu with Mobile BankID, BankID on card or BxID, and only as someone the bank registered for that merchant and Swish number. A partner can be authorised for renewals.
  • Revocation is irreversible. The portal lists every certificate for a merchant and Swish number, which is what makes overlap possible: issue, deploy, revoke.
  • Production rejects a mismatch between the alias in your body and the Swish number in the certificate, with 403. The simulator does not, so this one surfaces on go-live day.

Pinning the server side is optional, since Swish server certificates come from a public CA already in standard trust stores. Pin and you own the migrations: in March 2025 the external APIs moved to GeoTrust TLS RSA CA G1 under DigiCert Global Root G2.

Certificates in Azure: Key Vault at Boot, Never in the Image

Keep the PKCS#12 in Key Vault, read it with a managed identity, build the TLS agent once. The detail that costs Next.js teams an afternoon: https.Agent does nothing for the global fetch. Undici wants its own dispatcher, TLS options under connect.

// lib/swish/dispatcher.ts
let cached: { version: string; agent: Agent } | null = null;
let checkedAt = 0;

// Read as a secret, a Key Vault certificate is base64 PKCS#12.
export async function swishDispatcher(): Promise<Agent> {
  if (cached && Date.now() - checkedAt < 300_000) return cached.agent;

  const { value, properties } = await vault.getSecret('swish-client-cert');
  checkedAt = Date.now();
  if (cached?.version === properties.version) return cached.agent;

  const agent = new Agent({ connect: {
    pfx: [{ buf: Buffer.from(value!, 'base64'),
            passphrase: process.env.SWISH_CERT_PASSPHRASE ?? '' }],
    minVersion: 'TLSv1.2',
  }});

  cached?.agent.close().catch(() => {});   // drain the old pool
  cached = { version: properties.version!, agent };
  return agent;
}

Rotation becomes a Key Vault operation rather than a deploy, and the pool is rebuilt rather than leaked: a stale pool keeps presenting the old certificate until its sockets die. This is the pattern from Key Vault secret rotation, identical across App Service, Container Apps and AKS.

The Payment Request

The instructionUUID is yours: 32 hexadecimal digits, uppercase, no hyphens. Write your row before calling Swish, because the callback can beat your own response home.

// lib/swish/paymentRequest.ts
const newId = () => randomUUID().replace(/-/g, '').toUpperCase();

export async function createPaymentRequest(order: Order, payerAlias?: string) {
  const instructionUUID = newId();
  const callbackIdentifier = newId();     // 32-36 of [0-9a-zA-Z-]

  // Persist first: a PAID callback can arrive before the PUT resolves.
  await createPaymentRow({ instructionUUID, callbackIdentifier, order });

  const res = await fetch(
    BASE + '/swish-cpcapi/api/v2/paymentrequests/' + instructionUUID, {
      method: 'PUT',
      headers: { 'Content-Type': 'application/json' },
      dispatcher: await swishDispatcher(),  // undici option, non-standard
      body: JSON.stringify({
        payeeAlias: process.env.SWISH_MERCHANT_ALIAS!,  // your 123 number
        amount: order.total.toFixed(2),                 // min 0.01 SEK
        currency: 'SEK',
        callbackUrl: process.env.APP_URL + '/api/swish/callback',
        payeePaymentReference: order.reference,
        message: shortMessage(order),
        callbackIdentifier,
        ...(payerAlias ? { payerAlias } : {}),   // e-com vs m-com
      }),
    } as RequestInit);

  if (res.status !== 201) throw await swishError(res, instructionUUID);
  // Header names arrive lowercased; the token is m-commerce only.
  return { instructionUUID, token: res.headers.get('paymentrequesttoken') };
}

Compute the amount server side. And if you reach for setGlobalDispatcher instead, scope it: a global dispatcher carrying your Swish certificate presents it to every host the process talks to.

App-Switch on Mobile, QR on Desktop

The documented recommendation is m-commerce plus app-switch in a native app or a mobile browser, via swish://paymentrequest?token=<token>&callbackurl=<urlencoded>. On desktop, choose between e-commerce with a typed phone number and m-commerce with the token as a QR code. The unauthenticated POST /qrg-swish/api/v1/commerce will render that QR for you, but doing it locally is usually better: the payload is only the token prefixed with a capital D.

The sharp edge here is the return trip. Handling of the callback URL belongs to the device OS, and it will sometimes open in a separate tab or another browser. So the return URL must carry a reference the server can resolve, not lean on sessionStorage in the tab that started the payment. On Android the package to detect is se.bankgirot.swish, declared in the manifest since Android 11.

Callback Handling

Swish POSTs a payment request object on every change to PAID, DECLINED, ERROR or CANCELLED. The endpoint must be HTTPS on port 443, and Swish validates your certificate against commonly recognised CAs, so a self-signed certificate on the callback host breaks the flow silently.

There is no signature and no mutual TLS in this direction; Swish is the client. The documented verification mechanism is callbackIdentifier: 32 to 36 characters matching ^[0-9a-zA-Z-]{32,36}$, sent in the body and returned unchanged as a header of the same name. Generate a fresh one per request and pair it with IP filtering: production egress is egress.api.getswish.se, and the simulator calls from 89.46.83.171.

Retries are specified: up to 10 attempts, waiting 5 seconds, then 10, 20, 40 and 60-second intervals, until you answer 200 OK. So make the handler idempotent first.

// app/api/swish/callback/route.ts
const TERMINAL = ['PAID', 'DECLINED', 'ERROR', 'CANCELLED'];
const ok = (got: string | null, want: string) =>
  !!got && got.length === want.length &&
  timingSafeEqual(Buffer.from(got), Buffer.from(want));

export async function POST(req: Request) {
  const { id, status, paymentReference, errorCode } = await req.json();
  const payment = await prisma.swishPayment.findUnique({
    where: { instructionUUID: id },
  });

  // Acknowledge so Swish stops retrying, but make noise.
  if (!payment || !ok(req.headers.get('callbackidentifier'),
                      payment.callbackIdentifier)) {
    logger.error({ id }, 'swish.callback.rejected');
    return new Response(null, { status: 200 });
  }

  await prisma.$transaction(async (tx) => {
    // The conditional write is the idempotency guard: a duplicate
    // callback, or the reconciler racing us, updates zero rows.
    const { count } = await tx.swishPayment.updateMany({
      where: { instructionUUID: id, status: { notIn: TERMINAL } },
      data: { status, paymentReference, errorCode, settledAt: new Date() },
    });
    if (count === 0 || status !== 'PAID') return;

    await tx.order.update({ where: { id: payment.orderId },
      data: { paidAt: new Date(), paymentReference } });
    await tx.outboxEvent.create({ data: orderPaid(payment) });  // never inline
  });

  return new Response(null, { status: 200 });
}

The conditional updateMany absorbs duplicates and the reconciler reaching the same conclusion a second earlier, with no distributed lock. Answering 200 on a rejected callback is deliberate: it is either spoofed, so nothing needs retrying, or your own bug, which retries will not fix. The wider patterns match handling payment webhooks.

Reconciliation. Because Callbacks Will Fail

The documented guidance: await the callback, and if nothing lands within 10 seconds, poll the retrieve endpoint with backoff and jitter at 10 seconds, then 20, 40, 80. That covers a checkout someone is watching. For the rest, the timeouts say when a row is stale: three minutes for the payer to accept or decline, and a backend timeout that sends ERROR after five minutes for e-commerce, five and a half for m-commerce. Anything non-terminal past seven minutes is yours to chase.

// jobs/reconcileSwishPayments.ts   (every minute)
const STALE_MS = 7 * 60_000;   // past the 5.5 min backend timeout

export async function reconcileSwishPayments() {
  const stale = await prisma.swishPayment.findMany({
    where: { status: { notIn: TERMINAL },
             createdAt: { lt: new Date(Date.now() - STALE_MS) } },
    take: 200,
  });

  for (const p of stale) {
    const res = await fetch(
      BASE + '/swish-cpcapi/api/v1/paymentrequests/' + p.instructionUUID,
      { dispatcher: await swishDispatcher() } as RequestInit);

    if (res.status === 429) break;   // next pass, with backoff
    if (!res.ok) continue;
    // Same function the callback route calls: one state machine, not two.
    await applySwishStatus(p.instructionUUID, await res.json());
  }
}

Two code paths that each decide what PAID means will disagree within a quarter. The ledger side is in payment reconciliation strategies.

Cancellations and Refunds

Until the payer signs with BankID you can retract a request. Cancellation is a JSON Patch needing Content-Type: application/json-patch+json and one operation in the body: { op: 'replace', path: '/status', value: 'cancelled' }. A 422 carrying RP07 means it was already signed or final, so reconcile rather than retry.

Refunds differ from payments in two ways. The lifecycle has an intermediate state: two callbacks arrive in the happy case, DEBITED when the money leaves your account and PAID when it lands, with DEBITED guaranteed inside 10 seconds or you get ERROR. An error can also strike between the two, returning the money to your account. Alert on any refund that stays in DEBITED for more than a few minutes.

The second difference is the trap. The documentation says to generate a new instructionUUID for every refund request, retries included, because RP09 means that UUID was already used even if the earlier attempt failed or its response was lost. The UUID cannot be your idempotency key here. Deduplicate your side, and treat RF09 ("refund already in progress") as a stop signal.

// A refund retry needs a FRESH instructionUUID, so dedup is ours to own.
const refund = await prisma.$transaction(async (tx) => {
  const existing = await tx.swishRefund.findFirst({
    where: { orderId, requestKey, status: { not: 'ERROR' } },
  });
  return existing ?? tx.swishRefund.create({ data: {
    orderId, requestKey, amount, status: 'CREATED', instructionUUID: newId(),
  }});
});
if (refund.status !== 'CREATED') return refund;   // in flight or done

// Then PUT /api/v2/refunds/{instructionUUID}: originalPaymentReference,
// payerAlias, amount, currency, callbackUrl.

Refunds key off the paymentReference the bank assigned to the original payment, which only exists once it reached PAID. Partial refunds are unlimited until the total reaches the original amount, the minimum is 1 SEK rather than 0.01, and the original cannot be older than 13 months (RF02). Create-refund is one-at-a-time by design: leave a second between refunds, and run long sequences at night.

Error and Timeout Taxonomy: What to Retry

Signal Meaning Action
401 / 403 / 415Certificate, enrolment, alias or Content-Type problemConfig or code bug; never retry
RP09The instructionUUID already existsPayments: the earlier attempt landed, so GET it. Refunds: new UUID
RF07Declined; payer over their limit or short of fundsTerminal; point them at their bank
429Rate limited past 30 TPSHonour Retry-After plus jitter
500 / 504Unknown server error; on refunds, bank validation timed outReconcile; retry refunds with a new UUID
TM01 / BANKIDCLTimed out before starting; BankID signing cancelledRe-present the checkout
DS24Timed out waiting for the banks after the payment startedOutcome unknown even to Swish

DS24 earns its own branch and its own paragraph in the support playbook. The documentation is unusually direct: Swish has no knowledge of whether the payment succeeded, and the merchant should advise the consumer to check with their bank.

Design for the Failure Modes

  • The payer declines, or never opens the app. You get DECLINED, or ERROR once the timeouts elapse. Time the UI out just after Swish does.
  • The callback beats your own response. Insert the row before the PUT.
  • Your callback host's certificate lapses. Payments keep succeeding at Swish while your database quietly falls behind, which is why reconciliation is not optional.

Observability and Alerting

Swish failures are rarely loud. They surface as a widening gap between what Swish believes happened and what your database believes, so instrument that gap.

  • Share of payments resolved by reconciliation rather than callback. Healthy is near zero; a step change usually means a firewall rule, a DNS change or an expired callback certificate.
  • Rejected callbacks: unknown instructionUUID or identifier mismatch. Both should be flat zero, so alert on the first.
  • Refunds in DEBITED past five minutes, against that state's 10-second guarantee.
  • 429 against the 30 TPS ceiling, and DS24 and TM01 apart from ordinary declines.
  • deprecation and sunset headers, and days-until-expiry on the certificate.

Test Environments, and Where They Lie to You

The Merchant Swish Simulator at https://mss.cpc.getswish.net validates requests and returns correctly formatted but simulated responses, callbacks included. It ships test certificates with the password swish, and rejects certificates created in the Swish portal just as production rejects the test ones. Its best feature is deliberate failure: put an error code in the message field and MSS produces it, on the create call (RP06, RP09) or in the delayed result callback (RF07, BANKIDCL, DS24). Nothing else exercises DS24 deliberately.

The Sandbox at https://staging.getswish.pub.tds.tieto.com answers a different question: does the flow work with a real app in a real hand. It needs enrolment through Swish support, a dedicated test certificate, a test Swish app and the Test BankID security application, and it uses fake personal identity numbers. Merchant-side behaviour is documented as identical to production, and it is explicitly not for load testing. Each environment allowlists different callback addresses, so the firewall change is per environment, and swapping the test certificate for your own is the last step of going live.

PCI and Regulatory Scope

No card data passes through this flow. The payment moves bank to bank and the payer authenticates with BankID in the app, so PCI DSS scope is narrower than a card integration makes it. That is an observation about the data flow, not a certification claim.

What you do handle is personal data. payerAlias is a mobile number and the optional payerSSN is a personnummer or samordningsnummer. Send payerSSN and ageLimit only where a rule requires them, set retention on payment references and phone numbers, and keep both out of application logs. One recommendation is easy to miss: do not expose your Swish alias, the 123-number, to consumers, because it can be used for unprompted payments and some banks block those to aliases enrolled in Swish Commerce. Mapping this onto a checkout is what our payment system integration practice does.

The Minimal Production Checklist

  • Create calls use the v2 paymentrequests and refunds paths; retrieve and cancel use v1.
  • Client certificate sits in Key Vault, read by a managed identity into an undici dispatcher whose pool is rebuilt on version change.
  • A new certificate is deployed before the old is revoked, with expiry alerts at 90, 30 and 7 days.
  • instructionUUID is 32 uppercase hex digits, and the row is written before the PUT.
  • A per-request callbackIdentifier is verified in constant time, alongside IP filtering.
  • The callback handler is idempotent through a conditional update, downstream effects leave via an outbox, and the host serves a publicly trusted certificate on port 443.
  • Polling starts 10 seconds in, and a sweeper collects non-terminal rows past seven minutes.
  • Refund retries mint a fresh UUID, deduplicated by your own schema, and DS24 routes to a human queue.

subscribe # the AI news that matters, minus the noise

Book a Call

Tags

Related posts