OpenFeed
An Elixir client for OpenFeed — Australian Consumer Data Right (CDR) banking and energy data.
OpenFeed implements the FAPI 2.0 Security Profile. This library's job is to make that a detail you don't think about:
- PAR — pushed authorization requests, so the front channel only ever
carries a single-use
request_uri. - PKCE S256 on every code exchange.
- DPoP (RFC 9449,
PS256) — sender-constrained tokens, with a freshly signed proof on every call. private_key_jwtclient authentication, and the key management that implies.
Using Ash? You probably want
ash_openfeed, which builds on this package and adds grant persistence, token refresh, and an installer.
Requirements
- Elixir 1.17 or later
- OTP 27 or later — this is a hard floor, inherited from
oidcc, whose Erlang source does not compile on OTP 26. Both packages check for it and fail with a clear message rather than letting you hit an unexplained dependency compile error.
Tested in CI against Elixir 1.17, 1.18, 1.19 and 1.20.
Installation
def deps do
[{:openfeed, "~> 0.1"}]
end
Getting set up
1. Generate a keypair. OpenFeed's Recommended profile authenticates you with a key, not a secret. The same key signs DPoP proofs.
mix openfeed.gen.key --path priv/openfeed.jwk
2. Register the public half. Print the JWKS and add it to your app registration at https://app.openfeed.au/registered-apps:
mix openfeed.jwks --path priv/openfeed.jwk
3. Build a config.
config =
OpenFeed.Config.new!(
client_id: System.fetch_env!("OPENFEED_CLIENT_ID"),
redirect_uri: "https://my.app/openfeed/callback",
key_store: {OpenFeed.KeyStore.File, path: "priv/openfeed.jwk"},
scopes: [:banking, :energy]
)
4. Start the provider metadata worker in your supervision tree, so OIDC discovery and the JWKS are fetched once and cached rather than on every call:
children = [
{OpenFeed.ProviderConfiguration, config}
# ...
]
Collecting data
Every read goes through OpenFeed.Sharing, which has one function per endpoint.
Each takes the config and an access token.
How the data is shaped
Both domains are trees, and the nesting is the actual work:
banking/accounts ─┬─ accounts/{id}/balance on demand, slow
└─ accounts/{id}/transactions paginated, date-filterable
energy/accounts ─┬─ accounts/{id}/balance on demand, slow
├─ accounts/{id}/invoices
├─ accounts/{id}/billing
└─ accounts/{id}/meters ─┬─ .../usage paginated, date-filterable
└─ .../der
A complete read
{:ok, accounts} = OpenFeed.Sharing.banking_accounts(config, tokens)
for account <- accounts do
id = account["accountId"]
{:ok, balance} = OpenFeed.Sharing.banking_balance(config, tokens, id)
{:ok, transactions} =
OpenFeed.Sharing.banking_transactions(config, tokens, id,
oldest_date: Date.add(Date.utc_today(), -365)
)
IO.puts("#{account["displayName"]}: #{length(transactions)} transactions")
IO.puts(" balance #{OpenFeed.Amount.to_decimal(balance["currentBalance"])} #{balance["currency"]}")
end
Energy is one level deeper:
{:ok, accounts} = OpenFeed.Sharing.energy_accounts(config, tokens)
for account <- accounts, id = account["accountId"] do
{:ok, meters} = OpenFeed.Sharing.energy_meters(config, tokens, id)
for meter <- meters do
{:ok, days} =
OpenFeed.Sharing.energy_usage(config, tokens, id, meter["meterId"],
oldest_date: Date.add(Date.utc_today(), -90)
)
total = days |> Enum.map(&OpenFeed.Energy.net_usage/1) |> Enum.reduce(&Decimal.add/2)
IO.puts("#{meter["nationalMeteringId"]}: #{total} kWh net over #{length(days)} days")
end
end
Pass the OpenFeed.Tokens struct, not a bare access-token string — it carries
the token_type OpenFeed issued, which is the only reliable source of the
authorization scheme. See OpenFeed.Client.token_type/2.
Two traps, both handled for you
Money has two wire formats. Banking amounts are ISO 20022 strings
("-52.00"); energy amounts are JSON numbers (193.8). Decimal.new/1 works
on one and raises on the other, so whichever domain you test second breaks. Use
OpenFeed.Amount.to_decimal/1, which takes either — and never hold money in a
float.
Usage reads carry a discriminator.reads[].readUType selects
basicRead (a single total, from a daily-read meter) or intervalRead (an
aggregate plus per-interval values). Reading only the interval variant works
against an interval meter and silently reports zero for every daily meter.
Use OpenFeed.Energy.net_usage/1.
net_usage/1 returns 0 when OpenFeed returns no reads at all, which keeps
sums free of nil guards but collapses "no data" into "zero usage". Use
OpenFeed.Energy.has_reads?/1 when the difference matters — a gap in a chart is
more honest than a plotted zero.
Large collections
Collection functions return every page. One real account produced 3,947 transactions for a single year, so for anything unbounded stream instead:
OpenFeed.Client.stream(config, tokens, "/v1/banking/accounts/#{id}/transactions")
|> Stream.each(&store!/1)
|> Stream.run()
stream/4 fetches a page at a time and raises OpenFeed.Error on failure,
since a lazy stream has nowhere to put an error tuple.
When a call fails
Branch on OpenFeed.Error's :kind, never on the HTTP status — OpenFeed
returns 403 both for a withdrawn consent and for a subject mismatch, and
conflating them marks healthy grants dead.
case OpenFeed.Sharing.banking_accounts(config, tokens) do
{:ok, accounts} ->
accounts
{:error, %OpenFeed.Error{kind: :grant_revoked}} ->
:stop_syncing_and_ask_them_to_reconnect
{:error, %OpenFeed.Error{kind: :credit_exhausted}} ->
:your_openfeed_credit_ran_out
{:error, %OpenFeed.Error{} = error} ->
if OpenFeed.Error.retryable?(error), do: :try_later, else: :give_up
end
In a sync that touches many endpoints, collect errors and keep going rather than abandoning the run — one unavailable balance should not cost you a year of transactions. See the example app for that shape.
Field reference
This library returns decoded maps with the keys OpenFeed sends, and does not restate the schema — that would only drift. The authoritative reference is public:
- API reference — https://openfeed.au/developers/api
- OpenAPI spec — https://openfeed.au/openapi.yaml
- Quickstart — https://openfeed.au/resources/quickstart
- For coding agents — https://openfeed.au/llms.txt
There is no rawJson passthrough: responses carry explicitly typed,
allow-listed fields only.
What this library does not do
It does not mirror CDR data into a schema for you. How much history to keep, how
to model it and how often to refresh are application decisions, and OpenFeed's
own refresh cadence (banking ~4h, energy ~6h) is the practical ceiling on how
often re-reading is worthwhile. See the
Collecting data guide and
Cost and cadence, and
ash_openfeed if you are on Ash.
Configuration is injected, not read
Nothing in this package reads Application.get_env/2. Every entry point takes
an OpenFeed.Config.
That is deliberate. It means the FAPI 2.0 surface is testable against a stub with no application environment, and an application can hold more than one OpenFeed registration — one per tenant, say — without this library knowing anything about it. Where the values come from is your decision.
Key management
The one thing this library refuses to do is quietly generate a key for you.
A store that lazily generates on first read works beautifully on one machine and breaks on two: each node mints a different key, signs proofs with it, and OpenFeed rejects them because that key was never registered. The failure is intermittent and depends on which node the load balancer picked, which makes it genuinely painful to diagnose.
So key creation is always explicit, and you pick where keys live:
| Store | Multi-node safe | Use when |
|---|---|---|
OpenFeed.KeyStore.Env | yes | Releases, 12-factor deploys. Key supplied out of band. |
AshOpenFeed.KeyStore.Ash | yes | You want it in your database, optionally encrypted. |
OpenFeed.KeyStore.File | no | Local development, or genuinely shared storage. |
Bring your own by implementing the OpenFeed.KeyStore behaviour.
License
Apache-2.0