← Back to blog
OpenBanking11 min read

Commercial VRP in Spring Boot: Mandate-Safe Payments

Commercial VRP (cVRP) lets customers approve limits once, then pay a business. See how it differs from Direct Debit and how to build it in Spring Boot.

Illustration of a bank account connected to a business by a limited, revocable permission, with payment limits shown as a guardrail around a stream of recurring payments

Commercial Variable Recurring Payments (cVRP) are an Open Banking payment type where one consent, authorised in the customer's bank app, lets a merchant keep initiating payments inside limits written into that consent. Wave 1 of the UK Payments Initiative rollout began in June 2026 for utilities, telecoms, government and charities, and general online commerce is pencilled in for the second half of this year.

Most teams I have seen approach recurring bank payments as plumbing: collect credentials, call the endpoint, move on. That habit breaks down here. The bank can refuse a collection at any moment, and the customer can cancel between two of your billing runs. So the Spring Boot code is mostly about keeping an honest record of consent and payment state, and much less about HTTP.

If you are new to the scheme, the next two short sections give you what you need. If you already build payment backends, jump to the mandate model.

The scheme in a nutshell

You send the customer to their bank, they approve a consent with limits, and you get back a consent ID. From then on you call a payments endpoint under that ID without the customer present. The consent carries the control parameters: a maximum amount per payment, one or more periodic limits (day, week, fortnight, month, half-year or year), whether those periods align to the calendar or to the consent start, and a validity window.

Until recently, UK VRP only covered sweeping, where the destination account belongs to the same person as the source. cVRP is the commercial extension, which lets the payee be an energy supplier, a mobile operator or a council. UKPI is the industry body staging that extension in waves, and the wider National Payments Vision is the policy backdrop.

On the regulatory side, you act through an FCA-authorised payment initiation service provider (PISP) or a registered agent of one. Technically you talk to each bank's APIs built to the Open Banking Limited VRP specification, using mutual TLS plus signed requests. If you are not yourself the licensed party, your TPP partner sits between you and the bank, and that relationship determines whose certificates, whose consent records and whose liability you are dealing with.

Where it differs from Direct Debit and card-on-file

Direct DebitCard-on-file (MIT)cVRP
Who enforces limitsNobody per paymentCard network rules, looselyThe customer's bank, per payment
Time to learn the outcomeDaysSecondsSeconds to minutes (it rides Faster Payments)
Customer cancelsVia bank or merchantVia merchant, or chargebackIn the banking app, effective at the bank
Dispute routeDirect Debit Guarantee refundChargebackNo scheme-level chargeback yet; handled bank by bank
Fund check before collectingNoneAuth declineSeparate funds-confirmation call

The last two rows are what bite engineers. Direct Debit ran on a scheme with refunds baked in, and cards come with a dispute machine. cVRP has neither of those, so refunds and disputes become something you build and operate. The upside is real: failed payments surface quickly, authorisation sits inside a banking app the customer already trusts, and there are no interchange fees.

I would not choose cVRP when the customer base is mostly on banks that have not shipped VRP, when you need chargeback-style protection to win conversion, or when the billing amount is fixed and tiny so that Direct Debit through a provider such as GoCardless is simply cheaper to run. Coverage and conversion are the two numbers to measure before committing: how many of your customers' banks support it, and what share of those who start the bank redirect actually finish it. Expect both to vary by bank, because each ASPSP implements consent expiry, error codes and timeouts slightly differently.

Why the state of a mandate is the hard part

For payment work in the PSD2 era (see my notes on PSD2 and Open Banking API development), the pattern I keep returning to is simple. Calling the API is a few dozen lines. The difficult part is being able to say, at any instant, what the real status is of a customer's consent and every payment under it.

Consider the sequence that one mandate can go through across eighteen months:

  • The customer approves, but the redirect back to you fails, so the bank says Authorised while you still show AwaitingAuthorisation.
  • A payment returns Pending, you time out, and you have no idea whether money moved.
  • The customer revokes on Sunday night. Your scheduler fires on Monday at 06:00.
  • The consent's ValidToDateTime passes during a billing run.
  • A period boundary falls in a different timezone from the one your batch job uses, so your headroom calculation disagrees with the bank's.

Every item is a transition or a race. Boolean flags on a customer row cannot express that. An explicit state machine can, because it makes illegal moves impossible in one place and gives tests a finite space to cover.

The incident shape I design against is the double collection: a timeout on a payment call, a naive retry with a fresh identifier, and two payments for the same invoice. It is the most expensive of these failures because it loses customer trust as well as money, and the sections below are built to make it structurally impossible.

I also carry over habits from running market-data and trading systems, where the event stream is the truth and your in-memory picture is a belief you keep verifying. Payments deserve the same humility. Trace every transition with the mandate ID as a span attribute so a disputed payment can be followed from scheduler to bank response. If collection runs through Kafka, share groups are worth a look for the work queue, though a plain database-backed queue is fine at Wave 1 volumes.

Modelling the mandate

The lifecycle I use is deliberately small. It mirrors the bank's consent statuses (AwaitingAuthorisation, Authorised, Rejected, Revoked) and adds two states the bank does not know about:

AWAITING_AUTHORISATION -> ACTIVE -> SUSPENDED -> ACTIVE
          |                 |            |
          v                 v            v
       REJECTED          REVOKED      EXPIRED

SUSPENDED is mine, not the bank's. It means my own risk rules or a run of failures paused collection without the customer doing anything. REVOKED, REJECTED and EXPIRED are terminal, and terminal states are the most valuable part of the model: once a mandate is revoked, no code path may revive it or create a payment under it. I encode that in the type, not in a comment.

public enum MandateState {
    AWAITING_AUTHORISATION, ACTIVE, SUSPENDED, REVOKED, REJECTED, EXPIRED;

    private static final Map<MandateState, Set<MandateState>> LEGAL = Map.of(
        AWAITING_AUTHORISATION, Set.of(ACTIVE, REJECTED),
        ACTIVE,                 Set.of(SUSPENDED, REVOKED, EXPIRED),
        SUSPENDED,              Set.of(ACTIVE, REVOKED, EXPIRED),
        REVOKED,  Set.of(), REJECTED, Set.of(), EXPIRED, Set.of());

    public MandateState to(MandateState next) {
        if (!LEGAL.get(this).contains(next)) {
            throw new IllegalMandateTransition(this, next);
        }
        return next;
    }
}

@Entity
public class Mandate {
    @Id UUID id;
    @Version long version;           // optimistic lock: concurrent webhook + scheduler
    @Enumerated(EnumType.STRING) MandateState state;
    long maxPaymentMinor;            // per-payment cap in pence
    long periodCapMinor;             // cap per period, e.g. per month
    String periodType;               // DAY, WEEK, MONTH ...
    String periodAlignment;          // CONSENT or CALENDAR
    Instant validTo;
    String bankConsentId;            // the bank's identifier, never our own guess

    public void revoke(String reason) {
        this.state = this.state.to(MandateState.REVOKED);
    }
}

Two details. The @Version column matters because a revocation signal and a scheduled payment will eventually collide. With optimistic locking one of them fails with an OptimisticLockingFailureException instead of both succeeding, and the scheduler's response to that exception is to reload the mandate and look again. And bankConsentId is stored byte for byte as the bank returned it; I never normalise identifiers, because reconciliation later depends on exact matches.

Revocation versus a scheduled collection is a race you cannot eliminate, only bound. Re-read the mandate and, where the bank supports it, the consent status immediately before submitting; accept that a small window remains, and rely on the bank's rejection as the final word.

Enforce limits twice

The bank applies the limits, so why check them yourself? Because a rejection is not free: it costs a failed attempt, a retry decision, often a support contact, and in sectors like utilities a conversation about how you treat customers who are already struggling. Keeping the evidence for those conversations is easier when compliance is code, as in my piece on DORA and NIS2 policy as code.

So: a local pre-check first, then the bank's answer as the authority, with its reason recorded.

The period cap is where the subtle bugs live. "£150 per month" raises questions that the consent parameters and the bank's interpretation answer, not you: is it a calendar month or a window anchored on consent start (PeriodAlignment), which timezone defines midnight, and does a failed payment count against the cap? My local arithmetic is deliberately conservative. If I calculate £10 of headroom and I am unsure of the boundary, I ask for nothing. A skipped collection can be retried tomorrow, and a breach of the customer's stated limit cannot be undone.

The headroom comes from a per-mandate ledger of committed amounts, which includes pending payments, never from settled payments alone:

public long headroomMinor(Mandate m, Instant now) {
    Window w = Window.of(m.periodType, m.periodAlignment, m.createdAt(), now, ZoneId.of("Europe/London"));
    long committed = ledger.sumCommitted(m.id, w.start(), w.end()); // PENDING + SETTLED
    return Math.min(m.maxPaymentMinor, m.periodCapMinor - committed);
}

There is also a separate funds-confirmation call that asks the bank whether the account can cover an amount. I treat it as a hint that saves a doomed attempt, never as a reservation: the balance can change a second later.

Idempotent payment initiation

I covered the general mechanics in transactional outbox and idempotent consumers for payment systems, so here is only what is specific to cVRP.

A payment under a mandate has a natural identity: the consent plus the invoice or billing period it settles. The payment call takes the same consent ID each time and a new payment identity per instruction, and the idempotency key you send is what stops a retry from becoming a second payment. I derive it from the business identity instead of a random UUID created at call time:

String key = sha256(mandate.getId() + ":" + invoiceId + ":" + attemptSeq);

attemptSeq only increments when I know the previous attempt was rejected. A timeout does not increment it. The same key goes out again, and the bank returns the original outcome rather than creating a second payment.

The sequence for one collection looks like this:

  1. In one transaction, insert a payment row in INITIATED with the derived key and write an outbox row. If the unique constraint on (mandate_id, invoice_id) fires, a collection already exists and I stop.
  2. A relay publishes the outbox entry and a worker calls the bank with the stored key, signing the request and sending it over mutual TLS.
  3. The worker records the response. AcceptedSettlementCompleted and Rejected are final. Pending and AcceptedSettlementInProcess mean unknown yet, and that state is stored as such.
@Transactional
public void submit(UUID paymentId) {
    Payment p = payments.lockById(paymentId);               // SELECT ... FOR UPDATE
    if (p.isFinal()) return;
    Mandate m = mandates.findById(p.getMandateId()).orElseThrow();
    if (m.getState() != MandateState.ACTIVE) { p.markCancelled("mandate " + m.getState()); return; }
    if (headroomMinor(m, clock.instant()) < p.getAmountMinor()) { p.markDeferred("headroom"); return; }
    try {
        BankResult r = bank.pay(m.getBankConsentId(), p.getIdempotencyKey(), p.toInstruction());
        p.apply(r);
    } catch (SocketTimeoutException | HttpServerErrorException e) {
        p.markUnknown();                                    // never retry with a new key
    }
}

The unknown outcome is the one that deserves respect. When a call times out, the payment may exist at the bank. I park it as UNKNOWN and let reconciliation or a status poll settle it. I never guess "failed" and issue a new request.

Reconciliation, nightly and honest

Everything above reduces the chance of a mismatch, and reconciliation is how you find the remaining ones. A nightly job pulls the bank-side status for every payment I hold as PENDING or UNKNOWN (plus a sample of recent SETTLED ones), and compares it against my ledger and against the actual bank account statement or settlement report.

@Scheduled(cron = "0 15 2 * * *", zone = "Europe/London")
void reconcile() {
    payments.findByStatusIn(PENDING, UNKNOWN).forEach(p -> {
        BankStatus s = bank.fetchStatus(p.getBankPaymentId(), p.getIdempotencyKey());
        if (s.differsFrom(p)) {
            breaks.record(p, s);        // never auto-correct money silently
            p.apply(s);
        }
    });
}

Each discrepancy goes into a recon_break table with both versions side by side and pages a human if it is over a threshold. Auto-correcting the ledger without a trace makes the next audit painful. The same job also re-fetches consent status for active mandates, which is how you catch a revocation whose notification never arrived.

What I would test first

  • Replaying the same collection command ten times produces exactly one bank payment.
  • A revoked mandate rejects submission, even with a stale cached copy in memory.
  • A timeout followed by a successful status poll converges to SETTLED with no second request.
  • Period headroom is right on the first and last day of a month, across the clock change in March and October, and for both period alignments.
  • Two threads moving the same mandate (revoke vs submit) leave one winner and no corrupt row.

Bank sandboxes help for the happy path, though they tend to behave more uniformly than production banks. Budget time to learn each real bank's error codes and expiry quirks once real consents flow, and keep the bank-specific mapping behind one interface so the quirks do not leak into the state machine.

Where this leaves you

Related: ASEAN Cross-Border QR Payments: Building an EMVCo QR Router Across PayNow, DuitNow, PromptPay and QRIS in Spring Boot

Related: Spring Boot 4.2 Deprecates RestTemplate: A Production Migration Guide to RestClient and HTTP Interface Clients

If Wave 2 is on your roadmap, the work to do now is unglamorous: a mandate table with real states and a version column, idempotency keys derived from business identity, an UNKNOWN payment state, a ledger that counts pending money, and a reconciliation job whose breaks a person actually reads. None of that depends on which bank you integrate first, which is exactly why it is worth building before the first customer consent arrives.