Intuit Bank Feeds – Configuration Guide
Intuit Bank Feeds delivers bank-feed accounts and statements into QuickBooks Online through Apideck's FDX data provider, fdx-connect.
Set up your customer login
When a QuickBooks user connects a bank feed, Apideck hands them to your login page to authenticate, then takes the result back. This guide is everything you need to implement that page.
There is no SDK, and deliberately so — what you implement is standard OIDC discovery plus two signed JWTs. Any JOSE library does the whole of it.
The two settings
In Configuration → Accounting API → Intuit Bank Feeds, under Customer login:
| Setting | What it is |
|---|---|
| Login URL | Where we send the QuickBooks user. It receives one query parameter, handoff. |
| JWKS URL | Where you publish the public keys for the assertions your login signs. |
Set both or neither — a half-filled pair is rejected. Neither URL is validated beyond one rule: the
Login URL must not contain a fragment (a # and anything after it). We append the handoff by
concatenation, so a fragment would place the handoff inside the fragment and your server would never
receive it.
Saving is idempotent: send the same values as often as you like. An application with neither set is a legal state — the connect flow refuses to hand off until they are there, and reports it as our misconfiguration rather than telling Intuit the user declined.
The one value you need from us
Once the two URLs are saved, the Issuer field on the same screen fills in. Copy it into your login's configuration.
That is the whole of your configuration. Everything else — our signing keys included — is discovered
from it through {issuer}/.well-known/openid-configuration → jwks_uri. Cache the key set; we rotate
by publishing both keys for a period, so a cache that refreshes on an unknown kid is correct.
The Issuer changes if your OAuth client is ever re-registered, so read it from this screen rather than hard-coding it in a place you cannot update.
Step 1 — verify the handoff
Your Login URL is called with one parameter:
GET {your login URL}?handoff=eyJhbGciOiJFUzI1NiIsInR5cCI6ImZkeC1jb25uZWN0LWhhbmRvZmYran…
It is a compact JWS, ES256.
Header: alg: ES256, typ: fdx-connect-handoff+jwt, kid (look it up in our JWKS).
Claims
| Claim | Meaning |
|---|---|
iss | Our issuer for your application — the value from the screen above. |
aud | Your Login URL. Require it. |
resume_token | Opaque. Hand it back in step 3 and otherwise ignore it. |
iat / exp | Numeric dates. The window is a few minutes. |
Require typ. Without it, any ES256 JWT our key set can verify — an access token, an id_token —
would satisfy your check if its claims happened to line up.
Require aud. It is what stops a handoff minted for another customer's login being replayed
against yours.
There is deliberately nothing about the end user in the handoff. We do not know who they are, and the claim set is closed.
If verification fails, show your own error page. Do not redirect back to us: without a valid handoff you have no resume token, and there is nothing for us to resume.
Step 2 — authenticate your user
Yours entirely. Password, SSO, magic link, an existing session — we have no opinion and no visibility.
Step 3 — sign an assertion and redirect back
302 {issuer origin}/connect/resume?resume_token=…&assertion=…
Both parameters are required, once each. resume_token is the value from the handoff, unchanged.
The assertion is a compact JWS you sign, ES256, verifiable through your JWKS URL.
Header: alg: ES256, typ: fdx-connect-customer-assertion+jwt, plus kid if your JWKS holds
more than one key.
Claims
| Claim | Required | Meaning |
|---|---|---|
iss | yes | Your Login URL, exactly as registered |
aud | yes | Our issuer — the iss of the handoff you just verified |
resume_token | yes | The token from the handoff. Binds this assertion to this one flow. |
outcome | yes | approved or cancelled |
sub | on approved | Your identifier for the authenticated user |
exp | yes | Keep it short. Minutes. |
Both outcomes are real
Send cancelled when your user declines. It is not an error path: we translate it into the OAuth
access_denied that Intuit expects, and their handling takes over. If you simply never redirect, the
user is stranded on your page and Intuit learns nothing until the stash expires.
outcome must be stated. We do not infer a cancellation from a missing sub — a login that
forgot the subject would otherwise look like a user who declined, and they would be told they refused
something they did not.
No clock tolerance
We verify exp strictly, with zero skew allowance. The lifetime is yours to choose, so a few minutes
leaves ample room. If your clock is far enough out to fail this, that is worth knowing rather than
absorbing.
If something goes wrong
A failed verification does not consume the resume token, so a transient problem — your key endpoint briefly down, a clock that has since been corrected — can be retried on the same flow rather than restarting it.
Note that a rejected configuration is quiet by design: if we cannot store your URLs, your settings save still succeeds and the failure is only in our logs. So after saving, confirm the Issuer field filled in. An empty Issuer after a save is the signal that something did not land.