Skip to main content
Cookbooks

Open Finance Consent

From the consent at the bank to the ingested statement: initiate, finalise, refresh, revoke. The order each handler runs in, what already happened when a refusal arrives, and which refusal a retry actually fixes.

1 min read
View MarkdownEdit on GitHub
TIME
~20 min
STACK
HTTP / SDK RESTno agent, no CLI
SERVERS
ofb: stubofb: belvo

Five calls take a consumer's bank account from nothing to an ingested statement: initiate the consent, send the consumer to the bank, finalise with the code the bank hands back, pull the statement, and revoke when you are done.

CONSENT LIFECYCLE
Initiate → consumer authorises at the bank → finalise → ingest
1 · INITIATE
Open the consent
POST /v1/bank-consents → 201 · status pending
2 · AUTHORISE
Consumer at the bank
authorisation_url → your redirect_url
3 · FINALISE
Exchange the code
POST /callback → 200 · status authorised
4 · INGEST
Pull the statement
POST /refresh-statement → 202
STEP 1
refresh_started
run_id written before any fetch
STEP 2
adapter.fetchTransactions
paginated by nextCursor until it runs out
STEP 3
publishEvent
one event per entry, deduped per project
STEP 4
refresh_completed
202 carries imported and duplicates
revoke is a fifth call and touches no adapter: it writes our tables only

Prerequisites

npm install @codespar/sdk

An API key with consents:write (initiate, callback, refresh, revoke) and consents:read (the single read and the list). A key carrying the * wildcard takes a fast path in scope enforcement and passes regardless, which is the default for every pre-existing key and for service auth. You only see a scope refusal with a key that was deliberately narrowed. See Authentication.

Check which adapter is wired before you read a 201 as proof

OFB_ADAPTER_MODE defaults to stub. The stub returns an authorisation_url on https://example.bank/..., a bank_consent_id shaped stub_consent_<hash>, and three deterministic transactions with ids shaped stub_ofb_<hash>. A 201 with a filled authorisation_url proves no bank. The modes pluggy, iniciador and direct throw on the first call. belvo is the only real path wired today, as the glossary states.

A bad adapter mode does not produce a 502

getOfbAdapter() is called outside the try block that turns adapter failures into adapter_error on all three routes that use it. A wrong OFB_ADAPTER_MODE throws OfbAdapterUnavailableError and lands in the global handler, returning a code that appears in none of the response tables on the HTTP reference.

The whole journey in one file

open-finance-consent.ts
import { CodeSpar } from "@codespar/sdk";

const cs = new CodeSpar({ apiKey: process.env.CODESPAR_API_KEY! });

// 1. Initiate. Needs consents:write.
const created = await cs.api.post("/v1/bank-consents", {
  body: {
    bank_id: "banco-do-brasil",
    consumer_id: "csm_0a1b2c3d4e5f6071",
    scopes: ["accounts", "transactions"],
    redirect_url: "https://app.example.com/openfinance/return",
    wallet_id: "wlt_0a1b2c3d4e5f6071",
  },
});

const consentId = created.bank_consent_id;

// 2. Send the consumer here. The 201 fills all three of these.
console.log(created.authorisation_url, created.expires_at, consentId);

// 3. The bank redirects the consumer to redirect_url with a code.
//    You compose and host that page. It extracts the code and posts it back
//    to your own server, which makes call 4.

// 4. Finalise. The code is single-use: a second callback answers 409.
const finalised = await cs.api.post("/v1/bank-consents/{id}/callback", {
  path: { id: consentId },
  body: { auth_code: process.env.BANK_AUTH_CODE! },
});

// Assert on the field, never on the 200. See the gotcha below.
if (finalised.status !== "authorised") {
  throw new Error(`consent is ${finalised.status}, not authorised`);
}

// 5. Confirm and read the token clock. Needs consents:read.
const consent = await cs.api.get("/v1/bank-consents/{id}", {
  path: { id: consentId },
});
console.log(consent.status, consent.token_expires_at);

// 6. Pull the statement and ingest it. Both bounds are optional.
//    RFC 3339 WITH offset: "2026-09-01T00:00:00Z" passes,
//    "2026-09-01T00:00:00" is a 400.
const run = await cs.api.post("/v1/bank-consents/{id}/refresh-statement", {
  path: { id: consentId },
  body: {
    since: "2026-09-01T00:00:00Z",
    until: "2026-09-14T00:00:00Z",
  },
});
console.log(run.run_id, run.imported, run.duplicates);

// 7. Reconciliation runs in-process on a fixed 60s cycle.
//    next_recon_cycle_within_seconds is that constant, not a queue estimate.
await new Promise((r) => setTimeout(r, run.next_recon_cycle_within_seconds * 1000));

// 8. Revoke. No adapter is called: this writes our tables only.
const revoked = await cs.api.post("/v1/bank-consents/{id}/revoke", {
  path: { id: consentId },
  body: { reason: "user disconnected the account" },
});

if (revoked.status !== "revoked") {
  throw new Error(`revoke did not land: status is ${revoked.status}`);
}

Step 1. Initiate

Four fields are required: bank_id matching ^[a-z0-9-]+$ and 1 to 64 characters, consumer_id 1 to 128, scopes as an array of 1 to 20 strings of 1 to 64 characters each, and redirect_url as a URI. wallet_id is optional.

curl -X POST https://api.codespar.dev/v1/bank-consents \
  -H "Authorization: Bearer $CODESPAR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "bank_id": "banco-do-brasil",
    "consumer_id": "csm_0a1b2c3d4e5f6071",
    "scopes": ["accounts", "transactions"],
    "redirect_url": "https://app.example.com/openfinance/return",
    "wallet_id": "wlt_0a1b2c3d4e5f6071"
  }'

The handler runs in this order, and the order is what tells you what exists after a refusal:

  1. Zod parse of the body.
  2. getOfbAdapter().
  3. findSiblingGrant() on the pool, with no lock, before the bank is called.
  4. adapter.initiateConsent(), the call to the bank.
  5. A tenant transaction: SET LOCAL lock_timeout='2s', an advisory transaction lock on a hash of org, consumer and bank, findSiblingGrant() again under the lock, INSERT the row with status='pending', INSERT the initiated event.
  6. Re-read, then 201.

The 201 carries bank_consent_id, authorisation_url and expires_at, all three non-null.

wallet_id is a label, not routing

The refresh copies consent.wallet_id into the payload of every event it writes, and nothing reads it back. The wallet reconciliation engine resolves a wallet through wallet_funding_sources.connection_id matched against connection_id in the payload, a key the Open Finance event does not carry. With no binding, the engine skips the row: the orphan credit does not even become an anomaly. Attaching a consent to a wallet does not put that statement into that wallet's reconciliation. See Wallets.

Step 2. Send the consumer to the bank

Redirect the consumer to authorisation_url from the 201. Nothing else happens on our side until the callback.

Step 3. Host the return page yourself

The bank redirects the consumer to your redirect_url with the code. There is no hosted return page on our side: you compose that page and you host it, and it extracts the code and hands it to your server.

A comment in the adapter source claims redirectUrl is composed by the route handler from the dashboard's public URL plus the consent id. That comment is stale. The schema requires redirect_url in the request body, and the handler forwards what you sent.

Step 4. Finalise with the code

curl -X POST https://api.codespar.dev/v1/bank-consents/bankconsent_0a1b2c3d4e5f6071/callback \
  -H "Authorization: Bearer $CODESPAR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "auth_code": "b1a0f2c3-4d5e-6f70-8192-a3b4c5d6e7f8" }'

Order inside the handler: parse, load the consent by org and project, assert the transition to authorised is legal, check bank_consent_id is present, call adapter.finaliseConsent(), then a transaction that sets status='authorised', authorized_at=now(), the two token references and token_expires_at where the id and project match and the status is still pending, writes the authorised event, re-reads, and returns 200.

The 200 does not prove the transition happened

The UPDATE carries AND status='pending' on the callback and AND status IN ('pending','authorised') on the revoke, and the handler never checks how many rows it touched. It re-reads and returns 200 with whatever it finds. Under a race you can receive a 200 whose status is not the one you asked for. Assert on the status field of the body.

Step 5. Confirm the state

curl -H "Authorization: Bearer $CODESPAR_API_KEY" \
  "https://api.codespar.dev/v1/bank-consents?status=authorised&limit=50"

The list takes limit (1 to 200, default 50) and status (one of pending, authorised, revoked, expired, consumed), orders by created_at DESC, and answers with a bank_consents array. There is no cursor. Past 200 consents there is no way to page the rest.

Read "does this consent work right now?" from status === 'authorised', never from revoked_at being null. The served document says so on the field itself. Note that authorised with an s and authorized_at with a z sit on the same object: those are two column names, not a typo.

Only three of the five states are reachable through the API. The INSERT writes pending, the callback writes authorised, the revoke writes revoked, and nothing else in the codebase writes to that table. The five-value enum is the CHECK constraint on the migration. Querying ?status=expired or ?status=consumed returns an empty list every time.

Step 6. Pull the statement

curl -X POST https://api.codespar.dev/v1/bank-consents/bankconsent_0a1b2c3d4e5f6071/refresh-statement \
  -H "Authorization: Bearer $CODESPAR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "since": "2026-09-01T00:00:00Z", "until": "2026-09-14T00:00:00Z" }'

Both bounds are optional. Both must be RFC 3339 with offset. The handler loads the consent, requires status === 'authorised', requires both access_token_ref and bank_consent_id, writes the refresh_started event with the run_id before any fetch, then loops adapter.fetchTransactions() paginating on nextCursor and publishes one event per entry, writes refresh_completed, and answers 202 with consent_id, run_id, imported, duplicates and next_recon_cycle_within_seconds.

Ingested means rows in events, not money in a wallet

The first pass of the reconciliation engine only looks at wallet_ledger rows with kind='debit' and reconciled_at IS NULL, matching on events.provider_event_id = wallet_ledger.external_ref inside the org. A statement credit with no matching debit creates no ledger entry at all. imported: 3 in a 202 is a count of events, not a balance.

Calling refresh twice is safe about double-crediting, not about being a no-op

Every call hits the bank again, mints a new run_id, and writes two more events (refresh_started and refresh_completed). What stops a double credit is the partial unique index behind publishEvent: ON CONFLICT (source, provider_event_id, project_id) WHERE provider_event_id IS NOT NULL, with deduped read off (xmax = 0). Here source is ofb-<bank_id>, and the partition is per project.

Step 7. Wait for reconciliation

The reconciliation loop runs in-process inside the API. It is off only when WALLET_RECON=off or SMOKE_LOCAL=true. The interval and the grace period are both 60 seconds, fixed constants. The next_recon_cycle_within_seconds field in the 202 is that constant, not an estimate of queue depth.

Step 8. Revoke

curl -X POST https://api.codespar.dev/v1/bank-consents/bankconsent_0a1b2c3d4e5f6071/revoke \
  -H "Authorization: Bearer $CODESPAR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "reason": "user disconnected the account" }'

reason is optional, 1 to 512 characters. The handler parses, loads, asserts the transition, then sets status='revoked' and revoked_at=now() where the id and project match and the status is pending or authorised, writes the revoked event, re-reads and returns 200 with the consent projection. No envelope of its own, and no adapter call anywhere in this step.

Refusals, and which one a retry fixes

The legal transitions are pending to authorised, revoked or expired; authorised to revoked, expired or consumed. The states revoked, expired and consumed are terminal. That is why a repeated callback answers 409 instead of exchanging a second pair of tokens, and why a second revoke answers 409.

CodeStatusWhat already happenedDoes a retry fix it?
invalid_body400Nothing. The parse runs first.No. details.issues names the field. On refresh the usual cause is a bound without an offset.
invalid_query400Nothing.No. limit outside 1 to 200, or a status outside the five.
not_found404Nothing.No. The load matches org and project. A consent in a sibling project of the same org answers exactly like one that never existed: the credential is for the wrong project.
bank_consent_held_elsewhere409Depends on which layer refused. See below.No. details.retriable=false, classified terminal. Revoke in the project that holds it, or have the consumer authorise this project directly.
consent_active_for_consumer409Nothing new. Your own project already holds an open consent for this consumer at this bank.No. Revoke yours first.
db_error409The row could not be persisted.Not blind. The driver message stays in the log under the request id on purpose: a unique-violation message would print key values belonging to another project.
illegal_transition409Nothing changed.No. details.from and details.to carry the pair.
consent_not_authorised409Nothing.Only after a successful callback. details.status says where it actually is.
consent_disappeared500The transaction committed and the re-read came back empty. The row may well exist.Do not repeat blind. List by consumer and bank, or fetch the id, before trying again.
missing_bank_consent_id500Nothing. The stored row has no bank-side id, so there is nothing to finalise.No. Defect on our side. Quote the request id.
consent_missing_tokens500Nothing.No. Read it as "this authorised consent is not usable".
adapter_error502Depends on the route. See below.Yes on initiate and on refresh.
consent_lock_timeout503Nothing was decided. The 2s advisory lock expired and the transaction rolled back.Yes, details.retriable=true. But read the orphan note first.

The guard is if (!consent.access_token_ref || !consent.bank_consent_id), and the handler does not separate the branches. The second condition is not about a token at all. Treat the code as a statement about the consent being unusable, not as a claim about credentials.

What a 502 means depends on where it fired

On initiate, the bank refused or did not answer before any write of ours. Nothing was stored and nothing was opened at the bank. Retry is reasonable.

On the callback, the code exchange failed. The UPDATE only runs after the adapter returns, so the consent is still pending and no token pointer was written on our side. Whether the bank's code is still valid is state at the bank, and the response neither affirms nor denies it. The message is the adapter's, and it comes back empty when what was thrown was not an Error.

On refresh, partial progress stays. Each entry is committed as it is ingested, so everything that landed before the failure is still ingested and a retry only re-imports what is missing. The message is the adapter's, except when the error came from our own database, in which case it is replaced with the fixed phrase the statement could not be fetched so a table name of ours is never exposed.

Which refusals leave an orphan at the bank

The sibling pre-check runs on the pool before adapter.initiateConsent(), so a request refused there opens no bank-side consent. The refusals that do orphan one are the ones that land after the bank call: bank_consent_held_elsewhere coming from the lock or the index rather than the pre-check, consent_active_for_consumer, db_error, and consent_lock_timeout. A 502 on initiate does not orphan, because the bank call is what failed. consent_disappeared does not orphan either, because the row was written.

The caller cannot tell which layer refused

Pre-check and post-bank refusal answer with the same bank_consent_held_elsewhere and the same body. The field that names the layer is refused_by, valued precheck, lock or index, and it goes only to the audit event ofb_consent_refused_sibling_project, which the caller does not read. The body also never names which project holds the grant. Only an operator can enumerate the orphans, through the audit chain.

On consent_lock_timeout specifically: the rollback means no row of ours exists, but the bank-side consent was already opened and is now orphaned. The handler logs it with both the consent id and the bank consent id. Retry does resolve the call, and every retry opens one more consent at the bank.

Nothing cleans up an orphaned consent in code. The adapter interface has no revoke operation, and /revoke only writes our tables. Reconciliation is manual, through the bank's own channel.

Legacy aliases

Five of the six operations have a deprecated twin under /v1/ofb/consents. Same handler, same scope, same response codes, deprecated: true in the served document:

CanonicalLegacy alias
POST /v1/bank-consentsPOST /v1/ofb/consents
GET /v1/bank-consents/{id}GET /v1/ofb/consents/{id}
POST /v1/bank-consents/{id}/callbackPOST /v1/ofb/consents/{id}/callback
POST /v1/bank-consents/{id}/refresh-statementPOST /v1/ofb/consents/{id}/refresh-statement
POST /v1/bank-consents/{id}/revokePOST /v1/ofb/consents/{id}/revoke

GET /v1/bank-consents has no alias. The collection was born on the canonical path, so there is no legacy list to keep. All five aliases compile on the typed REST client; prefer the canonical path. The alias group has its own page at the ofb reference.

What this does not do

  • No meta-tool. Nothing under the meta-tool surface mentions bank consents, and none of the meta-tool concept pages mention Open Finance. This journey is HTTP and SDK, not an agent.
  • No CLI command. None of the six steps has one. Watch the homonym: codespar consents create and codespar consents init exist, but they reach POST /v1/consents and POST /v1/consents/init, which is the mandate and directed-pay family, a different table entirely.
  • No named SDK method. There is no cs.bankConsents.create(). What exists is the typed REST client, cs.api.get and cs.api.post with the literal path, as on the SDK page for this group.
  • No hosted callback endpoint. The bank redirects to your redirect_url. The /callback route is a server-to-server call carrying auth_code that your page has to make.
  • No expiry job. Nothing marks a consent expired when expires_at passes. That field mirrors what the bank said at initiation, and nothing reads it afterwards.
  • Six of these codes are not on the errors page. bank_consent_held_elsewhere, consent_lock_timeout, consent_missing_tokens, consent_disappeared, missing_bank_consent_id and adapter_error are absent from the errors index, which carries only consent_active_for_consumer, consent_not_authorised, illegal_transition and db_error. Use the response table on the reference page instead.
  • Response shapes are documentation, not a contract. Four of the six request bodies are hand-written in the OpenAPI source rather than imported from the route, because the route's schemas are module-local constants. The document header says response shapes are not checked against handler output. See the OpenAPI document.

Next steps

Open Finance Consent | CodeSpar