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:

Using Ash? You probably want ash_openfeed, which builds on this package and adds grant persistence, token refresh, and an installer.

Requirements

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:

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:

StoreMulti-node safeUse when
OpenFeed.KeyStore.EnvyesReleases, 12-factor deploys. Key supplied out of band.
AshOpenFeed.KeyStore.AshyesYou want it in your database, optionally encrypted.
OpenFeed.KeyStore.FilenoLocal development, or genuinely shared storage.

Bring your own by implementing the OpenFeed.KeyStore behaviour.

License

Apache-2.0