A grant is the consumer's consent: which of their accounts your app may read. OpenFeed implements the CDR Grant Management surface, so consent is not a one-shot thing you obtain and forget — it can be amended, queried and revoked, and it expires from under you if you do not track it.
This guide covers the lifecycle. For the mechanics of reading data once you hold a grant, see Collecting data.
One grant per consumer, per app
A consumer has at most one active grant with your app. This matters more than it sounds:
- Sending a consumer through a fresh authorization does not create a second grant. OpenFeed reuses the one they already have.
- So you cannot "add another consent" — you amend the existing one.
- Because the
grant_idis stable, storing grants keyed on it is correct.AshOpenFeed'supsert_from_tokensaction upserts ongrant_idfor exactly this reason.
The lifecycle
authorize_url/3 amend_url/3
─────────────────────► ┌──────────────┐ ◄─────────────────
│ ACTIVE │
│ revision N │ ── revision N+1 after an amendment
└──────┬───────┘
│
revoke/2, or the consumer revokes in OpenFeed's UI
│
▼
┌──────────────┐
│ REVOKED │ terminal — needs fresh consent
└──────────────┘Separately from status, a grant carries a metering state. If your OpenFeed
credit runs out the grant is suspended and data calls return
402 credit_exhausted — but the consent itself is untouched. That is your
problem to fix, not something to ask the consumer about.
Creating
flow = AshOpenFeed.new_flow()
{:ok, url} = AshOpenFeed.authorize_url(MyApp.OpenFeed.Grant, flow)grant_management_action is left off deliberately. OpenFeed treats it as
optional and infers the action — create when the request carries no grant_id,
replace when it does — so omitting it is both correct and honest about the fact
that "create" is not load-bearing here.
Amending
When a consumer wants to share more or fewer accounts:
flow = AshOpenFeed.new_flow()
{:ok, url} = AshOpenFeed.amend_url(grant, flow)
redirect(conn, external: url)This is grant_management_action: "replace" with the grant's id. OpenFeed keeps
the same grant_id and increments grantRevision. Handle the callback
exactly as you handle a first connection — the upsert lands on the same row.
The old revision is archived server-side; it is not revoked, and you do not need to clean anything up.
Revoking
{:ok, grant} = AshOpenFeed.revoke(grant)Needs grant_management? true and the openfeed-au:grant:self:revoke scope.
A grant OpenFeed does not recognise reports :not_found rather than
:forbidden — deliberately, so the endpoint cannot be used to probe for valid
grant ids. revoke/2 treats that as revoked anyway: whatever the cause, it is
not usable.
Finding out about revocations
OpenFeed has no webhooks. There is no push notification for any grant lifecycle event. That leaves two ways to learn a consumer disconnected, and you want both.
Lazily, when a call fails
Revocation is enforced lazily. Two things happen on your next attempt:
- A data call returns 403
disclosure_grant_required→%OpenFeed.Error{kind: :grant_revoked}. - A token refresh is rejected with
invalid_grant, because OpenFeed sweeps the refresh tokens bound to a revoked grant.
That second one is the common path in practice, since a sync usually refreshes
before it reads. openfeed reports it as :grant_revoked rather than
:unauthorized, and ash_openfeed marks the grant revoked when it sees it — so
a disconnected consumer stops costing you a doomed refresh on every run.
Note that
invalid_grantis only read this way for refresh. On a code exchange it means a stale or replayed authorization code, which is an entirely different problem.
Proactively, by polling
{:ok, summary} = AshOpenFeed.reconcile_grants(MyApp.OpenFeed.Grant)
#=> %{checked: 12, revoked: 1, updated: 2, unchanged: 9, unknown: 0}This diffs your local grants against GET /v1/app/grants. It is two-phase,
because the index is deliberately lightweight — id, revision, lastUpdated
and nothing else:
- A local grant absent from the index has been revoked upstream.
- A higher revision in the index means the consumer amended their consent, so the full state is fetched and applied.
unknown counts index entries you have no local record of. Those are reported,
not created — there are no tokens to invent for a grant you never saw.
Run it on a schedule. It costs nothing: app-level calls are not metered, and OpenFeed charges per grant per calendar month rather than per request.
config :my_app, Oban,
plugins: [
{Oban.Plugins.Cron,
crontab: [
{"0 */4 * * *", MyApp.OpenFeed.SyncAllWorker},
{"*/15 * * * *", MyApp.OpenFeed.ReconcileWorker}
]}
]It works without grant-management scopes
Phase one uses openfeed-au:grant:all:list from a client-credentials token,
which is app-level and tied to no consumer. So revocation and amendment detection
work even if you never requested the per-grant scopes.
Phase two needs a grant-bound token with openfeed-au:grant:self:query. Without
it, an amended grant is still noticed — the revision is recorded and it counts as
updated, so your next sync picks up the new account set — you simply cannot read
the authorised id lists. reconcile_grants/2 handles that degradation itself.
Revision is the useful signal
revision increments when the consumer changes which accounts they share. It
does not move for a status change or a metering change.
So a revision bump means exactly one thing — the authorised account set is different — which makes it the cheap trigger for re-reading accounts and pruning anything that has disappeared:
if reloaded.revision > grant.revision do
# Consent scope changed. Re-read accounts, and prune locally anything
# OpenFeed no longer returns.
endThe account id lists, and what they are not
Querying a grant returns bankingAccountIds and energyAccountIds — the
accounts the consumer authorised. AshOpenFeed stores them as
banking_account_ids and energy_account_ids.
You cannot match them against the accounts you can see. They are
account_identity ids, from a different identifier space to the accountId
values the data endpoints return. There is no mapping available to a third-party
client.
So treat them as opaque. They are useful for:
- Change detection — the set differing means scope changed (though
revisiontells you that more cheaply). - Audit — recording what was authorised at a point in time.
They are not useful for deciding which of your stored accounts to stop syncing. Do that the straightforward way instead: after a revision bump, re-read the account lists from the data endpoints and prune whatever is no longer returned. A de-authorised account simply stops being returned.
Scopes
| Scope | Enables |
|---|---|
openfeed-au:grant:self:query | refresh_status/2, and phase two of reconcile_grants/2 |
openfeed-au:grant:self:revoke | revoke/2 |
openfeed-au:grant:all:list | reconcile_grants/2 phase one (client credentials) |
openfeed-au:app:all:read | OpenFeed.Sharing.app/3 (client credentials) |
grant_management? true requests the first two. OpenFeed also advertises the
RFC-standard grant_management_query and grant_management_revoke, which are
accepted as equivalents.
Requesting a scope is not being granted it. complete_authorization/4 warns if
the grant-management scopes come back missing, rather than letting revoke/2
fail with a 403 at the moment a consumer is trying to disconnect.
What is deliberately not supported
grant_management_actions_supported advertises query and revoke alongside
create and replace. The first two are not authorization-endpoint actions
— they are the /v1/grants/{id} endpoints, reached via
OpenFeed.Sharing.grant/4 and OpenFeed.Sharing.revoke_grant/4. Passing them to
authorize_url/2 does nothing useful, and it will not be accepted.