Intuit Bank Feeds – Configuration Guide

Service ID: intuit-bank-feeds

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:

SettingWhat it is
Login URLWhere we send the QuickBooks user. It receives one query parameter, handoff.
JWKS URLWhere 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-configurationjwks_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

ClaimMeaning
issOur issuer for your application — the value from the screen above.
audYour Login URL. Require it.
resume_tokenOpaque. Hand it back in step 3 and otherwise ignore it.
iat / expNumeric dates. The window is a few minutes.
const { payload } = await jwtVerify(handoff, ourJwks, {
  typ: 'fdx-connect-handoff+jwt',   // ← do not skip this
  issuer: EXPECTED_ISSUER,          // the Issuer value from the settings screen
  audience: 'https://books.example.com/apideck/connect' // your own Login URL
})

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

ClaimRequiredMeaning
issyesYour Login URL, exactly as registered
audyesOur issuer — the iss of the handoff you just verified
resume_tokenyesThe token from the handoff. Binds this assertion to this one flow.
outcomeyesapproved or cancelled
subon approvedYour identifier for the authenticated user
expyesKeep it short. Minutes.

Both outcomes are real

// approved
{ iss, aud, resume_token, outcome: 'approved', sub: 'cust_9f2a', iat, exp }

// cancelled — the user said no, or closed your confirm step
{ iss, aud, resume_token, outcome: 'cancelled', iat, exp }

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.