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
| Environment | Details |
|---|---|
| Sandbox | Keys 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. |
| Live | Keys 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
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.
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.
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:
{
"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:
# 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_deniedAlways 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.
| Endpoint | Returns |
|---|---|
GET /v1/accounts?consent_id=cns_... | Accounts covered by the consent |
GET /v1/accounts/{account_id} | A single account |
GET /v1/accounts/{account_id}/balances | Balances (available, booked, ...) |
GET /v1/accounts/{account_id}/transactions?from=2026-01-01&to=2026-09-01&include_raw=false | Transactions in the date range |
GET /v1/accounts/{account_id}/direct-debits | Direct debit mandates |
GET /v1/accounts/{account_id}/standing-orders | Standing orders |
# 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:
{
"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
# 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:
{
"consent_id": "cns_5Gm2QkT9rXa1",
"reconfirmation_url": "https://api.aperioflow.com/connect/reconfirm/...",
"expires_at": "2026-10-02T09:00:00Z"
}6. List institutions
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.
- Amounts are objects with a signed decimal string and an ISO 4217 currency, from the account holder's perspective: money leaving the account is negative, money arriving is positive - e.g.
{"amount":"-12.50","currency":"GBP"}. Transactions also carrydirection(debitorcredit). Parse amounts with a decimal type, never a float. - Timestamps are ISO 8601 in UTC (e.g.
2026-09-18T14:32:05Z); dates areYYYY-MM-DD. - Enums are lowercase
snake_case. Treat unknown values gracefully - new values may be added.
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
{
"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.
- Call
POST /v1/consents/{id}/reconfirmationsto obtain areconfirmation_urland send the account holder to it (in-app, or by email/notification). Reconfirmation is a simple confirmation on the Aperioflow-hosted screen - they do not need to re-authenticate with their bank. - Once
reconfirm_byhas passed without reconfirmation, all data endpoints for that consent return 403consent_reconfirmation_requireduntil the account holder reconfirms.
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.
- If the account holder is present (they triggered the request in your app right now), send their IP address in the
Aperioflow-PSU-IP-Addressheader. - If they are not present (scheduled syncs, background jobs), send nothing extra.
- Unattended requests are limited to 4 per resource per consent per 24 hours. Beyond that the API responds 429
unattended_access_limit. Cache results and schedule background refreshes accordingly.
Aperioflow-PSU-IP-Address: 203.0.113.42Only send the header when the account holder is genuinely present
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:
- use data only for the purpose stated in the consent, and request only the permissions that purpose needs;
- not share, sell or pass data to third parties (onward sharing) unless it is necessary for that purpose and has been clearly disclosed to the account holder;
- stop using data and delete it in line with your retention policy when a consent is revoked or expires (listen for
consent.revokedandconsent.expired); - make it easy for account holders to disconnect - call
DELETE /v1/consents/{id}when they do.
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.
| Event | Sent when |
|---|---|
consent.authorised | The account holder approved the consent at their bank. |
consent.rejected | The account holder declined, or bank authorisation failed. |
consent.revoked | The consent was revoked by the account holder, the bank or via the API. |
consent.expired | The consent expired and data can no longer be accessed. |
consent.reconfirmation_due | The consent is approaching its reconfirm_by date (sent 7 days before). |
consent.reconfirmed | The 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.
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.
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);
});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 "", 200Errors
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.
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 / code | Meaning |
|---|---|
| 400 / 422 | The request is malformed or failed validation. |
| 401 | Missing, invalid or revoked API key. |
403 consent_reconfirmation_required | The 90-day reconfirmation is overdue. |
| 404 | The resource does not exist or is not visible to this key. |
429 unattended_access_limit | Unattended request limit reached for this resource and consent. |
| 5xx | A 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:
- Interactive reference (Swagger UI)
- ReDoc
- openapi.json - import into Postman or generate a client.