DEV Community

Cover image for Open Banking APIs Explained: What PSD2 Compliance Actually Requires From Your Engineering Team
Lycore Development
Lycore Development

Posted on

Open Banking APIs Explained: What PSD2 Compliance Actually Requires From Your Engineering Team

Open Banking APIs Explained: What PSD2 Compliance Actually Requires From Your Engineering Team

Every fintech founder we talk to eventually says some version of the same sentence: "we just need to connect to the bank's API." It sounds like a single integration task on a roadmap. In practice, it's a compliance program with an API attached, and the gap between those two framings is where a lot of engineering timelines quietly blow up.

We've built open banking integrations across payment initiation, account aggregation, and lending products in the UK and EU markets, plus US equivalents through providers like Plaid. This post is the technical walkthrough we wish more teams had before they scoped their first sprint — what PSD2 actually mandates at the API and architecture level, where the real engineering effort lives, and where teams consistently underestimate the work.

What PSD2 actually is, in engineering terms

The Second Payment Services Directive is an EU regulation, not a technical spec. It mandates that banks (Account Servicing Payment Service Providers, or ASPSPs) provide regulated third parties with API access to customer account data and payment initiation, provided the customer has consented. The regulation itself doesn't dictate exact API shapes — that's what the technical standards built on top of it, primarily the Berlin Group's NextGenPSD2 framework and the UK's Open Banking Standard, are for.

For an engineering team, PSD2 compliance breaks down into three functional API categories you'll be building against or exposing:

AISP (Account Information Service Provider) access — read-only access to account balances, transaction history, and account holder details, with explicit customer consent that expires and must be renewed (typically every 90 days under RTS rules).

PISP (Payment Initiation Service Provider) access — the ability to initiate a payment directly from the customer's bank account, bypassing card networks entirely. This is the piece with the real product upside (lower interchange costs, direct settlement) and the real engineering complexity (strong customer authentication flows, payment status polling, handling partial failures mid-flow).

CBPII (Card-Based Payment Instrument Issuer) access — confirmation of funds availability for card-based instruments, a narrower and less commonly implemented category unless you're specifically in the card issuing space.

If you're building a product that needs bank data or payment initiation, you're consuming these APIs from banks (or from an aggregator abstracting many banks' APIs). If you're a bank or e-money institution being asked to expose these APIs to third parties, you're implementing the provider side. The engineering shape of these problems is almost entirely different, so the first architectural decision is figuring out honestly which side of that line your product sits on.

Strong Customer Authentication is where most timelines go wrong

The single most underestimated piece of PSD2 work is Strong Customer Authentication (SCA). The regulation requires two-factor authentication for most account access and payment initiation — something the user knows (a PIN, password), something they have (a device, a hardware token), and something they are (biometrics) — with at least two of the three categories represented.

In practice, SCA in open banking means redirect-based or decoupled authentication flows where your app hands control to the bank's own authentication interface, the user authenticates directly with their bank (not with you — you never see their banking credentials, which is itself a hard compliance requirement), and control returns to your app via a redirect or a webhook-style callback.

1. Your app calls the bank's consent endpoint, specifying scope (balances, transactions, payments) and redirect URI
2. Bank returns an authorization URL
3. User is redirected to the bank's own login page (outside your app's control entirely)
4. User authenticates with the bank + completes SCA challenge (bank's own 2FA)
5. Bank redirects back to your redirect URI with an authorization code
6. Your backend exchanges the code for an access token + refresh token
7. Subsequent API calls use the access token until it expires or is revoked
Enter fullscreen mode Exit fullscreen mode

This sounds straightforward until you build it against a second bank and discover their redirect flow has different timeout behavior, a different set of error codes for user-cancelled consent, and a different token refresh window. There is no single PSD2 API — there is a shared regulatory framework implemented slightly differently by every bank, and your integration layer has to absorb that variance. This is the actual engineering problem, and it's also exactly the layer where aggregators like Plaid, TrueLayer, or Tink earn their fee: they've already absorbed hundreds of these implementation differences so you don't have to build and maintain adapters for each bank individually.

We generally advise clients to run the numbers honestly on build-vs-aggregate here. Direct bank integration removes a middleman fee and gives you more control, but it means owning an ongoing maintenance burden every time a bank updates its API version, changes its SCA flow, or has downtime — and banks are not known for graceful API versioning. Unless bank integration volume is genuinely core to your product's moat, an aggregator is very often the right call, at least for an initial launch, with a migration path to direct integrations for your highest-volume banks once you have the transaction volume to justify the engineering investment.

Consent management is a data model problem before it's a UI problem

A recurring mistake we see in early-stage fintech codebases is treating consent as a boolean flag — "has_bank_consent: true/false" — rather than as a first-class, time-bound, scope-specific record. PSD2 consent has real structure that your data model needs to reflect:

CREATE TABLE bank_consents (
    id UUID PRIMARY KEY,
    user_id UUID NOT NULL REFERENCES users(id),
    institution_id VARCHAR NOT NULL,
    scopes TEXT[] NOT NULL,  -- e.g. ['balances', 'transactions', 'payments']
    access_token_encrypted BYTEA NOT NULL,
    refresh_token_encrypted BYTEA NOT NULL,
    granted_at TIMESTAMPTZ NOT NULL,
    expires_at TIMESTAMPTZ NOT NULL,
    revoked_at TIMESTAMPTZ,
    consent_reference VARCHAR NOT NULL  -- the bank's own consent ID, needed for status checks
);
Enter fullscreen mode Exit fullscreen mode

The expiry field isn't decorative. RTS technical standards require re-authentication at least every 90 days even for an ostensibly "always on" data connection, and your product needs a real UX plan for what happens when a consent lapses mid-session — silently failing background jobs that pull transaction data is a bad default, but so is interrupting a user mid-task with a re-auth flow they weren't expecting. We generally build a proactive re-consent nudge that fires a week before expiry, with fallback handling for consents that lapse anyway.

Revocation needs equal attention. Users can revoke consent directly with their bank, entirely outside your product's UI, and your system needs to detect and handle that gracefully rather than continuing to hammer a dead token until it starts generating 401s in a retry loop. Most aggregators surface a webhook for this; if you're integrating directly with a bank, check whether their API even offers a revocation notification or whether you're stuck polling token validity — this detail alone has changed which banks we recommend integrating with directly on more than one project.

Payment initiation needs an honest state machine

Account data access is comparatively forgiving — a failed read can be retried. Payment initiation cannot, and this is where we've seen the most production incidents in fintech codebases we've inherited. A payment initiation request has more terminal and non-terminal states than most teams initially model:

PENDING → AUTHORIZED → ACCEPTED_SETTLEMENT_IN_PROCESS → ACCEPTED_SETTLEMENT_COMPLETED
                    ↘ REJECTED
PENDING → CANCELLED (user abandoned SCA)
PENDING → EXPIRED (SCA window timed out)
Enter fullscreen mode Exit fullscreen mode

The trap is treating "the API call returned 200" as "the payment happened." A 200 on payment initiation typically means the request was accepted for processing, not that funds moved — actual settlement confirmation comes through a separate status check or webhook, sometimes minutes later, occasionally longer during bank-side processing delays. We've inherited codebases that displayed "Payment Successful" to the user the moment the initiation call returned, only to discover — via a spike in support tickets — that a meaningful percentage of those payments were later rejected by the receiving bank for reasons the initiating API call couldn't have known about.

The fix is building your product UX and your backend state machine around the real terminal states, not the optimistic ones: show "Payment Processing" rather than "Payment Successful" until you have actual settlement confirmation, build idempotent status-check polling with backoff, and — this is the part teams skip under deadline pressure — decide up front what your product does when a payment sits in an ambiguous state for longer than expected. Silent failure is worse than an honest "we're still confirming this" message.

Security requirements that go beyond "use HTTPS"

PSD2's RTS on Strong Customer Authentication and Common Secure Communication includes specific technical requirements that go well past generic API security hygiene:

Qualified certificates. Communication with ASPSPs typically requires eIDAS-qualified certificates (QWAC for website authentication, QSEAL for request signing) issued by an accredited Qualified Trust Service Provider — not a standard TLS certificate from a normal CA. Budgeting time and cost for certificate acquisition is a compliance line item, not an engineering afterthought, and the acquisition process itself can take weeks.

Request signing. Many bank APIs require signed requests (JWS) in addition to TLS, meaning your integration layer needs to build and attach a digital signature to outgoing requests using your QSEAL certificate — an extra step most standard HTTP client libraries don't handle out of the box, and one that needs its own test coverage since a malformed signature fails silently as an auth error that looks identical to a token problem.

Token encryption at rest. Access and refresh tokens for bank accounts are, functionally, keys to a customer's financial data. We treat them the way we'd treat any other high-sensitivity secret — encrypted at rest with envelope encryption, never logged (a mistake we've caught in code review more than once, usually in a debug log statement someone forgot to remove), and access-scoped so that a compromised application server doesn't hand over decryption keys alongside the encrypted tokens.

Fraud and anomaly monitoring. Regulated payment initiation providers have transaction monitoring obligations under PSD2's fraud reporting requirements. Depending on your license type (or your relationship with a licensed partner, if you're operating under someone else's e-money license via an agency model), you may need real-time anomaly detection on payment patterns, not just after-the-fact reporting — this is a genuine data engineering workstream, not a checkbox.

The licensing question that determines your whole architecture

Before any of the above, the foundational question is whether you're operating under your own PSD2 license (as an Authorised Payment Institution or Electronic Money Institution) or as an agent/distributor under someone else's license. This isn't primarily an engineering decision, but it determines your engineering scope enormously. Operating as an agent under an existing EMI's license (a common and often sensible path for early-stage fintechs) typically means integrating with their compliance and reporting APIs rather than building your own regulatory reporting pipeline from scratch — a substantial scope reduction that founders sometimes don't realize is available until well into a build.

We always push clients to nail down licensing strategy with actual regulatory counsel before committing to an architecture, because "should we build our own compliance reporting pipeline" is a multi-month engineering question whose answer depends entirely on a legal decision that has nothing to do with code.

Building an abstraction layer that survives multiple banks

Whether you're integrating directly with several banks or working through an aggregator that still exposes bank-specific quirks (aggregators smooth over authentication flows, not every data inconsistency), you need an internal abstraction layer that normalizes responses before they touch your product code. Without this, bank-specific field names, date formats, and transaction categorization conventions leak into your business logic, and every new bank integration becomes a scavenger hunt through conditional branches.

class NormalizedTransaction:
    def __init__(self, id, amount, currency, timestamp, description, category, raw_source):
        self.id = id
        self.amount = amount  # always minor units, always signed (negative = debit)
        self.currency = currency  # ISO 4217
        self.timestamp = timestamp  # always UTC
        self.description = description
        self.category = category  # normalized to your own taxonomy, not the bank's
        self.raw_source = raw_source  # keep the original payload for debugging/audit

class BankAdapter(ABC):
    @abstractmethod
    def fetch_transactions(self, account_id, since) -> list[NormalizedTransaction]:
        ...

    @abstractmethod
    def initiate_payment(self, payment_request) -> PaymentResult:
        ...

class BarclaysAdapter(BankAdapter):
    def fetch_transactions(self, account_id, since):
        raw = self._client.get_transactions(account_id, since)
        return [self._normalize(tx) for tx in raw['Data']['Transaction']]
    # bank-specific parsing lives here, never in product code
Enter fullscreen mode Exit fullscreen mode

This pattern earns its cost the second bank you integrate, not the first — which is exactly why teams under deadline pressure skip it on bank one and then pay for the retrofit on bank three. We treat the adapter layer as non-negotiable scope on any open banking project regardless of how many banks are in the initial launch plan, because "we'll only ever support one bank" is rarely still true eighteen months later.

Sandbox testing has real limits — plan around them

Every major bank and aggregator provides a sandbox environment, and it's tempting to treat sandbox test coverage as equivalent to production readiness. It isn't. Sandbox environments consistently under-represent the messiness of production: real accounts with years of transaction history and inconsistent categorization, real SCA challenges with real user hesitation and abandonment, and real bank-side downtime during maintenance windows that sandboxes don't simulate.

We build a deliberate staged rollout for exactly this reason — a small cohort of real users on real bank connections before a full launch, specifically to surface the SCA abandonment rate (which is almost always higher than teams expect; redirecting a user out of your app to their bank's login and back is real friction, and a meaningful percentage of users don't complete the round trip) and the actual distribution of error codes your adapter layer needs to handle gracefully. Budgeting for this staged rollout as its own project phase, rather than treating "sandbox tests pass" as the finish line, has saved every fintech client we've worked with from a rockier production launch.

Observability for a system you don't fully control

The hardest part of operating an open banking integration long-term is that a meaningful chunk of your system's behavior is determined by infrastructure you don't own — the bank's API uptime, their SCA flow, their token expiry policy. Standard application monitoring (error rates, latency) still matters, but it needs to be paired with per-institution dashboards, because "our API error rate is elevated" is a much less actionable alert than "Barclays' consent endpoint has been returning elevated 503s for the last twenty minutes." We tag every metric and log line with the institution ID from day one, even when there's only one bank integrated, because retrofitting that tagging later means re-instrumenting a system that's already in production.

Alert thresholds also need to be bank-aware rather than global. A bank with a smaller user base on your platform will have a noisier error rate on low absolute volume — five failed calls out of ten looks alarming as a percentage but might be a single user's session, not a systemic issue. We set alerting on absolute counts as well as rates specifically to avoid page fatigue from a single small institution's normal variance.

What we tell teams starting this today

Open banking integration is genuinely buildable by a competent engineering team — this isn't a warning to avoid it. But it's a compliance-and-architecture problem wearing an API integration costume, and teams that scope it as "connect to the bank API" consistently blow their timelines by a factor of two or three once SCA edge cases, consent lifecycle management, and payment state machines surface mid-build.

The teams that ship this well start with the licensing and aggregator-vs-direct decisions before writing integration code, model consent and payment state as first-class, time-bound data rather than booleans, and build their UX around the actual terminal states banks return rather than the optimistic ones. Everything past that is solvable engineering work — it's just work that needs to be scoped honestly from day one, not discovered sprint by sprint.

Top comments (0)