Aperioflow

Documentation

Getting started with Aperioflow

This guide takes you from a new account to reading an account holder's transactions in the sandbox, and covers the rules you need to follow before going live. The full endpoint reference is at https://aperioflow-apiv1-production.up.railway.app/docs.

Overview

Aperioflow is a UK Open Banking aggregator. We are registered with the Financial Conduct Authority as an Account Information Service Provider (AISP), so you can access account holders' data under our permissions without becoming regulated yourself. You get one normalised REST API for every supported UK bank (starting with Lloyds), covering accounts, balances, transactions, direct debits and standing orders.

Aperioflow is account information only: there are no payment initiation endpoints.

Environments

EnvironmentDetails
SandboxKeys start with af_sandbox_. Sandbox keys can only see sandbox institutions, such as Aperio Model Bank (aperio-model-bank) and bank-provided sandboxes such as Lloyds. Available as soon as you create an application.
LiveKeys start with af_live_ and reach real account holders at live institutions. Live keys are issued only after Aperioflow approves your application's go-live request (Dashboard → your app → API keys → Request live access).

Aperio Model Bank test login

On the Aperio Model Bank login screen enter any username and choose Approve to authorise the consent (or decline to test the access_denied path). It returns realistic synthetic accounts, balances, transactions, direct debits and standing orders.

Use GET /v1/institutions (or the Institutions page) to see which institutions are available in each environment.

Authentication

Every request to the /v1 API is authenticated with an API key sent as a bearer token. The key's prefix determines the environment. Keep keys on your server; never embed them in mobile apps or browser code.

http
Authorization: Bearer af_sandbox_2b7c...

Quickstart

1. Create an app, register a redirect URI and create a sandbox key

Sign up, then create an application in the dashboard. Register every redirect URI you will use (for example https://yourapp.example/callback) - the redirect_uri you send when creating a consent must match one exactly. Open the API keys tab and create a sandbox key. The full key (af_sandbox_...) is displayed once; store it in your secrets manager.

2. Create a consent

A consent records what data you want, from which institution, and why. The purpose is shown to the account holder on the consent screen and limits how you may use the data (see purpose limitation). Use state to protect against CSRF and correlate the callback, and external_reference to link the consent to your own user ID.

POST /v1/consents
curl -X POST https://aperioflow-apiv1-production.up.railway.app/v1/consents \
  -H "Authorization: Bearer af_sandbox_..." \
  -H "Content-Type: application/json" \
  -d '{
    "institution_id": "aperio-model-bank",
    "permissions": ["accounts", "balances", "transactions"],
    "purpose": "Show your current account balance and spending in the Acme budgeting app",
    "redirect_uri": "https://yourapp.example/callback",
    "state": "xyz",
    "external_reference": "user-123"
  }'

The response includes the consent id (prefixed cns_), status: "awaiting_authorisation" and an authorisation_url:

json
{
  "id": "cns_5Gm2QkT9rXa1",
  "status": "awaiting_authorisation",
  "institution_id": "aperio-model-bank",
  "permissions": ["accounts", "balances", "transactions"],
  "external_reference": "user-123",
  "authorisation_url": "https://auth.aperioflow.example/consents/cns_5Gm2QkT9rXa1/authorise?..."
}

Permissions: accounts, balances, transactions, direct_debits, standing_orders. Request only what your stated purpose needs.

3. Redirect the account holder to authorise

Redirect the account holder's browser to authorisation_url. They review the consent (your company name, the data requested and your purpose) on the Aperioflow-hosted screen, authenticate with their bank, and are returned to your redirect_uri:

text
# Approved
https://yourapp.example/callback?consent_id=cns_5Gm2QkT9rXa1&state=xyz

# Declined or cancelled by the account holder
https://yourapp.example/callback?consent_id=cns_5Gm2QkT9rXa1&state=xyz&error=access_denied

Always check that state matches the value you generated. On success the consent status becomes authorised; you'll also receive a consent.authorised webhook. If error=access_denied is present, the account holder declined and no data is available.

4. Read data

List accounts with the consent ID, then use the returned account IDs for the account-level endpoints. Transactions accept from and to dates (YYYY-MM-DD) and include_raw (default false) to also return the bank's original payload.

EndpointReturns
GET /v1/accounts?consent_id=cns_...Accounts covered by the consent
GET /v1/accounts/{account_id}A single account
GET /v1/accounts/{account_id}/balancesBalances (available, booked, ...)
GET /v1/accounts/{account_id}/transactions?from=2026-01-01&to=2026-09-01&include_raw=falseTransactions in the date range
GET /v1/accounts/{account_id}/direct-debitsDirect debit mandates
GET /v1/accounts/{account_id}/standing-ordersStanding orders
bash
# List accounts covered by the consent
curl "https://aperioflow-apiv1-production.up.railway.app/v1/accounts?consent_id=cns_5Gm2QkT9rXa1" \
  -H "Authorization: Bearer af_sandbox_..." \
  -H "Aperioflow-PSU-IP-Address: 203.0.113.42"

# A single account, its balances and transactions
curl https://aperioflow-apiv1-production.up.railway.app/v1/accounts/acc_7hQ2nB -H "Authorization: Bearer af_sandbox_..."
curl https://aperioflow-apiv1-production.up.railway.app/v1/accounts/acc_7hQ2nB/balances -H "Authorization: Bearer af_sandbox_..."
curl "https://aperioflow-apiv1-production.up.railway.app/v1/accounts/acc_7hQ2nB/transactions?from=2026-01-01&to=2026-09-01&include_raw=false" \
  -H "Authorization: Bearer af_sandbox_..."

# Direct debits and standing orders
curl https://aperioflow-apiv1-production.up.railway.app/v1/accounts/acc_7hQ2nB/direct-debits -H "Authorization: Bearer af_sandbox_..."
curl https://aperioflow-apiv1-production.up.railway.app/v1/accounts/acc_7hQ2nB/standing-orders -H "Authorization: Bearer af_sandbox_..."

Every data response uses the same envelope:

json
{
  "data": [ ... ],
  "meta": {
    "request_id": "req_01J9ZB6W3N",
    "consent_id": "cns_5Gm2QkT9rXa1",
    "institution_id": "aperio-model-bank",
    "fetched_at": "2026-09-25T09:14:03Z",
    "count": 42
  }
}

Quote meta.request_id when contacting support. fetched_at is when the data was retrieved from the bank.

5. Manage consents

bash
# Retrieve a consent (status, permissions, reconfirm_by, ...)
curl https://aperioflow-apiv1-production.up.railway.app/v1/consents/cns_5Gm2QkT9rXa1 -H "Authorization: Bearer af_sandbox_..."

# Revoke a consent (e.g. when the user disconnects their bank in your app)
curl -X DELETE https://aperioflow-apiv1-production.up.railway.app/v1/consents/cns_5Gm2QkT9rXa1 -H "Authorization: Bearer af_sandbox_..."

# Create a reconfirmation link for the 90-day reconfirmation
curl -X POST https://aperioflow-apiv1-production.up.railway.app/v1/consents/cns_5Gm2QkT9rXa1/reconfirmations \
  -H "Authorization: Bearer af_sandbox_..."

Creating a reconfirmation returns a short-lived link to send the account holder to:

json
{
  "consent_id": "cns_5Gm2QkT9rXa1",
  "reconfirmation_url": "https://api.aperioflow.com/connect/reconfirm/...",
  "expires_at": "2026-10-02T09:00:00Z"
}

6. List institutions

bash
curl https://aperioflow-apiv1-production.up.railway.app/v1/institutions -H "Authorization: Bearer af_sandbox_..."

The institutions returned depend on the environment of the key you use.

Normalised data model

Every institution's data is mapped to one model, so your code never branches on the bank.

Account subtypes

current_account, savings, credit_card, charge_card, loan, mortgage, prepaid_card, e_money, other.

Balance types

available, booked, cleared, expected, information, other. The bank's native balance type is retained in bank_type (e.g. InterimAvailable) so nothing is lost in normalisation.

Sample transaction

json
{
  "id": "txn_01J9Z8V5K2M4",
  "account_id": "acc_7hQ2nB",
  "status": "booked",
  "direction": "debit",
  "amount": { "amount": "-12.50", "currency": "GBP" },
  "booked_at": "2026-09-18T14:32:05Z",
  "value_date": "2026-09-18",
  "description": "PRET A MANGER LONDON",
  "reference": "CARD 1234",
  "merchant": { "name": "Pret A Manger", "category_code": "5814" },
  "counterparty": null,
  "running_balance": { "amount": "1487.23", "currency": "GBP" },
  "bank_transaction_code": { "code": "IssuedCreditTransfer", "sub_code": "Card", "proprietary_code": null, "proprietary_issuer": null },
  "bank_transaction_id": "123456789",
  "raw": null
}

This example is illustrative; the authoritative schema for every object is in the API reference.

90-day reconfirmation

UK rules require the account holder to reconfirm their consent at least every 90 days for you to continue accessing their data. Each authorised consent has a reconfirm_by timestamp. Before it passes you will receive a consent.reconfirmation_due webhook.

http
HTTP/1.1 403 Forbidden

{
  "error": {
    "code": "consent_reconfirmation_required",
    "message": "The account holder must reconfirm this consent before data can be accessed.",
    "request_id": "req_01J9ZC1T8Q"
  }
}

Unattended access limit

Regulation distinguishes requests made while the account holder is actively using your product from background (unattended) refreshes.

http
Aperioflow-PSU-IP-Address: 203.0.113.42

Only send the header when the account holder is genuinely present

Sending the header on background requests to avoid the limit misrepresents the request to the bank and breaches your agreement with Aperioflow.

Purpose limitation and onward sharing

Account holders grant access for the purpose stated in the consent, and Aperioflow is accountable to the FCA for how data accessed under our registration is used. As a developer you must:

We review your stated purposes and data use as part of the go-live review.

Webhooks

Set a webhook URL on your application to receive consent lifecycle events. Generate the webhook signing secret on the application's Overview & settings tab - it is shown once.

EventSent when
consent.authorisedThe account holder approved the consent at their bank.
consent.rejectedThe account holder declined, or bank authorisation failed.
consent.revokedThe consent was revoked by the account holder, the bank or via the API.
consent.expiredThe consent expired and data can no longer be accessed.
consent.reconfirmation_dueThe consent is approaching its reconfirm_by date (sent 7 days before).
consent.reconfirmedThe account holder reconfirmed; reconfirm_by has moved forward 90 days.

Events are delivered as an HTTP POST with a JSON body of {"id","type","created_at","data"}. Respond with a 2xx quickly and process asynchronously. Use id to deduplicate.

http
POST /webhooks/aperioflow HTTP/1.1
Content-Type: application/json
Aperioflow-Signature: t=1790331243,v1=5f8a3c...e21b

{
  "id": "evt_01J9ZD3H7P",
  "type": "consent.authorised",
  "created_at": "2026-09-25T09:14:03Z",
  "data": {
    "consent_id": "cns_5Gm2QkT9rXa1",
    "environment": "sandbox",
    "institution_id": "lloyds",
    "status": "authorised",
    "external_reference": "user-123"
  }
}

Verifying signatures

Each delivery includes Aperioflow-Signature: t=<unix timestamp>,v1=<hex>, where v1 is the hex-encoded HMAC-SHA256 of the string <t>.<raw request body> using your webhook secret as the key. Compute it over the raw bytes of the body, compare in constant time, and reject timestamps more than a few minutes old.

Node.js (Express)
import crypto from "node:crypto";
import express from "express";

const WEBHOOK_SECRET = process.env.APERIOFLOW_WEBHOOK_SECRET;
const TOLERANCE_SECONDS = 300;

function verifySignature(rawBody, header, secret) {
  if (!header) return false;
  let timestamp = null;
  const signatures = [];
  for (const part of header.split(",")) {
    const idx = part.indexOf("=");
    const key = part.slice(0, idx).trim();
    const value = part.slice(idx + 1).trim();
    if (key === "t") timestamp = value;
    if (key === "v1") signatures.push(value);
  }
  if (!timestamp || signatures.length === 0) return false;

  // Reject old deliveries to limit replay attacks
  const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
  if (!Number.isFinite(age) || age > TOLERANCE_SECONDS) return false;

  const expected = crypto
    .createHmac("sha256", secret)
    .update(timestamp + "." + rawBody.toString("utf8"))
    .digest("hex");

  return signatures.some((sig) => {
    const a = Buffer.from(sig, "hex");
    const b = Buffer.from(expected, "hex");
    return a.length === b.length && crypto.timingSafeEqual(a, b);
  });
}

const app = express();

// Use the raw body: re-serialised JSON will not match the signature.
app.post("/webhooks/aperioflow", express.raw({ type: "application/json" }), (req, res) => {
  if (!verifySignature(req.body, req.get("Aperioflow-Signature"), WEBHOOK_SECRET)) {
    return res.status(400).send("invalid signature");
  }
  const event = JSON.parse(req.body.toString("utf8"));
  // Deduplicate on event.id - deliveries may be retried.
  switch (event.type) {
    case "consent.authorised":
    case "consent.rejected":
    case "consent.revoked":
    case "consent.expired":
    case "consent.reconfirmed":
    case "consent.reconfirmation_due":
      // handle event.data ...
      break;
  }
  res.sendStatus(200);
});
Python (Flask)
import hashlib
import hmac
import os
import time

from flask import Flask, abort, request

WEBHOOK_SECRET = os.environ["APERIOFLOW_WEBHOOK_SECRET"]
TOLERANCE_SECONDS = 300

app = Flask(__name__)


def verify_signature(raw_body: bytes, header: str | None, secret: str) -> bool:
    if not header:
        return False
    timestamp, signatures = None, []
    for part in header.split(","):
        key, _, value = part.strip().partition("=")
        if key == "t":
            timestamp = value
        elif key == "v1":
            signatures.append(value)
    if timestamp is None or not signatures:
        return False
    try:
        if abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS:
            return False
    except ValueError:
        return False

    signed_payload = timestamp.encode() + b"." + raw_body
    expected = hmac.new(secret.encode(), signed_payload, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(expected, sig) for sig in signatures)


@app.post("/webhooks/aperioflow")
def aperioflow_webhook():
    raw = request.get_data()  # raw bytes, before any JSON parsing
    if not verify_signature(raw, request.headers.get("Aperioflow-Signature"), WEBHOOK_SECRET):
        abort(400)
    event = request.get_json()
    # Deduplicate on event["id"] - deliveries may be retried.
    if event["type"] == "consent.revoked":
        ...  # stop using and delete data as your retention policy requires
    return "", 200

Errors

Errors use conventional HTTP status codes and always have the same shape. Show or log message, branch on code, and include request_id in support requests. Validation errors (422) list the offending fields in details.errors.

json
HTTP/1.1 422 Unprocessable Entity

{
  "error": {
    "code": "validation_error",
    "message": "Request validation failed.",
    "request_id": "req_01J9ZE2F4R",
    "details": {
      "errors": [
        { "loc": ["body", "redirect_uri"], "msg": "redirect_uri is not registered for this application" }
      ]
    }
  }
}
Status / codeMeaning
400 / 422The request is malformed or failed validation.
401Missing, invalid or revoked API key.
403 consent_reconfirmation_requiredThe 90-day reconfirmation is overdue.
404The resource does not exist or is not visible to this key.
429 unattended_access_limitUnattended request limit reached for this resource and consent.
5xxA problem at Aperioflow or the bank. Retry with exponential backoff.

API reference

The complete, always-up-to-date reference for the public v1 API is generated from our OpenAPI specification: