# openfeed — full developer & product reference (for LLMs) > Spec version: Sharing API v4.0.0. Last updated: 2026-07-17. > **Type authority:** `openapi.yaml` is the authoritative source for all field names, types, > and shapes. Where this document describes field types, treat the spec as the override. > Generate TypeScript types directly from the spec to catch mismatches at compile time: > `npx openapi-typescript https://openfeed.au/openapi.yaml -o lib/openfeed-types.ts` openfeed is a consumer data platform native to Australia's Consumer Data Right (CDR). Consumers connect their banking and energy accounts once through the full CDR flow, then share that already-aggregated data with any number of apps in a single tap. Developers integrate once against one OAuth 2.0 + REST API that covers every data holder, for both banking and energy. ## The two-consent model (critical) 1. CDR arrangement: the consumer's consent to a data holder, established once per holder through the full CDR flow. Heavy by design. 2. Disclosure grant: the consumer's consent to share already-aggregated data with a specific app, granted in a single screen and governed by the Privacy Act. Invariant: revoking a disclosure grant does NOT cascade to the CDR arrangement. ## Data aggregation and refresh openfeed aggregates each connected holder's data and normalises it to a single API. Data is batch-refreshed on a schedule — banking every 4 hours, energy every 6 hours. Always show a "last updated ~Xh ago" indicator to consumers. **Balance endpoints are an exception:** `GET /v1/banking/accounts/{id}/balance` and `GET /v1/energy/accounts/{id}/balance` fetch live from Open Gateway with a ~15-minute transient cache — not the 4–6h batch cycle. Show a distinct "live" freshness indicator for balance data. ## Login with openfeed (optional identity) Apps can offer optional "Login with openfeed" single sign-on, letting users sign in with their openfeed identity. It is free to any developer. The stable identity claim is `sub` (the openfeed user UUID — immutable; link on this, never on email). --- ## How to build an openfeed application ### 1. Register your application Register in the openfeed dashboard (https://app.openfeed.au/registered-apps/new). Required fields: - `name` and `description` — shown to users on the consent screen - `websiteUrl` — (optional) canonical homepage; shown on the consent screen and in the catalogue - `requestedScopes` — `openfeed-au:data:banking:read`, `openfeed-au:data:energy:read`, or both. The `grant:self:*` scopes (`grant:self:query`, `grant:self:revoke`) are **not provided by default**. Requesting a scope that is not registered returns `invalid_scope`. - `authMethod` — **`private_key_jwt`** (the only accepted value; `client_secret_basic` is retired and the dashboard hard-pins this field regardless of what the form submits) - **App keypair public key** — one of two mutually exclusive options: - **Inline JWKS JSON** (recommended for local dev): paste the public key set `{"keys":[...]}` directly. The auth-server stores it statically — you must update the dashboard if you rotate keys. - **`jwksUri`** URL: the auth-server re-fetches on every PAR request, enabling automatic key rotation. Must be a publicly reachable HTTPS URL (not localhost). Use this for production deployments where you want transparent rotation. - `postLogoutRedirectUris` — register at minimum `https://your-app.com/` (note the trailing slash). Exact-matched at logout; omitting the trailing slash or the field entirely causes silent logout failures. Note: OAuth 2.0 `redirect_uri` values do **not** need to be pre-registered. FAPI 2.0 carries `redirect_uri` inside the authenticated PAR request at each sign-in — the dashboard registration form has no redirect URI input field for this reason. On submit you receive: - `id` — your App ID (bare UUID); used as `appId` in the consent deep-link **only** - `oauth2ClientId` — the `app-`-prefixed OAuth2 client ID (e.g. `app-550e8400-...`); use this as `client_id` in **every** OAuth request (PAR, token endpoint, client_assertion `iss`/`sub`). The bare UUID returns `invalid_client`. Apps start **immediately approved** in the current phase — you can build and test as soon as you register. ### 2. Client security profile All third-party clients use the **Recommended profile** — `private_key_jwt` client authentication, DPoP-bound tokens (RFC 9449), PAR (required), and PKCE-S256. There are no public clients. PAR (Pushed Authorization Requests) is **mandatory**. A plain authorization redirect without a prior PAR will be rejected. `redirect_uri` pre-registration is **not** required — the operative `redirect_uri` is carried inside the authenticated PAR request and validated at runtime. The registration form has no redirect URI input field for this reason. `post_logout_redirect_uri` is still registered in the form (unauthenticated logout flow). #### Key material: two keypairs You need **two PS256/RSA keypairs** — keep them separate so that compromising one does not compromise the other: - **App keypair (PS256/RSA):** signs `private_key_jwt` client assertions. The public key is registered at app creation — either as inline JWKS JSON (paste the public key set into the dashboard; keys are stored statically and used until you update them) or as a `jwksUri` URL (the auth-server re-fetches on every PAR request, enabling transparent key rotation). The two modes are mutually exclusive — use inline JSON for local dev and simple deployments; use `jwksUri` when you need automatic rotation. - **DPoP keypair (PS256/RSA):** signs DPoP proofs (RFC 9449). The auth-server advertises `dpop_signing_alg_values_supported: ["PS256"]` — PS256 is the **only** accepted DPoP algorithm. Never publish the DPoP private key in your JWKS. ### 3. Authorization flow Resolve all endpoints from the discovery document. The auth issuer is configuration — read it from an environment variable (e.g. `OPENFEED_ISSUER`, default `https://auth.openfeed.au`) rather than hard-coding it. The `client_assertion` `aud`, the `resource` audience, and the discovery URL must all be consistent with the issuer the app was registered against — treat them as a matched set derived from a single `OPENFEED_ISSUER` env var, not as independent constants. Fetch the discovery document before making any auth calls — do not hard-code endpoint URLs: GET https://auth.openfeed.au/.well-known/openid-configuration Key endpoints: `pushed_authorization_request_endpoint`, `authorization_endpoint`, `token_endpoint`, `jwks_uri`, `end_session_endpoint`, `grant_management_endpoint`. Access tokens are **opaque strings** — do not attempt to decode or verify them as JWTs. The `grant_id` is carried in the token **response body** under `authorization_details[0].grant_id`, not as a claim inside the token itself. Introspection (`POST {introspection_endpoint}`) returns `{active, grant_id}` and is the server-side way to confirm a token is still active. #### Step-by-step (private_key_jwt + DPoP + PAR + PKCE-S256) **3a. Push the authorization request (PAR):** POST to `pushed_authorization_request_endpoint`, client-authenticated with `private_key_jwt`. The `client_assertion` `aud` must be the **`issuer` value from the discovery document** (`https://auth.openfeed.au`) — not the PAR or token endpoint URL. Using the endpoint URL returns `invalid_client`. Required JWT claims: `iss=`, `sub=`, `aud=`, `jti=`, `iat=`, `exp=`. ``` POST {pushed_authorization_request_endpoint} Content-Type: application/x-www-form-urlencoded client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer client_assertion= response_type=code client_id=YOUR_CLIENT_ID redirect_uri=https://your-app/callback scope=openid openfeed-au:data:banking:read openfeed-au:data:energy:read # Include only the data scopes the app actually needs — # see "Scope derivation" below. grant:self:* and all:* # scopes are opt-in; omit them from an initial build. resource=https://api.openfeed.au # REQUIRED — binds the token to the sharing-api audience. # Without this, sharing-api validation fails on every data call. code_challenge= code_challenge_method=S256 nonce= state= grant_management_action=create # omit to default to 'create'; use 'replace' + grant_id to amend -> { "request_uri": "urn:ietf:params:oauth:request_uri:...", "expires_in": 60 } ``` **3b. Redirect the user:** ``` GET {authorization_endpoint}?client_id=YOUR_CLIENT_ID&request_uri=urn:...:... ``` The user authenticates and sees the openfeed disclosure consent screen. **3c. Receive the code** at your `redirect_uri`. Validate the `iss` parameter (RFC 9207). **3d. Exchange the code (with DPoP):** ``` POST {token_endpoint} Content-Type: application/x-www-form-urlencoded DPoP: } payload: { htu: "", htm: "POST", iat: , jti: "" }> client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer client_assertion= grant_type=authorization_code code= code_verifier= redirect_uri=https://your-app/callback resource=https://api.openfeed.au # REQUIRED — binds token to sharing-api audience. # Omitting it issues a token without the sharing-api audience, # causing 401 on every data call. -> { "access_token": "", "token_type": "DPoP", "refresh_token": "...", "expires_in": 3600, "authorization_details": [{ "grant_id": "", ... }] } ``` Note: the `jwk` claim in the DPoP proof JOSE **header** must contain the **public** DPoP key (never the private key). The `typ` header must be `dpop+jwt`. **DPoP nonces (RFC 9449 §8):** the auth-server may demand a nonce at any time, answering `400` with `{"error":"use_dpop_nonce"}` and a `DPoP-Nonce` response header. A conforming client retries the request **once**, adding `nonce: ""` to the proof payload. Nonces are not currently required, but implement the retry now — otherwise enabling them server-side breaks every client at the token endpoint simultaneously. Cache the most recent nonce per endpoint and reuse it until the server issues a new one. Read `grant_id` from the token response body: `response.authorization_details[0].grant_id`. Access tokens are opaque strings — do **not** call `decodeJwt(access_token)`. If `authorization_details` is absent or empty, no disclosure grant exists yet — proceed to §4 Phase A. **3e. Call the Sharing API (DPoP-bound):** ``` GET https://api.openfeed.au/v1/banking/accounts Authorization: DPoP DPoP: ``` **DPoP `htu` construction (RFC 9449 §4.2):** the `htu` claim must be the request URL with query string and fragment stripped. When an account or meter ID contains `=` (base64 padding, e.g. `LTmpbiyxCpprM5TA3nTSK_sKPcV-O-h7OEI=`), the `=` must appear **raw** in both the fetch URL and the `htu` — never percent-encoded as `%3D`. Using `encodeURIComponent` on the full URL will encode `=`, causing `401 invalid_dpop_proof` even though the proof structure is otherwise correct. The safe pattern: ```javascript // Construct htu: strip query + fragment; path must remain unencoded const htu = url.split('?')[0].split('#')[0]; ``` | Fetch URL | htu in proof | Result | |---|---|---| | raw `=` | raw `=` | **200** | | `%3D` | `%3D` | 401 | | raw `=` | `%3D` | 401 | | `%3D` | raw `=` | 401 | **3f. Refresh the access token:** ``` POST {token_endpoint} Content-Type: application/x-www-form-urlencoded DPoP: } payload: { htu: "", htm: "POST", iat: , jti: "" }> client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer client_assertion= grant_type=refresh_token refresh_token= resource=https://api.openfeed.au -> { "access_token": "", "token_type": "DPoP", "refresh_token": "...", "expires_in": 3600 } ``` The refresh call requires a new DPoP proof (same shape as the code exchange — `htu` is the token endpoint, `htm` is `POST`). Include `resource=https://api.openfeed.au` to re-bind the new token to the sharing-api audience. On 401, the refresh token has expired — run the full PAR flow again. ### 4. Disclosure grant — the two-phase flow **Critical:** The first token response after login will NOT include `authorization_details` with a `grant_id` if no disclosure grant exists yet. A grant-less token cannot call data endpoints. #### Phase A — Establish the grant (first-time users) Check the token response body for `authorization_details[0].grant_id`. If absent, redirect the user to the consent deep-link: ``` https://app.openfeed.au/grants/disclosure?appId=YOUR_APP_ID&redirectUri=https://your-app/consent-return ``` `appId` is the bare `id` UUID from registration — **not** the `oauth2ClientId`. These are two different values: `oauth2ClientId` (the `app-` prefixed string) is used as `client_id` in all OAuth requests; `id` (bare UUID) is used only as `appId` in the consent deep-link. The user selects which accounts to share. On completion they are redirected to your `redirectUri` with `?grantId=&consented=true` (success) or `?consented=false&reason=...` (declined/error). On success, use your existing `refresh_token` to mint a new access token. The new token will carry the `grant_id` claim automatically — no second PAR or re-authentication is needed: ``` grant_type=refresh_token # existing refresh_token from the original login ``` After the refresh, confirm `authorization_details[0].grant_id` is present in the new token response before proceeding to data calls. **`grant_management_action` decision table (PAR parameter):** | Situation | Action | |---|---| | First login — no grant exists yet | `grant_management_action=create` (or omit — `create` is the default) | | User wants to change which accounts they share | `grant_management_action=replace` + `grant_id=` | #### Phase B — Use and manage the grant ``` # List the calling app's grants (lightweight index — M2M, Bearer) GET https://api.openfeed.au/v1/app/grants -> 200 { "version": "V1", "data": [ { "id": "...", "revision": 1, "lastUpdated": "..." } ], ... } # Get detailed grant information (scope: openfeed-au:grant:self:query) GET https://api.openfeed.au/v1/grants/{grantId} Authorization: DPoP -> 200 { "version": "V1", "data": { "grant_id": "...", "grantStatus": "ACTIVE", "userId": "...", "bankingAccountIds": [...], "energyAccountIds": [...], ... } } # Revoke a grant (scope: openfeed-au:grant:self:revoke) DELETE https://api.openfeed.au/v1/grants/{grantId} Authorization: DPoP -> 204 ``` To **amend** which accounts a user shares, initiate a new PAR with `grant_management_action=replace` and `grant_id=`. The grant is updated in-place (same `grant_id`, incremented `revision`) — no new grant ID is issued. The old endpoints `GET /v1/agreements/disclosure`, `DELETE /v1/agreements/disclosure/{id}`, and `POST /v1/consent-edit-link` **no longer exist**. Any call to them returns 404. ### 5. Fetch CDR data All data endpoints require an active grant. They return 403 (`disclosure_grant_required`) if the grant is absent, revoked, or the app is not approved, and 402 (`credit_exhausted`) if the developer's credit balance is exhausted (grant suspended). #### Response envelope (all endpoints) ```json { "version": "V1", "data": [ ... ], "meta": { "limit": 1000, "offset": 0 }, "links": { "self": "https://...", "next": "https://..." } } ``` `links.next` is present **only** when more records exist — its absence means you are on the last page. `meta` and `links` are present on paginated (collection) endpoints only. Single-resource endpoints return `{ "version": "V1", "data": { ... } }`. Pagination params: `limit` (default and max 1000) and `offset` (default 0). #### Banking endpoints ``` GET https://api.openfeed.au/v1/banking/accounts list accounts (paginated) GET https://api.openfeed.au/v1/banking/accounts/{accountId} account detail GET https://api.openfeed.au/v1/banking/accounts/{accountId}/balance current balance (live, ~15min cache) GET https://api.openfeed.au/v1/banking/accounts/{accountId}/transactions transactions (paginated) query params: oldestDate, newestDate (ISO date), limit, offset ``` **Transaction `amount` sign convention:** negative = debit (money out); positive = credit (money in). Use this to split income from expenses: sum positives for income, sum absolute values of negatives for expenditure. Returns 403 if the account is not in the caller's grant consent scope. #### Rely on the contract; defend against optional fields The spec's `required` list defines what you can depend on. A transaction guarantees only `transactionId` and `accountId` — every other field, including all date fields, is optional. Provider coverage varies: a field that is consistently present for one institution may be absent for another. Build so that a missing optional field degrades gracefully (skip or surface a "not available" state) rather than silently corrupting data or dropping records. Never assume an optional field is present; never hard-fail when it is absent. For date-dependent logic (sorting, filtering, recurring-pattern detection) this means checking all four date fields the spec defines — `transactionDate`, `valueDate`, `postedDateTime`, `executionDateTime` — and using the first non-null value. Only skip the record if all four are absent. Filtering on `transactionDate` alone will silently drop every transaction where that field is null, producing a confident but empty result (e.g. "$0 detected") with no error. **Institution / data holder name:** both `BankingAccount` and `EnergyAccount` extend `ProviderRef`, which carries `providerId` (string) and `providerName` (string — the institution display name). Use `providerName` as the data holder label in the UI (e.g. "Commonwealth Bank", "AGL"). #### Energy endpoints **Note:** energy meters are nested under their account (not a top-level resource). The old `/v1/energy/electricity/servicepoints/*` paths no longer exist. ``` GET https://api.openfeed.au/v1/energy/accounts list energy accounts GET https://api.openfeed.au/v1/energy/accounts/{accountId} account detail GET https://api.openfeed.au/v1/energy/accounts/{accountId}/balance current balance (live, ~15min cache) GET https://api.openfeed.au/v1/energy/accounts/{accountId}/meters list meters for account GET https://api.openfeed.au/v1/energy/accounts/{accountId}/meters/{meterId} meter detail GET https://api.openfeed.au/v1/energy/accounts/{accountId}/meters/{meterId}/usage interval usage (paginated) query params: oldestDate, newestDate (ISO date), limit, offset Note: use windows of 30 days or less; large date ranges (e.g. 12 months) may return 500. GET https://api.openfeed.au/v1/energy/accounts/{accountId}/meters/{meterId}/der DER config (404 if none) GET https://api.openfeed.au/v1/energy/accounts/{accountId}/billing billing line items (paginated) GET https://api.openfeed.au/v1/energy/accounts/{accountId}/invoices invoices (paginated) ``` **DER note:** `approvedCapacity: 0` is returned for non-solar installs — treat zero as "no solar/battery" rather than an error. Returns 403 if the account is not in the caller's grant consent scope. #### App self-info ``` GET https://api.openfeed.au/v1/app app details (pure app token) ``` ### 6. Handle errors | Status | Meaning | Action | |---|---|---| | 401 | Missing or expired token | Re-authenticate (run the PAR + auth flow again) | | 402 `credit_exhausted` | Grant suspended — developer's credit balance is zero | Inform user; developer must top up credits | | 403 `disclosure_grant_required` | No active grant — app not approved, or grant absent/revoked | Deep-link the user to the consent screen to (re-)establish a grant | | 403 `balance_unavailable` | Provider does not support live balance for this account type | **Do not treat as auth/grant failure.** Degrade to "Balance unavailable" in the UI and continue — this is a provider limitation, not a revocation | | 403 (empty body) | Enforcement failure — token valid and grant ACTIVE but enforcement filter rejected the request | Confirm `authorization_details[0].grant_id` was present in the token response and retry. If the app has `grant:self:query` registered, verify grant is ACTIVE via `GET /v1/grants/{grantId}`; if not, treat as a transient platform issue and retry | | 404 | Resource not found | Graceful not-found state; for DER, show "no solar/battery detected" | | 429 | Rate limited | Exponential backoff with visible loading state | Mid-session revocation: a 403 with `disclosure_grant_required` means the grant is gone. Abort all in-flight requests (don't let sibling calls run to completion — continuing to collect after revocation is a CDR breach), clear cached data **and** the stored `grantId`, then prompt the user to reconnect. A 403 with an empty body is a transient enforcement failure, not a revocation: retry rather than purging the session. A 403 with `balance_unavailable` is a **provider limitation** — it is **not** an auth or grant failure, and the session must not be purged. Degrade to "Balance unavailable" for that account and continue. **Balance endpoints are live, per-account calls — treat them as non-fatal.** Unlike the batch data endpoints, `GET /v1/banking/accounts/{id}/balance` and the energy equivalent hit the provider on-demand for each account and can fail independently of everything else. Fetch balances per-account, catch failures individually, and fall back to any balance already available on the batch account object; if none is available, render an explicit "balance unavailable" state and keep the rest of the view working. Never let a single balance failure fail the whole data fetch. Apply the same defensive principle as optional fields: the balance endpoints are fragile by nature (live, per-account, provider-dependent) — build so that absence or failure degrades gracefully rather than blocking the user. ### 7. Unattended (M2M) access Apps that need to query the Sharing API with no live user session (e.g. scheduled jobs) use the standard `client_credentials` grant with the `grant_id` of an already-established disclosure grant. Confidential clients only — public clients cannot use this grant. **Important:** `client_credentials` tokens are issued **without DPoP** and must be presented as `Authorization: Bearer `. Sending a `DPoP` proof header with a `client_credentials` token returns `401`. This is unlike the Recommended (user-facing) profile where DPoP is required. The M2M scopes are a separate family from the user-facing ones: request `openfeed-au:app:all:read` for `GET /v1/app` and `openfeed-au:grant:all:list` for `GET /v1/app/grants`. Both must be registered on the app, and neither requires a `grant_id`. ### 8. Developer metering and credits - 1 credit is debited per active grant at creation and on each monthly anniversary - Credits must be maintained or the grant transitions to SUSPENDED state - SUSPENDED grants return 402 on all Sharing API data calls - Topping up credits automatically recovers suspended grants - First 10 credits are free; see https://openfeed.au/pricing for the full model --- ## Scopes reference | Scope | Purpose | Required for | |---|---|---| | `openid` | OIDC login | Issued automatically; always present | | `openfeed-au:data:banking:read` | Read banking accounts, balances and transactions | Any app accessing banking data | | `openfeed-au:data:energy:read` | Read energy accounts, balances, meters, usage, DER, billing, invoices | Any app accessing energy data | | `openfeed-au:grant:self:query` | Call `GET /v1/grants/{grantId}` (GM API — detailed grant info) | Only if the app calls this endpoint directly | | `openfeed-au:grant:self:revoke` | Call `DELETE /v1/grants/{grantId}` (GM API — revoke a grant) | Only if the app drives revocation via the GM API | | `openfeed-au:app:all:read` | Call `GET /v1/app` (app self-info) | **`client_credentials` only** — not issued to user-facing authorization-code tokens | | `openfeed-au:grant:all:list` | Call `GET /v1/app/grants` (lightweight grant index) | **`client_credentials` only** — required for the M2M polling pattern in §7 | **Two scope families, two grant types.** `openfeed-au:app:all:read` and `openfeed-au:grant:all:list` are issued **only** on the `client_credentials` grant and require no `grant_id`; the `data:*` and `grant:self:*` scopes are issued on the user-facing authorization-code grant. Requesting an `all:` scope in a PAR, or a `self:` scope in a `client_credentials` request, will not be honoured. Register whichever families the app needs — if a scope is not registered, the auth server will not issue it regardless of what the request asks for. **Note:** `openfeed-au:grant:self:query` and `openfeed-au:grant:self:revoke` are only needed if the app calls the Grant Management API endpoints directly. They are **not** required to display accounts, establish a grant, or use the consumer-frontend disclosure deep-link for revocation. **Application registration is required before scopes are issued.** New apps default to either `openfeed-au:data:banking:read` and/or `openfeed-au:data:energy:read` depending on use case. Grant scopes are currently **not** assigned on registration by default — if you request them in PAR, the auth server returns `invalid_scope`. ## Capability → endpoints map Use this table to translate a plain-English app intention into the required endpoint set. Read the app description; tick every capability it implies; integrate all matched endpoints. For field names, types, and enums on each endpoint, generate types from `openapi.yaml`. | Capability / intent phrase | Endpoints required | |---|---| | Show bank account balances / account list | `GET /v1/banking/accounts`, `GET /v1/banking/accounts/{id}` | | Show institution / data holder name | `providerName` field on `BankingAccount` / `EnergyAccount` (from `ProviderRef` — always present; display this as the bank/retailer label) | | Show live / real-time account balance | `GET /v1/banking/accounts/{id}/balance` | | Show transactions / spending / cashflow / categorisation | `GET /v1/banking/accounts/{id}/transactions` | | Show energy account / provider | `GET /v1/energy/accounts`, `GET /v1/energy/accounts/{id}` | | Show live energy account balance | `GET /v1/energy/accounts/{id}/balance` | | Show meter details / usage data / consumption | `GET /v1/energy/accounts/{id}/meters`, `/{meterId}`, `/{meterId}/usage` | | Show solar / battery / DER / renewable / off-grid | `GET /v1/energy/accounts/{id}/meters/{meterId}/der` | | Show bills / billing breakdown / charges | `GET /v1/energy/accounts/{id}/billing` | | Show invoices / due dates / payment status | `GET /v1/energy/accounts/{id}/invoices` | | Cross-reference energy bills with bank transactions | Banking transactions + `/v1/energy/accounts/{id}/invoices` + `/billing` | | Loan serviceability / expense analysis | Banking accounts + 12mo transactions (all MCCs) + energy invoices | | Energy comparison / switch advisor | Energy accounts + meters + interval usage + invoices | | Connect / consent UI (default) | Consent deep-link to openfeed consumer dashboard; on return, refresh token to obtain grant-bound access token | | Disconnect UI (default) | Direct user to openfeed consumer dashboard to revoke; handle incoming 403 `disclosure_grant_required` to detect revocation | | Disconnect UI (optional) | `DELETE /v1/grants/{id}` (requires `grant:self:revoke` — only if the app drives revocation directly rather than via the consumer dashboard) | | Grant status / account list from grant detail (optional) | `GET /v1/grants/{id}` (requires `grant:self:query` — only if the app calls this endpoint directly) | | Show app info / connected app name | `GET /v1/app` (requires `app:all:read` via `client_credentials`) | | Any app (always required) | Discovery: `GET https://auth.openfeed.au/.well-known/openid-configuration` | --- ## Build non-negotiables (agent checklist — verify before presenting code) **Auth & tokens** - [ ] All token handling, keypair material, and OAuth flows are server-side only. Nothing auth-related touches the browser or is stored in localStorage. - [ ] Session cookie is **encrypted or signed** (not plaintext), `httpOnly`, `secure`, `sameSite: 'lax'`, with an explicit `maxAge`. A plaintext `httpOnly` cookie is user-editable — a tampered `grantId` can reach the GM API. Decrypt failure ⇒ no session. - [ ] `SESSION_SECRET` (or equivalent) is set and used to key the session cookie. - [ ] Ephemeral flow state (`codeVerifier`, `state`, `nonce`) lives in a separate short-lived cookie and is cleared in the callback — not stored for the session lifetime. - [ ] `.env.local` is gitignored before any private key is written to it; private keys are never committed, printed, or echoed back to the user. Only the public JWKS is ever shown. - [ ] Two separate PS256/RSA keypairs: app keypair (public key registered in dashboard) + DPoP keypair (private key server-side only, never in JWKS or dashboard). - [ ] `client_assertion` JWT claims: `iss=`, `sub=`, `aud=https://auth.openfeed.au`, `jti=`, `iat=`, `exp=`. `client_id` throughout is the `app-` prefixed `oauth2ClientId`, not the bare UUID `id`. Using the bare UUID or an endpoint URL as `aud` returns `invalid_client`. - [ ] A fresh `jti` is generated per assertion and the assertion is never cached or reused. - [ ] `resource=https://api.openfeed.au` is present in every PAR body, every code exchange body, and every refresh token body. (Omitting it from any one of these three calls causes 401 on every subsequent data call.) - [ ] All auth endpoints resolved from `https://auth.openfeed.au/.well-known/openid-configuration`; no hard-coded endpoint URLs. - [ ] DPoP proof JOSE header contains `{ alg: "PS256", typ: "dpop+jwt", jwk: }`. Missing `jwk` returns 401. - [ ] `400 use_dpop_nonce` + `DPoP-Nonce` header triggers exactly one retry with the nonce echoed in the proof. - [ ] If using `jwksUri` (auto-rotation): endpoint is publicly reachable HTTPS (not localhost). If using inline JWKS JSON: no public endpoint needed. - [ ] Token refresh implemented using `grant_type=refresh_token` with a fresh DPoP proof; no re-login on every page load. - [ ] If `id_token` is used for identity: signature verified against `jwks_uri`, `iss`/`aud`/`exp` checked, and `nonce` compared to the stored value. Never trust an unverified `id_token`. **Authorization / PAR / grant flow** - [ ] Every authorization starts with a PAR POST; redirect uses only `client_id` + `request_uri`. (A plain redirect without PAR is rejected.) - [ ] Callback validates `state` (CSRF) **and** `iss` (RFC 9207 mix-up defence) before the token exchange. - [ ] After token exchange, check `authorization_details[0].grant_id` in the token response body. If absent, redirect to the consent deep-link (§4 Phase A). Do not call data endpoints without a `grant_id`. - [ ] Post-consent: use the existing `refresh_token` to mint a new access token — the new token carries `grant_id` automatically. No second PAR needed. - [ ] After the refresh, confirm `grant_id` is present **and equals the `grantId` returned by the consent deep-link** before proceeding. A mismatch means the token is bound to a different grant. - [ ] `redirect_uri` values are carried in PAR; they are not pre-registered in the dashboard. - [ ] "Edit what I share" triggers a new PAR with `grant_management_action=replace` + `grant_id`. - [ ] All user-facing API calls use `Authorization: DPoP ` + `DPoP: `. Bearer is used only for `client_credentials` (M2M) tokens, which must NOT carry a DPoP proof. - [ ] Grant revocation: direct the user to the openfeed consumer dashboard (default) **or** wire `DELETE /v1/grants/{grantId}` directly (optional — requires `grant:self:revoke`). **Data fetching** - [ ] `GET /v1/app/grants` (M2M, requires `grant:all:list` via `client_credentials`) polled to detect grant changes; use `authorization_details[0].grant_id` from the token response to confirm the grant is active. Handle: 404 → trigger new grant flow. 402 → show "data access paused". 403 → reconnect modal. Optional, if `grant:self:query` is assigned, `GET /v1/grants/{grantId}` can fetch current grant detail - [ ] Every response unwrapped from `{ version: "V1", data, meta?, links? }`. (`version` is always present; don't skip it.) - [ ] All paginated endpoints loop until `links.next` is absent — its absence signals the last page. - [ ] All 5 error codes handled explicitly: 401, 402, 403, 404, 429. (402 = grant suspended/credit exhausted — surface it distinctly, not as a generic error.) - [ ] The two 403 shapes are told apart: `disclosure_grant_required` ⇒ grant is gone, purge and prompt reconnect; `balance_unavailable` ⇒ provider limitation on a balance endpoint, degrade gracefully and do NOT purge the session; empty body ⇒ transient enforcement failure, safe to retry. - [ ] On a revocation 403, **all in-flight sibling requests are aborted** and cached/session grant state is cleared. Continuing to collect or display data after revocation is a CDR breach. - [ ] Batch data freshness badges: banking = 4h, energy = 6h. Balance endpoints = live (~15min). (Show "live" badge for balance, "last updated ~Xh ago" for batch.) - [ ] No "refresh now" button that implies live data for batch endpoints. **Stack posture** - Server-side route handlers for all API calls and token exchange. Client components for UI only. - Tokens in an encrypted `httpOnly` cookie or server-side session; no secrets, keypairs, or tokens in any file served to the browser. - Redirect base uses the app's canonical public URL env var, not `request.url` — behind a proxy `request.url` reflects the internal URL, not the public one. **Removed endpoints** (return 404 — use the replacements below): - `GET /v1/agreements/disclosure` → `GET /v1/grants/{grantId}` (requires `grant:self:query`) - `DELETE /v1/agreements/disclosure/{id}` / `POST /v1/consent-edit-link` → `DELETE /v1/grants/{grantId}` - `/v1/energy/electricity/servicepoints/*` → `/v1/energy/accounts/{accountId}/meters/{meterId}` --- ## Integration bootstrap *Use this sequence when building a new integration from scratch. If a registered `client_id` is already in scope, skip to the section that matches your starting point.* **Recommended stack:** Next.js (App Router) or any Node.js server framework that supports server-side route handlers, `httpOnly` cookies, and a `/.well-known/jwks.json` route if you choose the `jwksUri` registration path. All token handling must be server-side. ### Default build lifecycle (self-driving — one human step, at the end) The default path for a new integration is continuous — the agent drives the full build and verifies it before pausing for the human. Do not interrupt the flow to ask whether to proceed. **Build everything, verify it working, then ask the human to register the app** — that is the only step the agent cannot perform. State that you are proceeding at each stage rather than asking for permission. **Phase 1 — Build and verify in mock mode (no user input needed)** Generate all of the following without asking the user for anything: - **App keypair (PS256/RSA):** private key (server secret) + public JWKS JSON `{"keys":[...]}` for dashboard registration. - **DPoP keypair (PS256/RSA):** private key (separate server secret). Never in the JWKS. - **App scaffold:** full project structure with server-side route handlers for `/auth/start`, `/auth/callback`, `/auth/consent-return`, and `/api/accounts`; encrypted `httpOnly` cookie session; `/.well-known/jwks.json` route (optional — only needed if the user later wants `jwksUri` auto-rotation). - **Mock mode:** wire a mock data path controlled by an env flag (e.g. `NEXT_PUBLIC_OPENFEED_MOCK=true`) so the app renders realistic data without real credentials. This is the default state for local development. - **`.env.local`:** write it now with all values the app needs — generated keypairs, `SESSION_SECRET`, `APP_PUBLIC_URL`, the mock flag set to `true`, and **placeholder comments** for `OPENFEED_CLIENT_ID` and `OPENFEED_APP_ID` that will be filled after registration. - **Types:** run `npx openapi-typescript https://openfeed.au/openapi.yaml -o lib/openfeed-types.ts` and use the generated types for all data-handling code. Build and start the dev server. Verify the app is working end-to-end in mock mode (renders data, no console errors, clean build). Only proceed to Phase 2 once mock mode is verified. **Phase 2 — Wire real credentials (no user input needed)** Once mock mode is verified, automatically continue — do not wait for the user to say "go live" or ask for permission to proceed. State: *"Mock mode is verified. Wiring real openfeed credentials against the local dev server."* Then: - Switch `NEXT_PUBLIC_OPENFEED_MOCK=false` (or equivalent) in `.env.local`. - Confirm all live-mode code paths (auth routes, token handling, data fetching) are present and complete. They should already exist from Phase 1 — if any are missing, generate them now. - Restart the dev server and confirm it starts cleanly. At this point the app is fully built and ready to connect to real openfeed data. The only missing pieces are `OPENFEED_CLIENT_ID` and `OPENFEED_APP_ID`, which require app registration. **Phase 3 — App registration (the one human step)** Now pause and present the user with everything they need to register. This is a small, bounded task that takes about two minutes and yields only two values: > Go to `https://app.openfeed.au/registered-apps/new` and register your app with these values: > > - **App name** and **description** (your choice) > - **Auth method:** `private_key_jwt` > - **App keypair public key:** paste this JWKS JSON into the inline field: > `` > (No tunnel or hosted endpoint needed — the dashboard stores your key material directly. > If you later want automatic key rotation, you can switch to a `jwksUri` URL in settings.) > - **Requested scopes:** the data scopes your app uses (see Scope derivation below) — > `openfeed-au:data:banking:read` and/or `openfeed-au:data:energy:read`. > Add `openfeed-au:grant:self:query`, `openfeed-au:grant:self:revoke` only if the app calls > the Grant Management API directly. Add `openfeed-au:app:all:read` and > `openfeed-au:grant:all:list` only if the app runs unattended (M2M) jobs. > The auth server will not issue a scope that is not registered — request only what you need. > - **Post-logout redirect URIs:** `http://localhost:3000/` (note the trailing slash — exact-matched) > > On submit the dashboard shows two IDs — paste both back here: > - **OAuth2 Client ID** (field `oauth2ClientId`, `app-` prefixed) → `OPENFEED_CLIENT_ID` > - **App ID** (field `id`, bare UUID) → `OPENFEED_APP_ID` **Phase 4 — Complete and verify live (small config delta)** Once the user pastes the two IDs: 1. Set `OPENFEED_CLIENT_ID` and `OPENFEED_APP_ID` in `.env.local`. 2. Restart the dev server — it should start cleanly and the live auth flow is immediately usable. 3. Walk the user through the full consent flow: login → consent screen → data loads. The user should be able to complete the full grant flow and see real data with no further code changes — everything was already built and verified in Phases 1–2. **Private key hygiene — do this before writing any key material to disk:** - [ ] Confirm `.env.local` (and any `*.pem`) is listed in `.gitignore`. If the project has no `.gitignore`, create one containing `.env*.local` and `*.pem` **first**. Writing a private key into an un-ignored file and then running `git add -A` publishes the app signing key. - [ ] **Never commit, never print, and never echo either private key** — not into chat output, not into the README, not into an `.env.example`. Only the **public** JWKS JSON is ever shown to the user or pasted into the dashboard. - [ ] Anyone holding the app private key can mint valid `private_key_jwt` assertions and impersonate the app to `https://auth.openfeed.au` until the registered public key is replaced. Treat exposure as a full client compromise. - [ ] **Rotation and compromise response:** with inline JWKS the key is stored statically, so rotation is manual — generate a new keypair and replace the JWKS in the dashboard, which invalidates the old key immediately. With `jwksUri` the auth-server re-fetches on every PAR, so publishing the new key at the same URL rotates it transparently. Prefer `jwksUri` for production for this reason. On suspected compromise, rotate both keypairs — the app keypair and the DPoP keypair — since a leaked `.env.local` exposes both. PKCE `code_verifier` / `code_challenge`, `nonce`, `state` — ephemeral, generated per request. ### `.env.local` shape ``` NEXT_PUBLIC_OPENFEED_MOCK=true # true during Phase 1; set to false in Phase 2 OPENFEED_CLIENT_ID= # filled in Phase 4 after registration (oauth2ClientId) OPENFEED_APP_ID= # filled in Phase 4 after registration (bare UUID id) OPENFEED_APP_PRIVATE_KEY= # App keypair private key (generated in Phase 1) OPENFEED_DPOP_PRIVATE_KEY= # DPoP keypair private key (generated in Phase 1) SESSION_SECRET= # encrypts the session cookie (generated in Phase 1) APP_PUBLIC_URL=http://localhost:3000 # Canonical public URL — used for redirect_uri base ``` ### Session cookie requirements The session holds the refresh token and the `grantId`, so its contents are security-critical and **must not be readable or writable by the browser**. `httpOnly` alone is not sufficient — it stops JavaScript reading the cookie but does nothing to stop the cookie's owner *editing* it. - [ ] **Encrypt (or sign) the cookie payload.** A plaintext `httpOnly` cookie is attacker-editable: a user who changes `grantId` to another consumer's grant UUID can make the app call `GET`/`DELETE /v1/grants/{grantId}` with its own legitimately-scoped token, because `grant:self:*` is scoped to the **calling app**, not to the signed-in consumer. That reads or revokes a different person's grant — an unauthorised-disclosure event under CDR. Use an encrypted cookie (e.g. a JWE with `alg: dir`, `enc: A256GCM`, keyed from `SESSION_SECRET`) and treat any decrypt/verify failure as "no session" → 401. - [ ] `httpOnly: true`, `secure: true` (always in production; `sameSite: 'lax'` is required for the OAuth redirect to carry the cookie back — `'strict'` breaks the callback). - [ ] `path: '/'` and an explicit `maxAge`. Never leave the session unbounded. - [ ] **Split ephemeral flow state from the long-lived session.** `codeVerifier`, `state`, and `nonce` are single-round-trip values: put them in a separate short-lived cookie (~10 minutes) and clear it in the callback once consumed. Keeping them in the session means a `state` value and a `code_verifier` stay replayable for the whole session lifetime. ### Scope derivation (no confirmation needed) Derive scopes from the app description using the capability map. Rules: - Include `openfeed-au:data:banking:read` for any app that reads banking accounts, balances, or transactions. - Include `openfeed-au:data:energy:read` for any app that reads energy accounts, meters, usage, or billing. - Include `openfeed-au:grant:self:query` only if the app calls `GET /v1/grants/{grantId}` directly (e.g. to show grant status, account list from grant detail). - Include `openfeed-au:grant:self:revoke` only if the app drives revocation via `DELETE /v1/grants/{grantId}` directly (rather than directing users to the openfeed consumer dashboard). - Include `openfeed-au:app:all:read` and/or `openfeed-au:grant:all:list` only if the app runs unattended (M2M) work — `GET /v1/app` for self-info, `GET /v1/app/grants` to poll for grant changes (§7). These are `client_credentials`-only and are never issued to a user-facing token. State which scopes you chose and why in the generated README — do not ask for confirmation. --- ## End-to-end integration example **Read this first — choose your entry point:** > **Building a new app from scratch?** > Follow the Integration bootstrap (above) to generate keypairs, scaffold the full project, > and register. Then use this example as the implementation reference for each route. > > **Integrating openfeed into an existing app?** > Skip the bootstrap. Use the core pattern below directly — map each route to your framework's > equivalent and wire the env vars from your existing app registration. --- ### Core pattern (framework-agnostic) This is the canonical integration pattern. Every route must run server-side. #### Environment variables ``` OPENFEED_CLIENT_ID= OPENFEED_APP_ID= OPENFEED_APP_PRIVATE_KEY= OPENFEED_DPOP_PRIVATE_KEY= SESSION_SECRET= APP_PUBLIC_URL= ``` #### Route map ``` POST /auth/start → build PAR, redirect user to openfeed login GET /auth/callback → receive auth code, exchange for tokens, store in session GET /auth/consent-return → receive grantId from consent deep-link, refresh token, redirect GET /api/accounts → fetch banking + energy accounts (requires active grant) POST /auth/logout → revoke session, redirect to openfeed end_session_endpoint ``` #### /auth/start — push the authorization request and redirect ```javascript // 1. Fetch discovery document (cache this; don't fetch on every request) const discovery = await fetch('https://auth.openfeed.au/.well-known/openid-configuration') .then(r => r.json()); // 2. Generate PKCE const codeVerifier = base64url(crypto.randomBytes(32)); const codeChallenge = base64url(crypto.createHash('sha256').update(codeVerifier).digest()); // 3. Build client_assertion JWT // Claims: iss=client_id, sub=client_id, aud=issuer, jti=uuid, iat=now, exp=now+60 const clientAssertion = signJwt({ iss: process.env.OPENFEED_CLIENT_ID, sub: process.env.OPENFEED_CLIENT_ID, aud: 'https://auth.openfeed.au', jti: crypto.randomUUID(), iat: Math.floor(Date.now() / 1000), exp: Math.floor(Date.now() / 1000) + 60, }, process.env.OPENFEED_APP_PRIVATE_KEY, { algorithm: 'PS256' }); // 4. POST to PAR endpoint const state = crypto.randomUUID(); const nonce = crypto.randomUUID(); const parRes = await fetch(discovery.pushed_authorization_request_endpoint, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer', client_assertion: clientAssertion, response_type: 'code', client_id: process.env.OPENFEED_CLIENT_ID, redirect_uri: `${process.env.APP_PUBLIC_URL}/auth/callback`, scope: 'openid openfeed-au:data:banking:read openfeed-au:data:energy:read', resource: 'https://api.openfeed.au', // REQUIRED — binds token to sharing-api audience code_challenge: codeChallenge, code_challenge_method: 'S256', nonce, state, grant_management_action: 'create', // omit is equivalent; use 'replace'+grant_id to amend }), }).then(r => r.json()); // parRes = { request_uri: "urn:ietf:params:oauth:request_uri:...", expires_in: 60 } // 5. Store PKCE + state + nonce in the SHORT-LIVED flow cookie (~10 min), not the session. // These are single-round-trip values; the callback clears them once consumed. flowState.set({ codeVerifier, state, nonce }); // 6. Redirect user to authorization endpoint redirect(`${discovery.authorization_endpoint}?client_id=${process.env.OPENFEED_CLIENT_ID}&request_uri=${parRes.request_uri}`); ``` #### /auth/callback — exchange code for tokens ```javascript // 1. Validate state (CSRF) and iss (RFC 9207 mix-up defence) before anything else const { codeVerifier, state: storedState, nonce: storedNonce } = flowState.get(); if (!storedState || query.state !== storedState) throw new Error('state mismatch'); if (query.iss && query.iss !== discovery.issuer) throw new Error('issuer mismatch'); // Flow state is single-use — clear it now so state/codeVerifier cannot be replayed flowState.clear(); // 2. Build DPoP proof for token endpoint // JOSE header: { alg: "PS256", typ: "dpop+jwt", jwk: } // payload: { htu: , htm: "POST", iat: now, jti: uuid } const dpopProof = signDpopProof({ htu: discovery.token_endpoint, htm: 'POST', }, process.env.OPENFEED_DPOP_PRIVATE_KEY); // 3. Exchange code for tokens const tokenRes = await fetch(discovery.token_endpoint, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'DPoP': dpopProof, }, body: new URLSearchParams({ client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer', client_assertion: await buildClientAssertion(), // async — must be awaited grant_type: 'authorization_code', code: query.code, code_verifier: codeVerifier, redirect_uri: `${process.env.APP_PUBLIC_URL}/auth/callback`, resource: 'https://api.openfeed.au', }), }).then(r => r.json()); // tokenRes = { access_token, token_type: "DPoP", refresh_token, expires_in, id_token?, // authorization_details: [{ grant_id, ... }] } // 3b. If you requested the 'openid' scope and intend to use id_token as an identity // assertion, you MUST verify it before trusting any claim. An unverified id_token is // attacker-supplied data — trusting its 'sub' hands an attacker any account. if (tokenRes.id_token) { const jwks = createRemoteJWKSet(new URL(discovery.jwks_uri)); const { payload: idClaims } = await jwtVerify(tokenRes.id_token, jwks, { issuer: discovery.issuer, audience: process.env.OPENFEED_CLIENT_ID, // aud = your oauth2ClientId }); // throws on bad signature/iss/aud/exp if (idClaims.nonce !== storedNonce) throw new Error('nonce mismatch'); // replay defence // Link the local user on idClaims.sub — immutable. Never link on email. } // Access tokens are opaque strings — do NOT decode as JWT // grant_id is in the response body, not in the token const grantId = tokenRes.authorization_details?.[0]?.grant_id; // 4. Store tokens in the encrypted httpOnly session (never localStorage or client state) session.set({ accessToken: tokenRes.access_token, refreshToken: tokenRes.refresh_token, expiresAt: Date.now() + tokenRes.expires_in * 1000, grantId: grantId ?? null, }); // 5. If no grant yet, send user to disclosure consent deep-link if (!grantId) { redirect(`https://app.openfeed.au/grants/disclosure?appId=${process.env.OPENFEED_APP_ID}&redirectUri=${process.env.APP_PUBLIC_URL}/auth/consent-return`); // appId = bare UUID 'id' from registration — NOT the oauth2ClientId (app- prefixed) // client_id in OAuth requests = oauth2ClientId (app- prefixed); appId here = bare UUID; they are different } else { redirect('/'); } ``` #### /auth/consent-return — user returns from disclosure consent ```javascript // User was redirected back with ?grantId=&consented=true (or consented=false) if (query.consented !== 'true') { redirect('/?error=consent_declined'); return; } const returnedGrantId = query.grantId; // the grant the user just consented to // Grant now exists in openfeed. Refresh the access token — the new token will carry // grant_id automatically. No second PAR or re-authentication needed. const { refreshToken } = session.get(); const dpopProof = signDpopProof({ htu: discovery.token_endpoint, htm: 'POST' }, process.env.OPENFEED_DPOP_PRIVATE_KEY); const tokenRes = await fetch(discovery.token_endpoint, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'DPoP': dpopProof }, body: new URLSearchParams({ client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer', client_assertion: await buildClientAssertion(), // async — must be awaited grant_type: 'refresh_token', refresh_token: refreshToken, resource: 'https://api.openfeed.au', }), }).then(r => r.json()); const grantId = tokenRes.authorization_details?.[0]?.grant_id; if (!grantId) throw new Error('grant still absent after refresh — check app registration scopes'); // Bind the token to the grant the user actually consented to. If the app holds more than one // grant for this consumer, the refreshed token could carry a different (older) grant — reading // that grant's accounts would be collection outside the consent just given. if (returnedGrantId && grantId !== returnedGrantId) { throw new Error('grant_id mismatch — refreshed token is not bound to the new grant'); } session.set({ accessToken: tokenRes.access_token, refreshToken: tokenRes.refresh_token, expiresAt: Date.now() + tokenRes.expires_in * 1000, grantId, }); redirect('/'); ``` #### /api/accounts — fetch banking + energy accounts ```javascript async function callSharingApi(path, session, signal) { // Refresh access token if expired (or within 60s of expiry) if (Date.now() >= session.expiresAt - 60_000) { session = await refreshAccessToken(session); } // Build DPoP proof — htu must be the URL with query string and fragment stripped; // path segments with = (base64 padding) must NOT be percent-encoded (%3D causes 401) const url = `https://api.openfeed.au${path}`; const htu = url.split('?')[0].split('#')[0]; // strip query + fragment; keep = raw const dpopProof = signDpopProof({ htu, htm: 'GET', ath: base64url(crypto.createHash('sha256').update(session.accessToken).digest()), }, process.env.OPENFEED_DPOP_PRIVATE_KEY); const res = await fetch(url, { signal, // lets the caller abort sibling requests the moment a grant is revoked headers: { 'Authorization': `DPoP ${session.accessToken}`, 'DPoP': dpopProof, }, }); if (res.status === 401) throw { code: 'token_expired' }; if (res.status === 402) throw { code: 'credit_exhausted' }; // grant suspended — surface distinctly if (res.status === 403) { // Two distinct 403s — see §6. Only 'disclosure_grant_required' means the grant is gone. // 'balance_unavailable' is a provider limitation — do NOT treat as grant failure. const body = await res.json().catch(() => null); if (body?.code === 'disclosure_grant_required') throw { code: 'grant_revoked' }; if (body?.code === 'balance_unavailable') throw { code: 'balance_unavailable' }; throw { code: 'enforcement_failure' }; // token + grant valid; likely transient } if (res.status === 404) return null; if (res.status === 429) throw { code: 'rate_limited' }; if (!res.ok) throw new Error(`sharing-api error ${res.status}`); return res.json(); // { version: "V1", data: [...], meta?, links? } } // Paginate through all pages until links.next is absent async function fetchAllPages(path, session, signal) { const results = []; let url = path; while (url) { const page = await callSharingApi(url, session, signal); if (!page) break; results.push(...(Array.isArray(page.data) ? page.data : [page.data])); // links.next is absent on the last page — its absence (not a null value) signals the end url = page.links?.next ? new URL(page.links.next).pathname + new URL(page.links.next).search : null; } return results; } // Route handler export async function GET(req, res) { const session = getSession(req); if (!session?.grantId) return res.status(401).json({ error: 'not_authenticated' }); // Abort siblings as soon as any leg reports the grant is gone — continuing to collect // data after revocation is a CDR breach, not just wasted work. const controller = new AbortController(); try { const [bankingAccounts, energyAccounts] = await Promise.all([ fetchAllPages('/v1/banking/accounts', session, controller.signal), fetchAllPages('/v1/energy/accounts', session, controller.signal), ]); // Timestamps for freshness badges: banking batch = 4h, energy batch = 6h // Balance endpoints (/balance) are live (~15min cache) — show "live" badge, not batch age return res.json({ bankingAccounts, energyAccounts }); } catch (err) { if (err.code === 'grant_revoked') { controller.abort(); // stop all in-flight data collection immediately clearSession(res); // drop the stale grantId and tokens return res.status(403).json({ error: 'reconnect_required' }); } if (err.code === 'token_expired') return res.status(401).json({ error: 'session_expired' }); if (err.code === 'credit_exhausted') return res.status(402).json({ error: 'data_access_paused' }); if (err.code === 'enforcement_failure') return res.status(503).json({ error: 'try_again' }); if (err.code === 'rate_limited') return res.status(429).json({ error: 'rate_limited' }); throw err; } } ``` --- ### Next.js (App Router) translation Same protocol, mapped to Next.js conventions. All route handlers are in `app/`. #### File structure ``` app/ auth/ start/route.ts → POST handler: build PAR, return redirect response callback/route.ts → GET handler: exchange code, set httpOnly session cookie consent-return/route.ts → GET handler: refresh token after consent deep-link logout/route.ts → POST handler: clear session, redirect to end_session_endpoint api/ accounts/route.ts → GET handler: fetch banking + energy accounts page.tsx → client component: fetch /api/accounts, render account list lib/ openfeed.ts → shared: discovery cache, DPoP proof builder, token refresh session.ts → shared: encrypted httpOnly cookie session helpers openfeed-types.ts → generated: npx openapi-typescript https://openfeed.au/openapi.yaml -o lib/openfeed-types.ts .env.local OPENFEED_CLIENT_ID= OPENFEED_APP_ID= OPENFEED_APP_PRIVATE_KEY= OPENFEED_DPOP_PRIVATE_KEY= SESSION_SECRET= APP_PUBLIC_URL=http://localhost:3000 ``` Every variable above is read by the routes below — `OPENFEED_APP_ID` in particular is required by `app/auth/callback/route.ts` to build the consent deep-link. Omitting it produces `appId=undefined` on the first login, which the disclosure endpoint rejects (it parses `appId` as a UUID). #### app/auth/start/route.ts ```typescript import { NextResponse } from 'next/server'; import { getDiscovery, buildClientAssertion } from '@/lib/openfeed'; import { setFlowState } from '@/lib/session'; import crypto from 'node:crypto'; export async function POST() { const discovery = await getDiscovery(); const codeVerifier = crypto.randomBytes(32).toString('base64url'); const codeChallenge = crypto.createHash('sha256').update(codeVerifier).digest('base64url'); const state = crypto.randomUUID(); const nonce = crypto.randomUUID(); const parRes = await fetch(discovery.pushed_authorization_request_endpoint, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer', client_assertion: await buildClientAssertion(), response_type: 'code', client_id: process.env.OPENFEED_CLIENT_ID!, redirect_uri: `${process.env.APP_PUBLIC_URL}/auth/callback`, scope: 'openid openfeed-au:data:banking:read openfeed-au:data:energy:read', resource: 'https://api.openfeed.au', code_challenge: codeChallenge, code_challenge_method: 'S256', nonce, state, }), }).then(r => r.json()); const res = NextResponse.redirect( `${discovery.authorization_endpoint}?client_id=${process.env.OPENFEED_CLIENT_ID}&request_uri=${parRes.request_uri}` ); // Short-lived flow cookie (~10 min) — NOT the long-lived session await setFlowState(res, { codeVerifier, state, nonce }); return res; } ``` #### app/auth/callback/route.ts ```typescript import { NextRequest, NextResponse } from 'next/server'; import { getDiscovery, buildClientAssertion, buildDpopProof } from '@/lib/openfeed'; import { getFlowState, clearFlowState, setSession } from '@/lib/session'; import { createRemoteJWKSet, jwtVerify } from 'jose'; export async function GET(req: NextRequest) { const { searchParams } = new URL(req.url); const code = searchParams.get('code')!; const returnedState = searchParams.get('state')!; const returnedIss = searchParams.get('iss'); const discovery = await getDiscovery(); const flow = await getFlowState(req); if (!flow?.state || returnedState !== flow.state) { return NextResponse.redirect(`${process.env.APP_PUBLIC_URL}/?error=state_mismatch`); } // RFC 9207 issuer validation — mix-up defence (required; see §3c) if (returnedIss && returnedIss !== discovery.issuer) { return NextResponse.redirect(`${process.env.APP_PUBLIC_URL}/?error=issuer_mismatch`); } const dpopProof = await buildDpopProof({ htu: discovery.token_endpoint, htm: 'POST' }); const tokenRes = await fetch(discovery.token_endpoint, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'DPoP': dpopProof }, body: new URLSearchParams({ client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer', client_assertion: await buildClientAssertion(), grant_type: 'authorization_code', code, code_verifier: flow.codeVerifier, redirect_uri: `${process.env.APP_PUBLIC_URL}/auth/callback`, resource: 'https://api.openfeed.au', }), }).then(r => r.json()); // Verify id_token before trusting ANY claim from it (only if you use it for identity). if (tokenRes.id_token) { const jwks = createRemoteJWKSet(new URL(discovery.jwks_uri)); const { payload: idClaims } = await jwtVerify(tokenRes.id_token, jwks, { issuer: discovery.issuer, audience: process.env.OPENFEED_CLIENT_ID!, }); if (idClaims.nonce !== flow.nonce) { return NextResponse.redirect(`${process.env.APP_PUBLIC_URL}/?error=nonce_mismatch`); } // Link the local user on idClaims.sub (immutable) — never on email. } // Access tokens are opaque — never decode as JWT // grant_id is in the response body, not inside the token const grantId = tokenRes.authorization_details?.[0]?.grant_id ?? null; const res = NextResponse.redirect( grantId ? `${process.env.APP_PUBLIC_URL}/` : `https://app.openfeed.au/grants/disclosure?appId=${process.env.OPENFEED_APP_ID}&redirectUri=${process.env.APP_PUBLIC_URL}/auth/consent-return` // appId = OPENFEED_APP_ID (bare UUID 'id'), NOT OPENFEED_CLIENT_ID (app- prefixed oauth2ClientId) ); clearFlowState(res); // single-use: state/codeVerifier/nonce must not outlive the callback await setSession(res, { accessToken: tokenRes.access_token, refreshToken: tokenRes.refresh_token, expiresAt: Date.now() + tokenRes.expires_in * 1000, grantId, }); return res; } ``` #### app/auth/consent-return/route.ts ```typescript import { NextRequest, NextResponse } from 'next/server'; import { getDiscovery, buildClientAssertion, buildDpopProof } from '@/lib/openfeed'; import { getSession, setSession } from '@/lib/session'; export async function GET(req: NextRequest) { const { searchParams } = new URL(req.url); if (searchParams.get('consented') !== 'true') { return NextResponse.redirect(`${process.env.APP_PUBLIC_URL}/?error=consent_declined`); } const returnedGrantId = searchParams.get('grantId'); const session = await getSession(req); const discovery = await getDiscovery(); const dpopProof = await buildDpopProof({ htu: discovery.token_endpoint, htm: 'POST' }); // Use existing refresh_token — the new access token carries grant_id automatically const tokenRes = await fetch(discovery.token_endpoint, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'DPoP': dpopProof }, body: new URLSearchParams({ client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer', client_assertion: await buildClientAssertion(), grant_type: 'refresh_token', refresh_token: session.refreshToken, resource: 'https://api.openfeed.au', }), }).then(r => r.json()); const grantId = tokenRes.authorization_details?.[0]?.grant_id ?? null; // Fail loudly and identically to the canonical example — do NOT persist grantId: null // and redirect as if consent succeeded. if (!grantId) { return NextResponse.redirect(`${process.env.APP_PUBLIC_URL}/?error=grant_not_bound`); } // Bind to the grant the user actually consented to (see canonical example). if (returnedGrantId && grantId !== returnedGrantId) { return NextResponse.redirect(`${process.env.APP_PUBLIC_URL}/?error=grant_id_mismatch`); } const res = NextResponse.redirect(`${process.env.APP_PUBLIC_URL}/`); await setSession(res, { accessToken: tokenRes.access_token, refreshToken: tokenRes.refresh_token, expiresAt: Date.now() + tokenRes.expires_in * 1000, grantId, }); return res; } ``` #### app/api/accounts/route.ts ```typescript import { NextRequest, NextResponse } from 'next/server'; import { fetchAllPages } from '@/lib/openfeed'; import { getSession, clearSession } from '@/lib/session'; export async function GET(req: NextRequest) { const session = await getSession(req); if (!session?.grantId) return NextResponse.json({ error: 'not_authenticated' }, { status: 401 }); // Abort siblings the moment any leg reports revocation — continuing to collect after // revocation is a CDR breach. const controller = new AbortController(); try { const [bankingAccounts, energyAccounts] = await Promise.all([ fetchAllPages('/v1/banking/accounts', session, controller.signal), fetchAllPages('/v1/energy/accounts', session, controller.signal), ]); return NextResponse.json({ bankingAccounts, energyAccounts }); } catch (err: any) { if (err.code === 'grant_revoked') { controller.abort(); const res = NextResponse.json({ error: 'reconnect_required' }, { status: 403 }); clearSession(res); // drop the stale grantId and tokens return res; } const status = { token_expired: 401, credit_exhausted: 402, enforcement_failure: 503, rate_limited: 429, }[err.code as string] ?? 500; return NextResponse.json({ error: err.code ?? 'internal_error' }, { status }); } } ``` #### lib/openfeed.ts (shared helpers) ```typescript import crypto from 'node:crypto'; import { SignJWT, importPKCS8, exportJWK } from 'jose'; /** Session shape shared by the route handlers and lib/session.ts. */ export interface Session { accessToken: string; refreshToken: string; expiresAt: number; grantId: string | null; } let _discovery: Record | null = null; export async function getDiscovery() { if (_discovery) return _discovery; _discovery = await fetch('https://auth.openfeed.au/.well-known/openid-configuration').then(r => r.json()); return _discovery!; } export async function buildClientAssertion(): Promise { // Sign a JWT with the app keypair (PS256). // NOTE: generate a FRESH jti on every call and never cache or reuse the returned // assertion — jti is the server's replay identifier. Unlike getDiscovery() above, // this must NOT be memoised. const clientId = process.env.OPENFEED_CLIENT_ID!; const now = Math.floor(Date.now() / 1000); const key = await importPKCS8(process.env.OPENFEED_APP_PRIVATE_KEY!, 'PS256'); return new SignJWT({ jti: crypto.randomUUID() }) .setProtectedHeader({ alg: 'PS256' }) .setIssuer(clientId) // iss = oauth2ClientId .setSubject(clientId) // sub = oauth2ClientId .setAudience('https://auth.openfeed.au') // aud = issuer, NOT the endpoint URL .setIssuedAt(now) .setExpirationTime(now + 60) .sign(key); } export async function buildDpopProof(opts: { htu: string; htm: string; ath?: string; nonce?: string; }): Promise { // JOSE header: { alg: "PS256", typ: "dpop+jwt", jwk: } // payload: { htu, htm, ath?, nonce?, iat, jti } // htu must have query string and fragment stripped; path = signs must NOT be percent-encoded const htu = opts.htu.split('?')[0].split('#')[0]; const key = await importPKCS8(process.env.OPENFEED_DPOP_PRIVATE_KEY!, 'PS256'); const publicJwk = await exportJWK(crypto.createPublicKey(process.env.OPENFEED_DPOP_PRIVATE_KEY!)); return new SignJWT({ htu, htm: opts.htm, jti: crypto.randomUUID(), ...(opts.ath ? { ath: opts.ath } : {}), ...(opts.nonce ? { nonce: opts.nonce } : {}), }) .setProtectedHeader({ alg: 'PS256', typ: 'dpop+jwt', jwk: publicJwk }) .setIssuedAt() .sign(key); } export async function refreshSession(session: Session): Promise { const discovery = await getDiscovery(); const dpopProof = await buildDpopProof({ htu: discovery.token_endpoint, htm: 'POST' }); const tokenRes = await fetch(discovery.token_endpoint, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'DPoP': dpopProof }, body: new URLSearchParams({ client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer', client_assertion: await buildClientAssertion(), grant_type: 'refresh_token', refresh_token: session.refreshToken, resource: 'https://api.openfeed.au', }), }).then(r => r.json()); if (!tokenRes.access_token) throw { code: 'token_expired' }; return { accessToken: tokenRes.access_token, refreshToken: tokenRes.refresh_token, expiresAt: Date.now() + tokenRes.expires_in * 1000, grantId: tokenRes.authorization_details?.[0]?.grant_id ?? session.grantId, }; } export async function callSharingApi(path: string, session: Session, signal?: AbortSignal) { if (Date.now() >= session.expiresAt - 60_000) session = await refreshSession(session); const url = `https://api.openfeed.au${path}`; const htu = url.split('?')[0].split('#')[0]; const ath = crypto.createHash('sha256').update(session.accessToken).digest('base64url'); // The server may demand a DPoP nonce at any time (RFC 9449 §8): it answers // 400 use_dpop_nonce with a DPoP-Nonce header. Retry once, echoing that nonce. const send = async (nonce?: string) => { const dpopProof = await buildDpopProof({ htu, htm: 'GET', ath, nonce }); return fetch(url, { signal, headers: { 'Authorization': `DPoP ${session.accessToken}`, 'DPoP': dpopProof }, }); }; let res = await send(); if (res.status === 400 && res.headers.get('DPoP-Nonce')) { res = await send(res.headers.get('DPoP-Nonce')!); } if (res.status === 401) throw { code: 'token_expired' }; if (res.status === 402) throw { code: 'credit_exhausted' }; if (res.status === 403) { const body = await res.json().catch(() => null); if (body?.code === 'disclosure_grant_required') throw { code: 'grant_revoked' }; if (body?.code === 'balance_unavailable') throw { code: 'balance_unavailable' }; throw { code: 'enforcement_failure' }; } if (res.status === 404) return null; if (res.status === 429) throw { code: 'rate_limited' }; if (!res.ok) throw new Error(`sharing-api ${res.status}`); return res.json(); // { version: "V1", data, meta?, links? } } export async function fetchAllPages(path: string, session: Session, signal?: AbortSignal) { const results: unknown[] = []; let url: string | null = path; while (url) { const page = await callSharingApi(url, session, signal); if (!page) break; results.push(...(Array.isArray(page.data) ? page.data : [page.data])); // links.next absent = last page; its absence (not null) is the signal url = page.links?.next ? new URL(page.links.next).pathname + new URL(page.links.next).search : null; } return results; } ``` #### lib/session.ts (contract) Two cookies with different lifetimes — see "Session cookie requirements" above. Both payloads are encrypted with `SESSION_SECRET`; a decrypt failure must be treated as "no session", never as an empty-but-valid session. **Next.js App Router cookie caveat — redirect responses:** When a route handler returns a `NextResponse.redirect()`, calling `session.save()` from libraries like `iron-session` (which write via `next/headers` cookies internally) silently drops the `Set-Cookie` header — the cookie is never sent to the browser. Every `/auth/callback → /` redirect will arrive with no session. Use `res.cookies.set()` **directly on the `NextResponse` object** instead. To use iron-session's encryption with a redirect response, seal the payload manually and attach it: ```typescript import { sealData } from 'iron-session'; const seal = await sealData(sessionPayload, { password: process.env.SESSION_SECRET!, ttl: 60 * 60 * 24 * 30, // 30 days }); const res = NextResponse.redirect(targetUrl); res.cookies.set('session', seal, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', path: '/', maxAge: 60 * 60 * 24 * 30, }); return res; ``` Do **not** call `session.save()` or write to the `cookies()` store from `next/headers` in any route handler that also returns a redirect — the `Set-Cookie` header will be silently omitted. ```typescript import type { Session } from './openfeed'; /** Long-lived (e.g. 30 days): accessToken, refreshToken, expiresAt, grantId. */ export function setSession(res: Response, s: Session): Promise; export function getSession(req: Request): Promise; export function clearSession(res: Response): void; /** Short-lived (~10 min), single-use: consumed and cleared by /auth/callback. */ export interface FlowState { codeVerifier: string; state: string; nonce: string } export function setFlowState(res: Response, f: FlowState): Promise; export function getFlowState(req: Request): Promise; export function clearFlowState(res: Response): void; ``` - OpenAPI 3.1 spec (v4.0.0): https://openfeed.au/openapi.yaml - API reference (interactive): https://openfeed.au/developers/api - Quickstart: https://openfeed.au/resources/quickstart - Pricing: https://openfeed.au/pricing - Concise index: https://openfeed.au/llms.txt # Knowledge base --- ## Resource: Frequently asked questions Answers to the questions consumers and developers ask most about openfeed, the Consumer Data Right, sharing, security and getting started. Short answers to the things people ask most. Can't find what you're after? Every topic below links to a deeper guide. ## About openfeed ### What is openfeed? openfeed is a consumer data platform built on Australia's Consumer Data Right (CDR). You connect a data holder, like a bank or energy retailer, once, then share that data with the apps you choose, in a single tap, and revoke it any time. Developers get one REST API for every holder instead of building a separate integration for each one. ### Who is behind openfeed? openfeed is built by Biza Pty Ltd, one of the largest contributors to the build of Australia's CDR ecosystem. See [Introducing openfeed](/resources/introducing-openfeed) and [About](/about) for the full story. ## For consumers ### Is my data safe with openfeed? Yes. openfeed operates within the CDR's accredited framework. Every share is limited to the accounts you approve and revocable at any time. You decide what is shared and with whom. Read [How openfeed keeps your data safe](/resources/how-openfeed-keeps-your-data-safe) for the plain-English version. ### What can I share? Today you can share banking and energy data. You choose exactly which accounts and which data types are included in each share. See [How onward sharing works](/resources/how-onward-sharing-works). ### How do I stop sharing? From your openfeed dashboard you can revoke any apps access at any time. Revocation is immediate. The app loses access straight away. ### Does it cost anything? No. Connecting holders, managing shares and using "Login with openfeed" are free for consumers. ## For developers ### How do I get started? Follow the [Quickstart](/resources/quickstart): register an app, add "Login with openfeed" using a standard OIDC client library, request disclosure consent, and fetch data. Most teams have live data flowing the same day. ### What is the two-consent model? Consumers grant a CDR consent to openfeed and a disclosure consent to your app. This keeps onward sharing clean, scoped and revocable. Explained in full in [Disclosure consent vs. CDR arrangements](/resources/onward-disclosure-explained). ### How do I get my app listed? Register as a developer, verify your business, and publish. See [Path to production](/resources/path-to-production). ### Is there an API reference? Yes. The full interactive reference lives at [/developers/api](/developers/api), and the raw spec is published at [/openapi.yaml](/openapi.yaml). ### How does openfeed handle security? openfeed is a **FAPI 2.0 compliant** OAuth 2.0 provider, the highest security profile published by the OpenID Foundation. Data-pipeline controls (including data masking and redaction) protect sensitive fields as data moves through the platform. See [Security & data handling at openfeed](/resources/security-and-data-handling) for the technical detail. --- ## Resource: Getting started: connecting your first account How to sign up for openfeed and connect your first bank or energy account — step by step, in plain English. Getting set up with openfeed takes a few minutes. You create an account, connect your first bank or energy provider, and you're ready to share with the apps you choose. Here's exactly what to expect. ## 1. Create your openfeed account Sign up with your email, or continue with Google or Apple. If you use email, we send you a one-time code to confirm it's you, so there's no separate password to remember. Your openfeed account is created the first time you sign in, so there's no lengthy registration form. openfeed is **free for consumers**, always. ## 2. Connect your first provider Choose whether to connect a **bank** or an **energy** provider, then pick your provider from the list. From here you connect through the **Consumer Data Right (CDR)** — Australia's government-backed system for sharing your own data. This next step is deliberately thorough: 1. You're taken to your bank or energy provider to log in **with them directly** — openfeed never sees your password. 2. You confirm what you're connecting. 3. You're brought back to openfeed. **You only do this once per provider**. To understand why this step is heavier than sharing with an app later, see [Connect once, share in a tap](/resources/connect-once-share-in-a-tap). ## 3. We bring your data in Once you have established a CDR Arrangement, openfeed imports your data securely in the background. You will see a brief loading step while that happens — the first import pulls in some history, so give it a moment. After that, your data stays current automatically: banking refreshes every 4 hours, energy every 6, so you don't have to do anything to keep it up to date. ## You're ready to share That's it — you're connected. From here you can share with the apps you choose in a single tap, and stay in control of every connection from one place. --- ## Resource: Quickstart From zero to live data in a few simple steps: register your app, let users log in with openfeed, request consent to their banking or energy data, and fetch it through a simple REST API.
Developer having fun
#shipit: We just launched.

openfeed just went live. Whatever you're building on openfeed, come ask questions, share feedback, and hear about new features first.
Join the openfeed Discord →

From zero to live data in a few simple steps — here's how to get started with openfeed. ## Before you start We use some OpenID Connect terms in this guide. Don't worry if you aren't familiar with them — we link to more detailed developer articles as we go. openfeed is built on strong, open standards that are common practice for hosting secure data-integration APIs. ## 1. Register your application Register your app in the [openfeed developer portal](https://app.openfeed.au/registered-apps/new). Developers self-register their own applications — there's no approval wait to start building. We gather a few fields to set up your secure client and describe your application, such as your website and logo. Later, you can improve your registration with screenshots to help consumers understand your app. ![The openfeed register-app screen, showing the App details fields: name, description, website and logo.](/screenshots/register-app.png) This is where you configure the secure client credentials your app uses in the later steps to authenticate with openfeed. ## 2. Login with openfeed Managing user information is tricky and time-consuming. openfeed takes care of that hassle with a **Login with openfeed** feature: using your registered app, we securely authenticate your users for you. See [Login with openfeed](/resources/login-with-openfeed) for how to add it. > **Tip:** openfeed uses the **FAPI 2.0 Security Profile** (an OpenID Connect standard) for strong > security. We recommend choosing an OIDC client library for your implementation language, as it > supports these standards for you and makes integration much simpler. Point a standard OIDC client library at the openfeed issuer URL — it discovers everything else it needs automatically: ``` GET https://auth.openfeed.au/.well-known/openid-configuration ``` You then send the user to openfeed to sign in, requesting the `openid` scope plus `offline_access` (so you receive a refresh token): ``` openid offline_access ``` > **Note:** `offline_access` may not appear in the `scope` field of the token response even though > a `refresh_token` is issued. This is expected — use the presence of `refresh_token` in the > response to confirm offline access was granted. ## 3. Request access to data Once you have an authenticated user, openfeed provides a simple flow to gather consent from a consumer to share their banking or energy data with you. They choose which accounts to share, and you can modify this list later if the consumer needs to change it. See [Disclosure consent explained](/resources/onward-disclosure-explained) and [Grant management & lifecycle](/resources/grant-management-and-lifecycle) for the detail. ![The openfeed data-sharing consent screen, where the consumer reviews and selects which accounts to share with your app.](/screenshots/disclosure-consent.png) To request access, add the data scopes you need when you send the user to sign in: ``` openfeed-au:data:banking:read # read banking accounts and transactions openfeed-au:data:energy:read # read energy accounts and usage openfeed-au:grant:self:query # read the grant details for a user's consent openfeed-au:grant:self:revoke # revoke a grant on the user's behalf ``` ## 4. Fetch the data With this consent in place, you can now query openfeed for data. We provide a simple REST API for this access, and publish an [OpenAPI v3 specification](/openapi.yaml) that's understood by many tools to make the integration easier. Browse it in the [API Reference](/developers/api). Your OIDC library attaches the security details to each request for you: the consumer's access token, bound to your app with a per-request DPoP proof. High level, a data call looks like this: ```javascript const url = 'https://api.openfeed.au/v1/banking/accounts'; const res = await fetch(url, { headers: { // The access token, presented under the DPoP scheme (not Bearer). Authorization: `DPoP ${accessToken}`, // A fresh, single-use proof signed with your app's key, binding this // request to that token. Your OIDC library generates this for you. DPoP: await createDpopProof('GET', url, accessToken), }, }); const { version, data, meta, links } = await res.json(); ``` Every response uses the envelope `{ version: "V1", data, meta?, links? }`. Collections are paginated with `limit` (default and max 1000) and `offset` (default 0); `links.next` is present only when more records exist. ## Ready to go That's it — with those four steps you have authenticated users and consented data flowing into your app. From here you can focus on what makes your product great, not on the plumbing. There's no accreditation to obtain, no per-institution connections to build and maintain, and no complex security stack to implement and keep compliant — openfeed handles all of that behind a single, simple REST API. The hard integration work is done, so your time goes into your features and your users' experience. --- ## Resource: What is the Consumer Data Right (CDR)? A plain-English primer on Australia's Consumer Data Right and how openfeed fits in. The **Consumer Data Right (CDR)** is Australian legislation that gives consumers control over their own data. Instead of your bank or energy provider being the only ones who can access your information, the CDR lets you choose who else can use it. With your permission, accredited organisations can securely access your data directly from the source, making it easier to compare products, switch providers or access services to make better informed financial decisions. The CDR started with banking (often called Open Banking) and has since expanded to energy, with more sectors expected over time. ## The core idea Your data should work for you. The CDR makes that possible by requiring banks and energy retailers to securely share your information when you ask them to. Every connection is made using secure, standardised technology (APIs), and **every transfer happens with your explicit consent**. You stay in control of who can access your data, what they can access, and how long that access lasts. It's a simple idea, but an important shift: instead of your data being locked away inside different organisations, you decide when it can be used to help you. ## Where openfeed fits openfeed sits on top of the CDR as a **consumer data platform**: - You connect each holder once through the official CDR consent flow. - openfeed securely connects to your data sources, bringing information together into a simple, consistent view. - You then share it onward with apps in a single tap, which are governed by the Privacy Act to protect your data. This removes the repetitive consent burden for consumers and the per-holder integration burden for developers. Read next: [How onward sharing works](/resources/how-onward-sharing-works). --- ## Resource: Open Banking in Australia: a primer What Open Banking is, how it grew out of the Consumer Data Right, what data is in scope, and how openfeed makes it usable for everyone. "Open Banking" is the banking slice of Australia's Consumer Data Right (CDR). It gives you the right to share your own banking data, securely and with your explicit consent, with Accredited Data Recipients you choose. This primer focuses on the banking specifics: what data is in scope and how it works in practice. For the general framing of the CDR itself, see [What is the Consumer Data Right?](/resources/what-is-cdr). ## What data is in scope Under Open Banking, with your explicit consent, you can share your: - **Account details**: account names, numbers and balances. - **Transaction data**: the debits and credits on your accounts. You always choose which accounts and which data types are included in a share. ## Why it was hard to use, and what changed The CDR is powerful but technically demanding: every data holder exposes its own endpoints, and businesses historically had to become accredited and build a separate integration per holder. That is why adoption lagged the ambition. openfeed collapses that complexity. Consumers connect a holder once and share in a single tap. Developers connect via OAuth 2.0 and reach every holder through a single REST API. See [How onward sharing works](/resources/how-onward-sharing-works) for the consumer flow and the [Quickstart](/resources/quickstart) for the developer flow. ## Your protections - Sharing is **opt-in** and **scoped**: Nothing moves without your consent. - Consent is **revocable**: Cancel any share per app instantly. - Data disclosed by openfeed is still protected byt he Privacy Act and it's associated obligations - openfeed is **only** available to **Australian based** businesses --- ## Resource: Connect once, share in a tap: how consent works Why connecting your bank is a one-time step, and why sharing with each new app only takes a single tap — in plain English. The first time you connect your bank or energy provider to openfeed, it takes a few minutes and a full round trip to your provider. After that, sharing your data with any app is a single tap. Here's *why* the two feel so different — and the reason openfeed is both safe and fast. ## Two kinds of "yes" There are two separate approvals happening, and keeping them apart is the whole trick. 1. **Connecting your account**: the once per year step where you link your bank or energy provider through the Consumer Data Right, Australia's government-backed data-sharing system. It's deliberately thorough: you log in with your provider, confirm what you're connecting, and you only do this once per provider. (Walked through in [Getting started](/resources/getting-started-connecting-your-first-account).) 2. **Sharing with an app**: because your data is already safely in openfeed, letting a new app see it is just one screen: pick the accounts, confirm, done. No provider login, no codes, no starting over. ## Why keeping them separate matters Because the heavy step already happened, everything after it is light — and reversible. The Consumer Data Right has many rules and obligations that make it challenging to make it smoother but recent guidance has clarified that if **you** the consumer _choose_ to disclose downstream - that is **your** right. - **Sharing is fast**: each new app is one tap, not another full provider login. - **The two are independent**, so you can turn things off at whichever level you need: - **Turn off one app**: withdraws only *that app's* permission. Your underlying CDR connection to your bank and energy accounts stays exactly as it was, and every other app keeps working. - **Disconnect openfeed entirely**: revokes openfeed's access to your bank and energy accounts, and we remove that data from our database. Apps get nothing new after you revoke, though they may keep data they already received. - **You stay in control**: sharing is per account, inherits the **Privacy Act**, and spelled out in plain English before you confirm. We don't permit applications without an Australian business presence. That independence is the part that matters most for peace of mind: you can share freely, knowing you can pull any single app's access without disturbing anything else. Want the step-by-step of sharing, editing and turning apps off? [How onward sharing works](/resources/how-onward-sharing-works) walks through it. --- ## Resource: Disclosure consent vs. CDR arrangements: what developers need to know The two-consent model that lets consumers share in one tap — and how it keeps onward sharing clean, scoped and revocable. A common point of confusion when building on openfeed is the relationship between the **CDR Arrangement** (the CDR Consumer's consent to a CDR Data Holder) and the **onward disclosure** (the user's agreement to share with *your* app, technically called a _"grant"_). They are **deliberately** independent. ## Two consents, one tap 1. **CDR Arrangement**: established once per holder, through the full CDR flow, complies with the CDR Rules. Heavy by design. 2. **Onward Disclosure**: granted per app, in a single screen, because the data is already in openfeed. Governed by the Privacy Act and therefore the Australian Privacy Principles. The key invariant: **revoking an onward disclosure does not cascade to the CDR arrangement.** A consumer can turn off your app without losing their underlying connection. ## What this means for your integration - Track the current status and definition of your apps grants by using the `GET /v1/app/grants` endpoint to track changes - Poll changed grants through `GET /v1/grants/{grantId}` to synchronise downstream state - Handle `403 disclosure_grant_required` by triggering grant information updates when encountered - Treat consent as revocable at any time — design for graceful loss of access. See the [quickstart](/resources/quickstart) for the full flow, and [Grant management & lifecycle](/resources/grant-management-and-lifecycle) for querying, amending and revoking grants. --- ## Resource: How onward sharing works The one-tap sharing model — what happens when you share data with an app, what the app can and can't do, and how to turn it off. Once you've connected a data holder to openfeed, sharing that data with an app takes a single screen — no bank login, no OTP, no re-consent. This guide walks through exactly what happens when you share, what an app can and can't see, and how to turn it off. ## What happens when you share 1. You open an app's consent screen (often directly from the app itself). 2. You pick exactly which accounts the app may see. 3. You confirm. The app immediately gains read access to the selected data. Because the data is **already in openfeed**, there is no new CDR Sharing Arrangement. The onward sharing is governed by the **Privacy Act**. ## What the app can — and can't — do When you share, the app receives access only to the data **openfeed** is holding. - **Readonly**: An app can see the data you shared. It can't move money, change anything, or act on your accounts. - **Only the accounts you picked**: Sharing is per account, not all-or-nothing. An app never sees accounts you didn't select. - **Your passwords stay private**: No app, including openfeed, ever sees your bank or energy provider login details. - **Can't reconnect your provider**: An app can't touch your underlying connection to the data holder - only you can. ## Your Sharing view Everything you've connected and everything you've shared lives in one place: - **Data sources**: Each bank and energy provider you have linked to openfeed. - **Applications**: Every app you've shared with, and how many accounts each one can see. ## Editing and revoking From your **Applications** view you can: - **Edit** which accounts an app can see, narrow it down or add another account, and - **Revoke** the app entirely. Revoking an app takes effect immediately and **never touches** your underlying CDR connections. The other apps you've shared with keep working, and you can share with a new app straight away without reconnecting anything. --- ## Resource: Login with openfeed Optional single sign-on so people can sign in to your app with their openfeed identity, the starting point for a streamlined path to data sharing. **Login with openfeed** lets people sign in to your app using their openfeed identity, a familiar single-tap sign-in for anyone who has an openfeed account. It's the natural starting point for apps that will invite users to share their banking or energy data. ## Why offer it - **Data sharing feels like one flow, not two**: When a signed-in user later chooses to share their banking or energy data, the flow is streamlined. They don't re-authenticate; they go straight to the disclosure consent screen and pick which accounts to share. - **A user base already trusted with their data**: People who use openfeed have made a considered trust decision about handing over their banking or energy data. "Sign in with openfeed" carries more weight than a generic social login for a data-heavy app. - **Lower sign-up friction**: Returning openfeed users get into your app in a single tap. No new password to create, no new profile to fill in. ## How it works Login with openfeed uses the same authorization flow as data sharing. The [Quickstart](/resources/quickstart) covers the setup. Your OIDC client library handles the rest. ## What it costs Leveraging Login with openfeed is **free**. There's no per-authorisation charge and no cost to add it to your app. Consumer pricing applies when a signed-in user chooses to share their banking or energy data with you. See [Pricing](/pricing) for the details. --- ## Resource: Security & data handling at openfeed The security posture developers build on: openfeed as an accredited CDR recipient software product, FAPI 2.0 authentication, and data-pipeline controls. openfeed is built by the team that operates production CDR infrastructure — security is foundational, not bolted on. This is the platform posture your app inherits when you build on openfeed. ## openfeed in the Consumer Data Right ecosystem openfeed is a **software product** operated by Biza Pty Ltd, an **Accredited Data Recipient** under Australia's Consumer Data Right. **openfeed** is registered on the [CDR Register](https://www.cdr.gov.au/find-a-provider?provider=DRSP000374) maintained by the Australian Competition and Consumer Commission (ACCC), and has completed the [Conformance Test Suite for data recipients](https://www.cdr.gov.au/for-providers/conformance-test-suite-data-recipients). When you build on openfeed, you're building on top of a validated CDR Software Product inside the CDR ecosystem, you don't need to become an ADR yourself. ## What openfeed handles for you The CDR ecosystem is many-sided and evolving. **openfeed** absorbs that complexity so your app can stay focused on your product: - **100+ data holder connections**: Every bank and energy retailer implements the CDR against their own systems, with their own access, endpoint quirks, and edge cases. openfeed maintains those connections continuously so you code against one interface. Users encountering issues with their providers can resolve them via openfeed and have all the downstream apps reap the benefits. - **CDR mandated requirements**: Mutual TLS, dynamic client registration against the CDR Register, participant PKI issuance and rotation, and per-holder discovery — all handled centrally by openfeed. - **Data normalisation**: Each holder implements the CDR Data Standards with subtle differences. openfeed normalises accounts, transactions, meters, usage, and DER into a consistent shape you can code against once — the same call works across every holder and **openfeed** continuously improves how it maps data on a per-holder basis. - **Availability and resilience**: openfeed tracks holder health, retries with backoff when specific holders are degraded, and monitors published [rectification schedules](https://www.cdr.gov.au/for-providers/rectification-schedules) so your app isn't caught out by known issues upstream. We will publish individual data holders we identify as having ongoing data quality issues. - **Standards evolution**: The CDR Data Standards change over time. openfeed tracks the versions and adapts ingestion so your integration doesn't churn every release. ## Data flow at a glance CDR Consumers authorise their Data Holder, their bank or energy retailer, to share data with openfeed under the Consumer Data Right. When a consumer chooses to share with your app, openfeed brokers an **onward disclosure grant** and your app fetches the shared data over a FAPI 2.0 hardened API. Your app never touches raw CDR credentials or upstream data holder connections. ## Security in Depth The openfeed Sharing API is **FAPI 2.0 compliant**, the highest standard of security profile published by the OpenID Foundation, purpose-built for financial-grade data sharing. Your app inherits that bar simply by authenticating against openfeed. The [Quickstart](/resources/quickstart) covers the configuration but for those interested in the deeper complex parts this section is for you. ### Token handling in the platform The openfeed Sharing API is a FAPI 2.0 resource server. The platform issues sender-constrained tokens through a hardened authorisation flow and enforces grant state live on every request. New client registrations default to the Recommended profile (`private_key_jwt` + DPoP). ### Confidential clients - **Confidential-only clients**: There are no public clients on openfeed. All profiles require a server-side credential. The resource-owner password grant is rejected outright. - **`private_key_jwt` client authentication (Recommended)**: Client-authentication assertions are signed with your app keypair (PS256/RSA) and verified by the auth-server against your published `jwks_uri` or your staticly uploaded public keyset. ### Authorisation request hardening - **Pushed Authorization Requests (PAR), mandatory for all clients**: Every authorisation request is pushed server-to-server over an authenticated back channel and exchanged for a single-use `request_uri`. A plain authorisation redirect without a prior PAR is rejected. Request parameters — including `redirect_uri`, scopes, PKCE challenge, and grant-management instructions — never traverse the browser, so they cannot be inspected, tampered with, or replayed in transit. - **Short-lived, single-use `request_uri`**: The PAR response carries `expires_in: 60`. The `request_uri` is one-time-use at the point of authorisation. This shrinks the window for request-leak CSRF and browser-swapping attacks to seconds. - **PKCE with S256, required**: Every authorisation code is bound to a per-request PKCE challenge (`code_challenge_method=S256`). An intercepted authorisation code is useless without the matching verifier, and the verifier never leaves your server. - **`redirect_uri` carried inside the authenticated PAR**: Redirect URIs are validated at runtime from the authenticated PAR body rather than from a pre-registration list. This closes open-redirect vectors while eliminating the pre-registration foot-gun — the dashboard registration form has no redirect URI field for this reason. - **Authorization code binding to DPoP key**: When using the Recommended profile the authorisation code is cryptographically bound to your DPoP key at issuance, so it cannot be exchanged by a different client even if intercepted. - **Issuer identification in the authorisation response**: The authorisation response includes an `iss` parameter (RFC 9207) which clients must verify, preventing mix-up attacks where a malicious authorisation server substitutes its response for a legitimate one. - **Authorization codes are single-use and short-lived**: A code that has already been presented to the token endpoint is permanently invalid; a code that is never presented expires within ~60 seconds. ### Grant management via authorisation - **Grants are established through the authorisation request itself**: Per the OIDF [Grant Management for OAuth 2.0](https://openid.net/specs/fapi-grant-management.html) spec, grants are created, queried, and replaced via `grant_management_action` (and `grant_id`) in the PAR body — not a mutable side API that can be called independently. See [Grant management & lifecycle](/resources/grant-management-and-lifecycle) for the PAR attributes table. - **`grant_id` claim on every data token**: Access tokens carry a `grant_id` claim identifying the bound disclosure grant. A token without a `grant_id` cannot call any data endpoint. ### Token binding and properties - **DPoP sender-constrained (bound) access tokens (Recommended)**: Every access token is cryptographically bound to your DPoP keypair (RFC 9449). Tokens are presented under the `DPoP` scheme — not `Bearer` — and each API call must include a fresh, single-use DPoP proof signed over the HTTP method and request URL, with an `ath` claim hashing the exact access token. A token exfiltrated from a log, proxy, or wire capture is useless without your DPoP private key. See the `Authorization: DPoP` example in the [Quickstart](/resources/quickstart). - **Two separate keypairs for two separate jobs**: Client-assertion signing uses your app keypair (PS256/RSA); DPoP proof signing uses a distinct DPoP keypair (also PS256/RSA — the auth-server only accepts PS256 for DPoP, per `dpop_signing_alg_values_supported` in the discovery document). The DPoP private key is never published in your JWKS. Compromising one keypair does not compromise the other. - **Audience-bound tokens**: Access tokens are minted for `api.openfeed.au` implicitly bound at request establishment. A token issued without this audience binding fails validation on every data call. A token issued for another resource cannot be replayed against the sharing API. - **Minimum 128-bit entropy on all credentials**: Authorisation codes, access tokens, and refresh tokens are generated with at least 128 bits of entropy, making brute-force guessing computationally infeasible. - **Short access-token lifetimes paired with refresh tokens**: Access tokens are short-lived (on the order of an hour). Refresh tokens carry the long-lived session, keeping the blast radius of a leaked access token small. Refresh tokens also let you rotate your sender-constraining DPoP keypair without losing the user's grant. ### Runtime enforcement - **Live grant state resolved on every request**: Both consumer consent (`grantStatus`) and metering state (`meteringState`) are resolved at the API boundary on every call — there is no cached authorisation. A revoked or suspended grant fails immediately before any data leaves the platform. - **Minimal introspection surface**: Introspection responses expose only `{active, grant_id}` — no `authorisation_details`, no personal information, no account identifiers. Nothing sensitive is reachable through introspection. ## Data pipeline controls openfeed's ingestion pipeline applies controls to Consumer Data as it enters and moves through the platform — including but not limited to **data masking and redaction** of sensitive fields. Sensitive identifiers are neutralised before data reaches sharing-API responses, so your app receives fields fit for storage and use. ## Consent & grant safety - **openfeed manages the consumer's consents** end-to-end. Grants are the durable, revisioned handle for each share; consent events are recorded against each grant. - **Revocation is live**: When a consumer revokes a grant, subsequent data calls return `403 disclosure_grant_required` immediately — no cached authorisation. - **State is enforced per request**: Both consumer consent (`grantStatus`) and metering state (`meteringState`) are resolved live at the API boundary; you can't accidentally read data on a stale grant. See [Grant management & lifecycle](/resources/grant-management-and-lifecycle) for the developer's side of the model. ## Independent assurance Beyond the CDR accreditation covered above, Biza Pty Ltd maintains: - **ISO 27001**: information security management standard including **ISO27017** and **ISO27018** extensions. - **SOC 2 Type 2**: independent assurance of security, availability, and confidentiality controls. - **ASAE 3150**: assurance audits underpinning Biza's CDR Data Recipient accreditation. The same infrastructure powers openfeed. ## Your side The platform gives you a secure foundation; your app closes the loop. See **Stage 4** of [Path to production](/resources/path-to-production) for openfeed's expectations of how you handle the data your app receives — token storage, retention, and Privacy Act obligations. --- ## Resource: Grant management & lifecycle How grants work on openfeed: the durable handle for a consumer share, how to create, amend, query and revoke them, and how to handle grant state in your app. ## Grants, briefly A **grant** is the durable authorization artifact that represents what a consumer has shared with your app. There is exactly **one grant per user per app**. Its permissions evolve over time as the consumer amends or revokes what they share. - **Grant** — the artifact. Identified by a `grant_id`. Its content revision (`revision`) bumps every time it changes. - **Consent** — the *event* that mutates a grant (create, amend, revoke). One grant has many consent events over its lifetime. Your app receives the `grant_id` in the token **response body** under `authorization_details[0].grant_id` (access tokens are opaque strings and cannot be decoded). Alternatively, introspect an access token at `https://auth.openfeed.au/token/introspection` which returns `{active, grant_id}`. From then on, treat it as a versioned, revocable resource by either reacting dynamically to `403 disclosure_grant_required` or establishing a poll mechanism to the `GET /v1/app/grants` endpoint. For the end-to-end auth flow, start with the [quickstart](/resources/quickstart). ## The grant lifecycle openfeed implements the OIDF **[Grant Management for OAuth 2.0](https://openid.bitbucket.io/fapi/oauth-v2-grant-management.html)** draft, so the terminology and semantics are portable across other data sharing ecosystems that adopt the same spec. A grant goes through these transitions: 1. **`create`**: the consumer authorises your app for the first time. A new `grant_id` is minted and returned in the token response body under `authorization_details[0].grant_id`. 2. **`replace`**: the consumer can change what they share, making adds or removes, an unlimited number of times. The `grant_id` **stays the same**, the `revision` **bumps**, and the authorised account set is replaced atomically. The user may do this directly via the openfeed dashboard or the app may initiate by including `grant_management_action=replace` and `grant_id=` in a PAR request. 3. **`revoke`** — the grant is ended. Revocation is one-way; you can't undo it. This can occur either by the user clicking *Revoke* within the openfeed dashboard or the app calling `DELETE /v1/grants/{grantId}` (requires `openfeed-au:grant:self:revoke` scope). To resume sharing, the consumer must start a new grant, producing a new `grant_id`. Revoking a disclosure grant **never** touches the consumer's underlying CDR arrangement with the data holder — see [Disclosure consent vs. CDR arrangements](/resources/onward-disclosure-explained). ## Establishing and amending grants You don't call these actions directly, instead you use your OAuth 2.0 client library to include them in the request object you submit via Pushed Authorization Request. Since Grant Management is an emerging spec we try to provide some sane defaults for situations where an implementer doesn't explicitly support the Grant Management specification. | Situation | Mandatory PAR Attributes | Recommended PAR Attributes | |-------------------------|--------------------------|-----------------------------------------------------------| | New grant | None | `grant_management_action=create` | | Amend an existing grant | `grant_id=` | `grant_management_action=replace` + `grant_id=` | The consumer sees the openfeed disclosure screen and confirms which accounts to share; the resulting grant reflects exactly what they approved. ## Reading your grants openfeed exposes grants through a set of API endpoints. | Endpoint | Token | Description | |--------------------------------|-------|----------------------------------------------------------| | `GET /v1/app/grants` | M2M | Paginated index of `{id,revision,lastUpdated}` per grant | | `GET /v1/grants/{grantId}` | Grant | Retrieve detailed grant information (`grant:self:query`) | | `DELETE /v1/grants/{grantId}` | Grant | Revoke a grant (`grant:self:revoke`) | ## What detailed grant information contains Detailed grant information includes the following information: - `bankingAccountIds` / `energyAccountIds`: the account identifiers accessible under this grant - `grantId`: Unique identifier of the grant - `userId`: Unique user identifier for the grant - `appId`: Developer App identifier attached to the grant - `grantRevision`: An incrementing version integer - `grantStatus`: `ACTIVE` or `REVOKED` reflects the **consumer's** consent - `meteringState` — `ACTIVE` or `SUSPENDED` reflects the **app developers** billing status Both `grantStatus` and `meteringState`must be `ACTIVE` for data calls to succeed. ## Handling grant state in your app Data endpoints are gated on live grant state. The data endpoint responds with the following errors allowing apps to react appropriately: - **`403 disclosure_grant_required`** — no active grant is resolvable. The grant is absent or revoked (this also covers an unregistered or unapproved app). Stop in-flight requests, clear local data, and deep-link the consumer back to the consent screen to re-establish a grant. - **`402 credit_exhausted`** — the grant is suspended because your credits are exhausted. The consumer's consent is intact; you need to top up credits to resume access. See [Handling errors from the openfeed API](/resources/error-handling) for the complete response catalogue. ## Checklist - [ ] Read the `grant_id` from the token response body (`authorization_details[0].grant_id`) and store it against the user. - [ ] Poll `GET /v1/app/grants` on a schedule to detect when a grant changes based on `revision` - [ ] Poll `GET /v1/grants/{grantId}` to update current status after scheduled detection or error response - [ ] Handle `403 disclosure_grant_required` by initiating a grant amendment sending the user back through the consent flow - [ ] Handle `402 credit_exhausted` by tracking it's presence and recharging available tokens - [ ] Treat every grant as revocable at any time. ## References - [Grant Management for OAuth 2.0](https://openid.net/specs/fapi-grant-management.html): the OpenID Foundation FAPI Working Group specification that openfeed implements. - [openfeed OpenAPI specification](/openapi.yaml): the machine-readable source of truth for every grant and data endpoint. --- ## Resource: Handling errors from the openfeed API Every non-200 response you can expect from the openfeed Sharing API, what it means, and how your app should react. Every openfeed API call can fail. Handling these cleanly is the difference between an integration that feels solid and one that surfaces confusing errors to your users. This is the complete list of non-200 responses you can expect and what to do about each. ## Response summary | Status | Error code | Meaning | What your app should do | |--------|-----------------------------|------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------| | 401 | | Missing or expired token | Refresh the access token. If the refresh also fails, send the user back through sign-in, optionally with `prompt=none`. | | 402 | `credit_exhausted` | Application access is suspended until credits are refilled | Prompt a credit top-up in your app. Do **not** ask the consumer to re-consent. | | 403 | `disclosure_grant_required` | Grant is either absent or revoked by the consumer | Send the user back through sign-in, optionally with `prompt=none`, to establish a new grant. | | 404 | | The resource does not exist | Show a graceful "not found" state. Some endpoints (e.g. energy DER) return 404 when the consumer has no relevant asset. | | 429 | | You are being rate-limited | Back off exponentially and show a visible loading state so the user knows the app is still working. | | 502 | `balance_temporarily_unavailable` | Balance source transiently unavailable | Retry with exponential backoff. This is a transient upstream condition, not an integration error — do not surface it as a permanent failure to users. | | 5xx | | Transient upstream error | Retry with backoff and jitter; surface a "try again shortly" state after repeated failures. | ## Handling an access revocation If a data call returns `403 disclosure_grant_required` while the user is actively using your app, they have revoked their consent or it has otherwise ended. Treat this as a hard stop: 1. Cancel any in-flight data requests. 2. Include a `grant_id` attribute in the PAR request, then redirect the user to reconnect. 3. Clean up any data you have made a commitment to the user to remove. ## When it isn't your code Some failures are known and tracked. Before deep-debugging, check the [status page](/status) — the "openfeed platform" section for defects we own, and the ["data holder issues"](/status#data-holders) section for problems that originate upstream at a specific bank or energy retailer (inconsistent data, unavailable APIs, partial implementations, delayed updates). Each entry lists severity, status and any workaround. --- ## Resource: How openfeed keeps your data safe Your data, protected — in plain English. What openfeed does to keep your banking and energy data secure, and how you stay in control. Sharing your banking and energy data should never feel risky. openfeed is built so you stay in control the whole time, and so your information is protected at every step. ## You never hand over your password You connect your accounts through the Consumer Data Right — the ecosystem run by the Australian government for sharing your own data. That means you log in with your bank or energy provider directly, and **openfeed (and the apps you use) never see your password**. ## You choose exactly what's shared Sharing is per account, not all-or-nothing. When an app asks for your data, you pick which accounts it can see and confirm. Nothing is shared until you say so. ## You can turn any app off, any time Every app you've shared with shows up in one place. Revoke any of them off instantly, and the rest keep working. Turning off an app never breaks your bank or energy connection. ## Your data is protected behind the scenes - Your connections use secure, encrypted sign-in, no shared passwords floating around. - Sensitive details like card numbers are automatically redacted from your data - Your session stays private to you and can't be read by other apps or scripts. - All communications within the openfeed platform are encrypted in transit - All your data is stored in encrypted form and access is cryptographically gated ## Backed by independent security standards openfeed runs on infrastructure operated by Biza Pty Ltd, which is independently assured to a variety of business assurance standards (**ISO 27001**, **SOC 2 Type 2**, **ASAE3150**), is an Accredited Data Recipient within the Consumer Data Right. All security interactions within the openfeed platform meet the [**FAPI 2.0** security profile](https://openid.net/specs/fapi-security-profile-2_0-final.html) and meet the [Best Current Practice for OAuth 2.0 Security](https://datatracker.ietf.org/doc/html/rfc9700). In short: our systems, processes and security are **_externally verified_**, not just claimed. --- ## Resource: Path to production How to take your openfeed app from first build through verification and into the public app catalogue — the gates you cross, and the guidance to follow along the way. openfeed runs a single live platform. There is no separate developer environment — you register today, build against real data, and progress through a small number of gates before you can serve consumers at scale. This guide walks you through those stages. ## Stage 1 — Build and test on openfeed Register your app in the [openfeed developer portal](https://app.openfeed.au/registered-apps/new) (see the [Quickstart](/resources/quickstart) for the full walkthrough). You start with: - **10 free credits**: enough to build and test without a bill, if you run out you can always recharge. - **Up to 5 active user grants** across all your apps. Use them to connect your own bank and energy accounts, then invite a small pilot group to try your app end-to-end. Revoking a grant frees a slot. The 6th active user to try your app before you're verified will see a *"sign-ups limited"* screen — that's the trigger to move to Stage 2. ### What to prove works before requesting verification Give your integration a genuine workout with your own accounts first. You'll want to see: - **Login with openfeed**: the user round-trips through openfeed and returns authenticated. - **Onward disclosure consent**: the user picks accounts and confirms; your app receives the `grant_id` on the access token. - **Data fetch**: banking, energy, or both (whatever your app declares). - **Refresh**: the access token renews silently, users aren't bounced back to sign-in on every session. - **Revocation**: you handle `403 disclosure_grant_required` by sending the user back through the consent flow. You will also encounter these in production over time — worth understanding how your app reacts: - **Amending a grant** (`grant_management_action=replace`): a user changes what they share, your cached view updates on the next call. See [Grant management & lifecycle](/resources/grant-management-and-lifecycle). - **Credit exhaustion** (`402 credit_exhausted`): you prompt a credit top-up in your app, rather than sending the user back through consent. - **Rate limiting** (`429`): you back off exponentially. See [Handling errors from the openfeed API](/resources/error-handling) for the complete response catalogue. ## Stage 2 — Get verified Verification is how you become a trusted developer on the platform. Verified developers can onboard consumers without the 5-grant cap and can list their app in the public catalogue. ### When to verify You can verify whenever you're ready — but you'll get the most out of the process if your integration is already working end-to-end. Verification is the last gate between you and scaling. ### What you'll submit Verification is self-service in the [openfeed developer portal](https://app.openfeed.au). You'll provide: - **Entity type**: Company or Sole Trader. - **Legal name**, **trading name**, **ABN** or **ACN** . - **Contact number** and **primary contact email**: reused from your openfeed account where they're already verified. We currently manually verify all businesses during launch phase. ### How it's reviewed A member of the Biza team will contact you with further details to confirm your business details while taking a look at the app you've built. We'd love to talk to you as part of that — expect a phone call. ### What verification unlocks - **Unlimited consumer grants**: the 5-grant cap is lifted. - **Public catalogue listing**: you can publish your app for consumers to discover (Stage 3). ## Stage 3 — Publish your app Once you're verified, your app can appear in the openfeed app catalogue so consumers can find it. ### Before you publish - Your app is registered and working (Stage 1). - Your business is verified (Stage 2). - Ideally, prepare some catalogue polish (nice to have, not required to publish): - **Tagline**: a one-line pitch. - **Category**: the type of experience your app offers. - **Developer/publisher name**. - **Screenshots**: a handful of your app in action. - **"Login with openfeed" SSO indicator**, if your app uses openfeed as a sign-in provider. You can edit any of this after publishing. ### How to publish Publishing is a single toggle in the developer portal — set your app to public and it enters the catalogue. Flip it back to unpublish; existing consumer grants continue either way. ## Stage 4 — Handling your users' data well You're now a receiver of openfeed data. While the user has agreed to share the data under the Privacy Act (exiting the CDR ecosystem) the data you receive is **governed by the Privacy Act** once it leaves the platform. These are our expectations of a well-run integration. ### Tokens - **Access tokens** are session credentials. Keep them server-side or in `httpOnly` cookies. **Never** write them to `localStorage`, and **never** expose them to client-side JavaScript. - **Refresh tokens** are higher-stakes — they can mint new access tokens without the user present. Server-side only and on an encrypted storage device. ### Personal information Under the Privacy Act 1988 and the [Australian Privacy Principles (APPs)](https://www.oaic.gov.au/privacy/australian-privacy-principles), the banking and energy data you fetch is **personal information** about your users. Once it leaves the openfeed platform it is governed by the Privacy Act — not the CDR rules. That comes with concrete obligations: - **Transparency (APP 1)**: maintain a clearly expressed, up-to-date privacy policy that describes what personal information you collect, why, and how you handle it. - **Collection (APP 3)**: only collect personal information that is reasonably necessary for your service's stated purpose. Don't pull scopes your app doesn't actually need. - **Notification (APP 5)**: tell users what you're collecting and why at or before the time of collection — your onboarding or consent screen is the natural place for this. - **Use and disclosure (APP 6)**: use personal information only for the primary purpose for which it was collected. Don't on-sell, share with third parties, or use it for secondary purposes (such as marketing) without separate consent or a lawful basis. - **Direct marketing (APP 7)**: if you use financial or energy data to target users with offers, you must provide a simple opt-out mechanism and honour it promptly. - **Cross-border disclosure (APP 8)**: if you store or process data on infrastructure outside Australia — including cloud regions — ensure equivalent Privacy Act protections are in place before transferring. - **Data quality (APP 10)**: take reasonable steps to ensure the personal information you hold and use is accurate, up to date, and complete. - **Storage and security (APP 11)**: encrypt personal information at rest and in transit; implement access controls; have a process to detect and respond to data breaches (see also the [Notifiable Data Breaches scheme](https://www.oaic.gov.au/privacy/notifiable-data-breaches)). APP 11 also requires you to destroy or de-identify personal information you no longer need. - **Retention**: keep information only for as long as needed for the purpose it was collected for. When that purpose ends, destroy or de-identify it. - **Access and correction (APP 12 & 13)**: users have the right to request access to the personal information you hold about them and to have inaccuracies corrected. Have a documented, reachable path for both. - **Deletion**: if a user asks you to delete their information, do so. Revoking their openfeed grant stops you receiving *new* data — it does not delete data you've already fetched. That side is on you. Review the [APP quick reference](https://www.oaic.gov.au/privacy/australian-privacy-principles/australian-privacy-principles-quick-reference) and the [OAIC's CDR guidance for business](https://www.oaic.gov.au/consumer-data-right/consumer-data-right-guidance-for-business) before you go live. ### Logging - Never log tokens, JWT payloads, or account identifiers to application logs. - openfeed scrubs card numbers (PANs) from transactions on the wire — your logs are still your own responsibility. ## Stage 5 — After launch You're live. These are the things worth watching so problems surface quickly. ### What to monitor in your app - **`403 disclosure_grant_required`** rate: a normal background level is fine (users revoke), but a spike usually means a configuration regression on your side or a UX moment that's driving users to disconnect. - **`402 credit_exhausted`**: you have run out of credits. Alert on this before it happens by tracking your credit balance in the developer portal. - **`429 rate limited`**: you're hitting a ceiling; back off harder and investigate whether you're polling more than you need to. - **Holder-side `5xx`**: a specific bank or energy retailer is degraded. Retry with backoff and jitter; don't hammer it. ### Credit balance Keep an eye on your credit balance in the developer portal and top up before you run out. `402 credit_exhausted` suspends metered access for affected grants until you do — the consumer's consent is intact, but data calls will fail until you refill. ### Grant lifecycle - Watch your grant **create** and **revoke** rates over time. A rising revoke rate is a leading indicator of a UX issue worth investigating. - Watch **replace** rates — users amending what they share is a healthy signal that they trust your app to reflect the change. ## References - [APP quick reference](https://www.oaic.gov.au/privacy/australian-privacy-principles/australian-privacy-principles-quick-reference) - [OAIC's CDR guidance for business](https://www.oaic.gov.au/consumer-data-right/consumer-data-right-guidance-for-business) before you go live. --- ## Resource: Sign in to apps with your openfeed account What "Login with openfeed" means for you, a one-tap way to sign in to apps, separate from sharing your data. Some apps let you sign in with your openfeed account, the same way you might sign in with other identity providers. It's called **Login with openfeed**, it's optional, and it's a quick way into an app without creating yet another username and password. ## What it is When an app offers Login with openfeed, you can tap one button, confirm with openfeed, and you're signed in. Behind the scenes it uses the same secure sign-in openfeed uses everywhere, so there's no new password to set up for that app. ## Signing in is not the same as sharing your data This is the important part: **logging in and sharing your financial data are two separate things.** - **Logging in** gives the app minimal information about you, in fact just a unique identifier for you expressed as a globally unique string `21cde22b-501b-4969-8553-a7d7c64ca708`. Not your name, email or other personal details. - **Sharing** is a separate, explicit step. An app only sees your banking or energy data if you choose to share it, account by account, as described in [How onward sharing works](/resources/how-onward-sharing-works). You can sign in to an app with openfeed and share **no** financial data at all, just don't choose any accounts. Nothing is shared until you say so. ## One account, however you sign in Your openfeed account is the same whether you first signed up with email, Google or Apple, so Login with openfeed always points at the one identity that's yours. If an app supports it, you'll see a **"Login with openfeed"** label so you know it's available. # News --- ## News: Live before lunch: what the first hour with openfeed actually looks like _2026-08-28_ This is a build log, not a brochure. If you want the pitch, it's one line: consented Australian banking and energy data through one API, no accreditation project. Here's what happens after you decide to try it. _By Chris Katsinas, openfeed — powered by Biza.io_ ## 0:00m — Get a key No sales call, no waiting list, no accreditation. Simple pricing at 10c per consumer, per app, per month. First 10 free. That's the whole pricing model, no platform fee, no tiers. ## 0:05m — Point your agent at the docs We publish [llms.txt](/llms.txt), an [OpenAPI 3.1 spec](/openapi.yaml) and [full-text docs](/llms-full.txt), and we publish them for this exact purpose. Point Cursor, Claude Code or whatever you're running at the docs and ask it to write the integration. It reads the auth flow, the endpoints and the consent model without you narrating. ![A coding agent building a household cashflow forecaster on openfeed — fetching llms-full.txt and the OpenAPI schema, then planning the build.](/resources/live-before-lunch-agent.png) This is the part people don't believe until they watch it. The reason it works isn't magic, it's that the surface is one API instead of one integration per data holder. There's simply less to learn. ## 0:20m — The consent flow This is the piece worth understanding properly, because it's where the model differs from everything else. The consumer consents once at their bank or energy retailer, the heavy CDR flow, done once. Then sharing with your app is a second, much lighter consent: one screen, one tap. They can revoke your app without breaking their holder connection, and they see every app they've shared with in one dashboard. In shorthand, we say "one consent". Precisely, it's a two-consent design, and the precise version is the more impressive one: your users go through the ten-screen dance once, not once per app. If they've already connected for another openfeed app, your activation is a single tap. ![The coding agent pasting the openfeed client and app IDs into .env.local, switching to live mode, and confirming the app connects to openfeed.](/resources/live-before-lunch-env-setup.png) ## 0:40m — First call Build complete and ready for your first grant. Most people are through in under an hour. ## The limits, before you find them yourself Batch refresh is every 4 hours for banking and every 6 hours for energy. It is a batch cadence, not a stream, and we'd rather say so here than have you discover it in staging, but account balance is always live. So if you want a live balance, you can get it. For other purposes (budgeting, lending assessment and energy switching etc.), that cadence is fine. If your use case needs something different, tell us. Cadence tiers are on the roadmap and they'll be priced publicly. openfeed is new but the infrastructure isn't: it runs on the plumbing behind 30+ live data holder implementations, under ISO 27001, SOC 2 Type 2, FAPI 2.0 and ASAE 3150 assurance. ## Why this beats the thing you're probably doing now Same speed to integrate as a scraped feed, but the API is FAPI 2.0 so on a far more secure baseline. Consented and sanctioned instead of credential-harvested. Your users never hand over a banking password, and your feed doesn't break the next time a bank changes a login page. There's a clock on the alternative, too. The Government has called screen scraping "fundamentally unsafe" and asked Treasury for advice on a full and formal ban. The sanctioned path was always the right answer, it just wasn't usable until now.
First ten users free.

No form, no sales call. Kick off here →

![openfeed, powered by Biza](/blog/openfeed-powered-by-biza.png) --- ## News: The Consumer Data Right just got bigger. Your build shouldn't have to. _2026-08-24_ At least 35 non-bank lenders entered the Consumer Data Right in July 2026. What the expansion means for product roadmaps, and the three ways teams get access. _By Max Diamond, openfeed — powered by Biza.io_ ![Six years of CDR, one rollout at a time — a timeline showing the phased rollout of the Consumer Data Right: Banking (July 2020), Energy (November 2022), Non-bank Lending (July 2026) and Consumer Data Sharing (from November 2026). Each phase added another set of holders, another set of feeds, and another compliance build.](/blog/cdr-six-years-timeline.png) On 13 July 2026, non-bank lenders joined the Consumer Data Right. At least 35 new data holders entered the regime, starting with product information (i.e. rates, fees, eligibility criteria, etc.) and consumer data sharing phases in from 9 November 2026 based on provider size. More than 1.3 million Australians now use the CDR. That's up 135% in a year. If you own a product roadmap, that's two pieces of news at once. The data set you can build on just widened. And the integration job you've been deferring got one sector bigger. ## The plumbing is not the interesting part, but it is the expensive part. The CDR works. The problem has never been the regime's ambition; it's the cost of standing in it. When the Government reviewed CDR compliance costs, the major banks' implementation programs ran past $100 million each, and smaller institutions past $1 million. Those are data holder numbers, not yours. But the recipient side has its own version of the same bill: accreditation measured in months, six-figure budgets, legal review, and per-holder connections that need maintaining long after launch. That maths is why the sanctioned path stayed empty for so long, and why so many teams kept screen-scraping instead. The Government has since called screen scraping "fundamentally unsafe" and asked Treasury to advise on a full and formal ban. Whatever lands, the direction is not ambiguous. ## Three doors to shipping your product. Which one do you use? **Become accredited yourself.** Full control, direct access, and a compliance function you now employ permanently. For a scale-up, that's a headcount decision disguised as an integration decision. **Keep scraping.** Fast to ship, and it works until a bank changes a login page or blocks you outright. Your users hand over their banking passwords to get there. Your risk committee already knows this. **Let the feed carry it.** Consented data through one API, with consent, revocation and compliance handled by the platform. Your team builds the feature; someone else owns the regulatory change. ## The question that actually decides it Not "can we get the data?". You can, three ways. The question is who wears the next rule change. If your team is accredited, you do. If you're scraping, you wear the breakage and the reputational risk. If the feed carries it, the change lands on the platform's roadmap instead of yours. That's the design principle behind openfeed. It's built by Biza, the team behind 30+ live data holder implementations, running under ISO 27001, SOC 2 Type 2, FAPI 2.0 and ASAE 3150 assurance. ![We have implemented CDR compliance more than 30 times. You should never have to once.](/blog/cdr-pullquote-30-times.png) ## Three questions for your next sprint planning - What is the CDR line in your roadmap actually costing you? - When a user hits your consent flow, what share of them survive it? - If data access were one sprint instead of one quarter, what would you ship next? ![We're taking that last question to Intersekt. Meet us on our coffee cart on the 3–4th of September to discuss.](/blog/cdr-intersekt-cta.png) ![openfeed, powered by Biza](/blog/openfeed-powered-by-biza.png) --- ## News: Introducing openfeed: one consent, every app _2026-06-20_ Why we built a consumer data platform on top of technology proven across 30+ production CDR implementations, and what it means for consumers and developers in Australia. Australia's Consumer Data Right (CDR) is one of the most ambitious data-portability regimes in the world. Right now, it is also exhausting to use. Why? Consumers must re-establish consent **separately, every time**, at **every** data holder. Developers rebuild the same per-holder plumbing **over and over**, despite CDR being a standard. The promise of the CDR, your data working for you, can get buried under repetition. ## One of the largest CDR contributors Since 2020 we have been one of the largest contributors to the CDR: **30+ data-holder implementations** live in market, **Open Gateway** for data recipients, and a full conformance and testing ecosystem. openfeed is the next step, bringing the power of production CDR technology to both consumers and developers through a simple, trustworthy product. ## One consent. Every app. - **For consumers:** connect each holder once, then share with any app in a single tap. And revoke any time. - **For developers:** connect via OAuth 2.0 and access every holder via a single REST API, across banking *and* energy. No per-holder integration. No CDR boilerplate. openfeed brings the convenience of a single integration layer to Australia's Consumer Data Right, spanning both banking and energy from day one.