immensus All articles

25 October 2026 · 9 min read

Account-to-account payments with Open Banking: the payment flow in practice

A state machine, a signed return state and a reconciliation rule for pay-by-bank: what to build when the customer pays from their bank account instead of a card.

A bank, a phone approving a payment and a merchant receiving it

In an account-to-account (A2A) payment the customer pays directly from their bank account. There is no card number and no card scheme in the middle. Under Open Banking in the UK and PSD2 in the EU, a regulated payment initiation service provider (PISP) asks the customer’s bank to make a credit transfer to you, and the customer approves it inside their own banking app with strong customer authentication.

For a business collecting fines, fees, invoices or top-ups, the attractions are practical: lower per-transaction cost than cards in many cases, no card data to protect, and payments that settle quickly on instant rails. The cost is that the flow is asynchronous and redirect-based, and a simple design that treats the return from the bank as “paid” will lose money. This article describes the flow and the parts worth building carefully.

Who does what

Becoming a PISP yourself needs regulatory authorisation. Most merchants integrate with an authorised provider that exposes one API over many banks. In that model you build three things:

  1. Creating a payment request with the provider.
  2. Sending the customer to their bank to authorise it, and bringing them back.
  3. Learning the final result and matching it to the thing it paid for.

You never see the customer’s banking credentials, and you should not ask for them. Field names below are generic. Providers differ in naming and in details such as authorisation expiry, so read your provider’s documentation for the exact contract.

The flow

1  merchant ──▶ provider     create payment (amount, currency, reference, return URL)
2  provider ──▶ merchant     authorisation URL
3  customer ──▶ bank         redirect or app-to-app, strong customer authentication, approve
4  bank     ──▶ customer     redirect back to the merchant's return URL
5  provider ──▶ merchant     webhook: status changes (authorised, executed, settled, failed)
6  merchant                  match the payment to the order, update the ledger

Step 4 and step 5 are separate events that can arrive in either order, or one of them not at all. A customer can close the bank app before the redirect; a webhook can arrive before the customer lands on your page. Design for that from the start.

Model the payment as a state machine

Store each attempt as a row with an explicit status and a list of allowed transitions:

CREATE TABLE payment_attempt (
  id             uuid        PRIMARY KEY,
  order_id       bigint      NOT NULL REFERENCES customer_order (id),
  provider_id    text        UNIQUE,                    -- the provider's payment id, once known
  reference      text        NOT NULL UNIQUE,           -- end-to-end reference we generate
  amount_minor   bigint      NOT NULL CHECK (amount_minor > 0),
  currency       char(3)     NOT NULL,
  status         text        NOT NULL DEFAULT 'created'
                 CHECK (status IN ('created', 'awaiting_authorisation', 'authorised',
                                   'settled', 'failed', 'expired', 'cancelled')),
  state_nonce    text        NOT NULL,                  -- bound to the return URL, see below
  created_at     timestamptz NOT NULL DEFAULT now(),
  updated_at     timestamptz NOT NULL DEFAULT now()
);
const NEXT: Record<Status, Status[]> = {
  created:                ['awaiting_authorisation', 'failed'],
  awaiting_authorisation: ['authorised', 'failed', 'expired', 'cancelled'],
  authorised:             ['settled', 'failed'],
  settled:                [],
  failed:                 [],
  expired:                ['settled'],          // see "late results" below
  cancelled:              [],
};

async function move(tx: Tx, id: string, to: Status) {
  const row = await tx.one('SELECT status FROM payment_attempt WHERE id = $1 FOR UPDATE', [id]);
  if (row.status === to) return;                                  // duplicate event: nothing to do
  if (!NEXT[row.status as Status].includes(to)) {
    await tx.query('INSERT INTO payment_anomaly (attempt_id, from_status, to_status) VALUES ($1,$2,$3)',
                   [id, row.status, to]);                         // never silently ignore
    return;
  }
  await tx.query('UPDATE payment_attempt SET status = $2, updated_at = now() WHERE id = $1', [id, to]);
}

Three habits make this reliable. The row is locked while it changes, so two events cannot interleave. Repeating the current status is a no-op, which makes duplicate webhooks harmless. And an impossible transition is recorded as an anomaly rather than dropped, because it usually means a missed event or a provider problem you want to know about.

“Authorised” is not “paid”. It means the customer approved and the bank accepted the instruction. Whether you can release goods or mark a fine as paid depends on your risk appetite and the provider’s guarantees. The conservative rule is to treat only settled (or the provider’s equivalent confirmed-executed status) as money received.

Creating the payment

Generate your own reference and send an idempotency key, so a retried request does not create a second payment:

async function startPayment(order: Order): Promise<string> {
  const attempt = await db.one(
    `INSERT INTO payment_attempt (id, order_id, reference, amount_minor, currency, state_nonce)
     VALUES (gen_random_uuid(), $1, $2, $3, $4, $5) RETURNING *`,
    [order.id, newReference(), order.totalMinor, order.currency, randomToken(16)]);

  const res = await fetch('https://api.provider.example/v1/payments', {
    method: 'POST',
    headers: { Authorization: `Bearer ${await providerToken()}`,
               'Content-Type': 'application/json',
               'Idempotency-Key': attempt.id },
    body: JSON.stringify({
      amount: { value: attempt.amount_minor, currency: attempt.currency },   // minor units
      reference: attempt.reference,
      return_url: signedReturnUrl(attempt),
    }),
  });
  const p = await res.json();
  await db.query(`UPDATE payment_attempt SET provider_id = $2 WHERE id = $1`, [attempt.id, p.id]);
  await db.tx((tx) => move(tx, attempt.id, 'awaiting_authorisation'));
  return p.authorisation_url;
}

Mind the reference. It is what lets you match the money later, and schemes limit it. UK Faster Payments, for example, allows 18 characters, while SEPA allows far more. Generate something short, unique and free of personal data, and check your provider’s rules.

Trust the server, not the browser

When the customer lands back on your site, they may be in a different browser than the one that started the payment: banking apps often open the return URL in the default browser. Cookie-based sessions are not dependable here, so the return URL carries a signed state that identifies the attempt:

import { createHmac, timingSafeEqual } from 'node:crypto';

const sign = (id: string, nonce: string) =>
  createHmac('sha256', RETURN_SECRET).update(`${id}.${nonce}`).digest('base64url');

export const signedReturnUrl = (a: Attempt) =>
  `https://pay.example.com/return?a=${a.id}&n=${a.state_nonce}&s=${sign(a.id, a.state_nonce)}`;

export function verifyReturn(q: { a: string; n: string; s: string }): boolean {
  const given = Buffer.from(q.s, 'base64url'), want = Buffer.from(sign(q.a, q.n), 'base64url');
  return given.length === want.length && timingSafeEqual(given, want);
}

Then comes the rule that matters most: the return page never marks anything as paid. The query string of a redirect can be edited by anyone. The page only shows the current server-side status of the attempt and refreshes it while the webhook is on its way:

app.get('/api/attempt/:id/status', async (req, res) => {
  const a = await db.oneOrNone('SELECT status FROM payment_attempt WHERE id = $1', [req.params.id]);
  res.json({ status: a?.status ?? 'unknown' });
});

On the client, poll that endpoint for about half a minute with a growing delay. If the status is still awaiting_authorisation or authorised after that, stop polling and say so honestly: “Your bank is still confirming the payment. We will email you as soon as it arrives.” The customer should never be left on a spinner, and should never be told “paid” without a settled record.

Webhooks and the provider’s truth

Webhook handling follows the same pattern as any payment notification: verify the signature over the raw body, store the event under its unique id, answer 200 quickly and process it in a worker that applies the state change with the move function above. Duplicates and reordering are absorbed by the state machine.

Add a sweeper for the cases where no webhook ever comes. Every few minutes, select attempts that have been in awaiting_authorisation or authorised longer than a threshold, fetch their status from the provider’s API and apply it. Webhooks are an optimisation; the status endpoint is the source of truth.

Late results

Authorisation links expire (the lifetime is provider-specific). The sweeper marks an unfinished attempt as expired. Sometimes the customer approves just as it expires, and the money arrives anyway. That is why the table above allows expired → settled: if the provider says the payment settled, believe the provider, apply it, and raise an alert so a person can see that a race happened. Refusing the transition would leave you holding the customer’s money with no record of it.

Reconciliation

Match settlement data with your own attempts using the reference you generated, the amount and the currency. The provider’s settlement report, or the bank statement if you receive funds directly, is the final authority:

-- Settled with the provider, but not settled by us
SELECT s.reference, s.amount_minor
FROM provider_settlement s
LEFT JOIN payment_attempt a ON a.reference = s.reference AND a.status = 'settled'
WHERE a.id IS NULL;

-- Settled by us, but missing from the provider's report
SELECT a.id, a.reference
FROM payment_attempt a
LEFT JOIN provider_settlement s ON s.reference = a.reference
WHERE a.status = 'settled' AND s.reference IS NULL
  AND a.updated_at < now() - interval '2 days';

Anything that comes back is a ticket for a person, as in any reconciliation. Unmatched credits (a customer who sent money by hand with a mistyped reference) go to a manual-matching queue, never to a guess.

Refunds are payments, not reversals

There is no card-scheme chargeback network behind an A2A payment. A refund is a new credit transfer you initiate back to the payer, so you need a process for it: who may approve one, how the destination account is obtained (some providers return the payer’s account details, others do not), and a ledger entry that links the refund to the original payment. Record it as its own row so the balance stays explainable.

Handling personal data

The provider may return the payer’s name and a masked account number. That is personal data. Store only what the business needs (for matching and refunds), define a retention period, and keep it out of logs. Never log full account numbers or webhook bodies that contain them.

Test the awkward paths

Provider sandboxes usually let you simulate bank behaviour. Cover the scenarios that real customers produce, not only the happy path:

ScenarioWhat should happen
Customer approvesawaiting_authorisation → authorised → settled
Customer rejects at the bankfailed, customer sees a clear message and can retry
Customer closes the app and never returnswebhook or sweeper still settles or expires the attempt
Webhook arrives before the return pagereturn page shows the settled status at once
Same webhook delivered twiceone transition, no anomaly
Authorisation expires, then approval arrivesexpired → settled, with an alert
Edited return URLsignature check fails, nothing changes
Provider API times out on creationretry with the same idempotency key, one payment

The short version

Treat a pay-by-bank payment as a small, explicit state machine fed by two unreliable messengers, the browser and the webhook. Let only the server-side status decide what the customer sees. Believe the provider’s final word, even when it arrives late. And reconcile every day, because the cases you did not think of show up there first.

payments · open banking · PSD2 · architecture

Tell us about your process.