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.

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 Debit | Card-on-file (MIT) | cVRP | |
|---|---|---|---|
| Who enforces limits | Nobody per payment | Card network rules, loosely | The customer's bank, per payment |
| Time to learn the outcome | Days | Seconds | Seconds to minutes (it rides Faster Payments) |
| Customer cancels | Via bank or merchant | Via merchant, or chargeback | In the banking app, effective at the bank |
| Dispute route | Direct Debit Guarantee refund | Chargeback | No scheme-level chargeback yet; handled bank by bank |
| Fund check before collecting | None | Auth decline | Separate 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
Authorisedwhile you still showAwaitingAuthorisation. - 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
ValidToDateTimepasses 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:
- In one transaction, insert a
paymentrow inINITIATEDwith 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. - 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.
- The worker records the response.
AcceptedSettlementCompletedandRejectedare final.PendingandAcceptedSettlementInProcessmean 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
SETTLEDwith 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
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.