---
title: "Open Finance Consent"
description: "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."
---

<MetaStrip items={[
  { label: "TIME", value: "~20 min" },
  { label: "STACK", value: (<><ServerChip name="HTTP / SDK REST" accent /><span style={{ color: "var(--color-fd-muted-foreground)", fontSize: 12 }}>no agent, no CLI</span></>) },
  { label: "SERVERS", value: (<><ServerChip name="ofb: stub" /><ServerChip name="ofb: 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.

<EventPipeline
  title="CONSENT LIFECYCLE"
  subtitle="Initiate → consumer authorises at the bank → finalise → ingest"
  nodes={[
    { tag: "1 · INITIATE", name: "Open the consent", meta: "POST /v1/bank-consents → 201 · status pending", tone: "event" },
    { tag: "2 · AUTHORISE", name: "Consumer at the bank", meta: "authorisation_url → your redirect_url", tone: "validation" },
    { tag: "3 · FINALISE", name: "Exchange the code", meta: "POST /callback → 200 · status authorised", tone: "loop" },
    { tag: "4 · INGEST", name: "Pull the statement", meta: "POST /refresh-statement → 202", tone: "event" },
  ]}
  loopSteps={[
    { tool: "refresh_started", description: "run_id written before any fetch" },
    { tool: "adapter.fetchTransactions", description: "paginated by nextCursor until it runs out" },
    { tool: "publishEvent", description: "one event per entry, deduped per project" },
    { tool: "refresh_completed", description: "202 carries imported and duplicates" },
  ]}
  note="revoke is a fifth call and touches no adapter: it writes our tables only"
/>

## Prerequisites

```bash
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](/docs/concepts/authentication).

<Callout type="warn" title="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](/docs/glossary) states.
</Callout>

<Callout type="warn" title="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](/docs/api/reference/bank-consents).
</Callout>

## The whole journey in one file

```typescript title="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.

```bash
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.

<Callout type="warn" title="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](/docs/concepts/wallets).
</Callout>

## 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

```bash
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.

<Callout type="warn" title="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.
</Callout>

## Step 5. Confirm the state

```bash
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

```bash
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`.

<Callout type="warn" title="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.
</Callout>

<Callout type="info" title="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.
</Callout>

## 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

```bash
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.

| Code | Status | What already happened | Does a retry fix it? |
|------|--------|-----------------------|----------------------|
| `invalid_body` | 400 | Nothing. The parse runs first. | No. `details.issues` names the field. On refresh the usual cause is a bound without an offset. |
| `invalid_query` | 400 | Nothing. | No. `limit` outside 1 to 200, or a `status` outside the five. |
| `not_found` | 404 | Nothing. | 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_elsewhere` | 409 | Depends 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_consumer` | 409 | Nothing new. Your own project already holds an open consent for this consumer at this bank. | No. Revoke yours first. |
| `db_error` | 409 | The 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_transition` | 409 | Nothing changed. | No. `details.from` and `details.to` carry the pair. |
| `consent_not_authorised` | 409 | Nothing. | Only after a successful callback. `details.status` says where it actually is. |
| `consent_disappeared` | 500 | The 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_id` | 500 | Nothing. 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_tokens` | 500 | Nothing. | No. Read it as "this authorised consent is not usable". |
| `adapter_error` | 502 | Depends on the route. See below. | Yes on initiate and on refresh. |
| `consent_lock_timeout` | 503 | Nothing was decided. The 2s advisory lock expired and the transaction rolled back. | Yes, `details.retriable=true`. But read the orphan note first. |

### consent_missing_tokens covers two different faults

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.

<Callout type="warn" title="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](/docs/concepts/audit-chain).
</Callout>

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:

| Canonical | Legacy alias |
|-----------|--------------|
| `POST /v1/bank-consents` | `POST /v1/ofb/consents` |
| `GET /v1/bank-consents/{id}` | `GET /v1/ofb/consents/{id}` |
| `POST /v1/bank-consents/{id}/callback` | `POST /v1/ofb/consents/{id}/callback` |
| `POST /v1/bank-consents/{id}/refresh-statement` | `POST /v1/ofb/consents/{id}/refresh-statement` |
| `POST /v1/bank-consents/{id}/revoke` | `POST /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](/docs/api/reference/ofb).

## 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](/docs/api/sdk/rest-client/bank-consents).
- **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](/docs/errors), 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](/docs/api/openapi).

## Next steps

<NextStepsGrid items={[
  { label: "REFERENCE", title: "Bank consents", description: "Every parameter, body and documented response for the six operations.", href: "/docs/api/reference/bank-consents" },
  { label: "SDK", title: "Bank consents on the REST client", description: "The six calls as typed cs.api invocations, compiled against the package.", href: "/docs/api/sdk/rest-client/bank-consents" },
  { label: "CONCEPT", title: "Wallets", description: "How reconciliation binds a funding source, and why a consent alone does not.", href: "/docs/concepts/wallets" },
]} />
