For engineering teams at partner companies. This page is the integration contract for the
/v1/account-links API; it covers those endpoints and nothing else. The CIBA flow in §7.4
applies only to integrations whose agreement includes it. For the asserted linking model, where
Bilt calls linking endpoints that you host with an already-verified member identity, see
Bilt to Partner Account Linking.1. What account linking is
Account linking connects one Bilt member to one account in your system, so that Bilt can recognize your customer and your platform can recognize a Bilt member. Once a link exists, both sides can query its status and either side can break it. In the OAuth integration model described next, Bilt never calls your API. All traffic is partner → Bilt, over three endpoints. Integrations that also include the CIBA flow add exactly one call in the other direction — the backchannel request described in §7.4; if your agreement does not name CIBA, that section does not apply to you.2. The integration model: Bilt as the identity provider
Bilt acts as an OpenID Connect identity provider; you act as an OAuth client of Bilt.- Your customer chooses to connect their Bilt account.
- You send them through Bilt’s standard OAuth authorization-code flow with PKCE (sign-in + consent hosted by Bilt).
- You exchange the authorization code at Bilt’s IdP for an
id_token. - You call
POST /v1/account-links/oauth-linkwith thatid_tokenplus your own user id. - Bilt validates the token, creates the link, and answers
LINKED.
3. Environments
Develop and test against staging first.
Every path in §7 is relative to the API base URL, so a production status call is
GET https://partnerapi.biltrewards.com/v1/account-links/{partnerUserId}.
* Most integrations are provisioned in staging and production only. Development access is granted
case by case — confirm with your Bilt contact rather than assuming three environments.
Bilt’s IdP runs on Keycloak, in the BILT realm. Derive every OIDC endpoint from the discovery
document at <issuer>/.well-known/openid-configuration rather than hard-coding paths (§6.1).
OAuth client credentials and API keys are per environment and never shared across environments.
A staging id_token presented to production is rejected (INVALID_ID_TOKEN) — the issuer will not
match.
All requests must use TLS 1.2 or higher, and all callback URLs must use HTTPS (http is acceptable
for localhost during development only).
4. Onboarding checklist
Before you can send traffic, both sides exchange the following. Your Bilt partnership contact drives this; nothing here is self-service. Bilt gives you, per environment:- An OAuth
client_idandclient_secretfor the Bilt IdP - An API key for the account-links API (the
x-api-keyvalue) - Confirmation of which environments you are provisioned in (base URLs are in §3)
- A
partnerIdslug (e.g.grubhub) — informational; it never appears in your requests
- The redirect URI(s) to register on the OAuth client, for both staging and production
- The source IP ranges you will call from, if you need them allowlisted
- A technical contact and an escalation path for production incidents (email, plus a shared Slack channel if you have one)
- Confirmation of which optional endpoints you will use (
GETstatus, partner-initiated unlink)
- A backend server able to make HTTPS requests — the token exchange must be server-to-server
- Somewhere to store secrets securely (the
client_secretand the API key) - A JWT library for validating and decoding ID tokens
401 on every call. Enabling is a Bilt-side
configuration change that takes effect within about 30 seconds — no deploy on either side.
5. Authentication
Every request to the account-links API carries your API key:- The key identifies you. There is no
partnerIdanywhere in the URL or the body; Bilt resolves your identity from the key. Two partners can never see each other’s links. - A missing, unknown, or disabled key is
401with no further detail. A key that worked yesterday and returns 401 today means either the key was rotated or your integration was disabled on the Bilt side — contact Bilt rather than retrying. - Keys are rotatable without any URL or contract change. Rotation is coordinated with you; plan for the key to be a configuration value you can change without a deploy.
- Treat the key as a secret. Store it in a secret manager, never in source control, never in a browser or mobile client. It is a server-to-server credential only.
id_token in the oauth-link body is a second, independent credential — the API key proves
which partner is calling, the id_token proves which Bilt member consented. Both are always
required for a link.
6. The id_token contract
The id_token you post to oauth-link must be one freshly minted by Bilt’s IdP for your OAuth
client. Bilt verifies it against the JWKS of the environment you are calling.
Notes:
- Do not cache and reuse tokens across links. Each new link needs its own token with its own
jti. The one sanctioned reuse is retrying a request whose response you lost (§8.1). - Do not decode, rewrite, or re-sign the token. Pass through exactly the compact JWS string the IdP returned. Maximum accepted length is 8192 characters.
- Do not log the token or store it after the call completes. It is a bearer credential for a Bilt member’s identity.
- Token lifetime is set by Bilt’s IdP. Call
oauth-linkimmediately after the code exchange rather than queuing tokens for later processing.
6.1 Where the token comes from
Bilt’s IdP is a standard OpenID Connect provider, running Keycloak in theBILT realm. Its
discovery document is the authority on these values — fetch the endpoints from it at runtime
rather than hard-coding them, so your integration survives a hostname or path change on Bilt’s side.
The fields you need from it:
For reference only, these are the production values the discovery document currently returns. Use
the discovery document, not this table, in your implementation:
Staging and development use the same paths under their own issuers (§3).
Step 1 — before the redirect
You are issued aclient_secret, which makes you a confidential OAuth client. Everything except
the browser redirect itself runs on your server.
Generate, server-side, and store against the customer’s server-side session — not in a cookie, not
in local storage:
Step 2 — the authorization request
A browser redirect to theauthorization_endpoint:
Step 3 — the callback
- Validate
stateagainst the value stored in the server-side session. Reject the callback if it is missing or does not match. - Check for
error. Treaterror=access_deniedas the member changing their mind, not as a failure to retry. Other values you may see areinvalid_request,unauthorized_clientandserver_error. - Extract the
code— short-lived and single-use. - Exchange it, server-to-server.
Step 4 — the token exchange
Server-to-server, form-encoded, at thetoken_endpoint. This must not happen from a browser or a
mobile client, because it carries your client_secret:
Content-Type other than
application/x-www-form-urlencoded, a redirect_uri that is not byte-for-byte identical to the one
in step 2, and a code that was already used. Authorization codes are short-lived and single-use:
exchange immediately, and if the exchange fails, restart the flow rather than reusing the code.
A successful exchange returns 200 OK:
The only field this API needs is
id_token — post it to oauth-link as described in §7.1.
Bilt’s account-links API never sees your access_token, and storing it is your choice, not a
requirement of linking.
Claims. The token carries the usual OIDC set — sub, iss, aud, exp, iat, plus name,
email and others subject to the scopes you asked for. Account linking reads only sub (plus
iss, aud, exp and jti for validation, §6). The sub claim is the Bilt member id: a stable,
immutable UUID, and the only identifier safe to key on. Do not use email — email addresses change.
Any other claim is yours to use or ignore; sending more scopes does not change how the link is made.
You must validate the token before trusting its contents — verify the signature against jwks_uri,
and check iss, aud and exp. Do not simply base64-decode it. Cache the JWKS keys, but refresh
them periodically: a stale cache is the usual cause of a signature check that suddenly starts
failing.
7. API reference
All endpoints here:- require
x-api-key; - use
Content-Type: application/jsonon requests with a body (a missing or non-JSON content type is rejected with415); - accept an optional
x-request-idheader for correlation — send one (§9.1); - return
application/json(except the204on unlink); - return the error envelope in §9 for business failures.
7.1 Create a link — POST /v1/account-links/oauth-link
Completes a link for a member who has just authorized you through Bilt’s IdP.
Request
partnerUserId must be stable for the lifetime of the account. It is the only handle you have on
the link afterwards: status lookups and unlinks are keyed by it. Do not send an email address, a
phone number, or any other personal data in this field.
Response 200
A Bilt-side IdP outage does not always surface as
502. Depending on how the failure presents,
token verification can fail as 401 INVALID_ID_TOKEN instead. So cap your response to
INVALID_ID_TOKEN: mint a fresh token and retry once, and if it repeats, stop and alert rather
than looping — a token-minting loop during a Bilt outage costs you and helps nobody.
7.2 Read link status — GET /v1/account-links/{partnerUserId}
Returns the current link for one of your accounts, including links that are no longer active.
Request
200
Failure modes
Treat
404 as “not linked”. It is not an error condition — it is the normal answer for an account
that has never been connected. A link becomes addressable by partnerUserId only once your
oauth-link call completes it, so a 404 never means “start over” on its own — if you have an
oauth-link call in flight, wait for its response.
7.3 Break a link — POST /v1/account-links/unlink
Call this when a customer disconnects Bilt inside your product, or when you close their account.
It is an API call you make, not a webhook Bilt subscribes to.
Request
204 — no body. The link moves to LINK_REVOKED.
Failure modes
Unlinking is idempotent: calling it again on an already-broken link returns
204, not an error.
INVALID_STATE_TRANSITION means the link was busy, not that your request was wrong. It is a
narrow race, and only possible for integrations where Bilt calls you back on unlink: the member
disconnects in the Bilt app, Bilt claims the link and calls you, and your own POST /unlink arrives
inside that window. The link is neither active (so not a plain unlink) nor terminal (so not the
idempotent 204), so the request is refused rather than applied to a link already being torn down.
Back off a second or two and re-send the identical request — by then it has settled and your retry
gets the 204. If it persists beyond a few attempts, quote the requestId (§9.1). You cannot reach
this code if Bilt does not call you on unlink; if you are unsure which applies to your integration,
ask during onboarding.
Retry promptly on a lost response — but do not replay a much older unlink, because the endpoint
resolves the current link for that account: if the member has re-linked since, a stale retry
revokes the new link.
Unlink is a soft delete. The historical record is retained for audit; a later re-link creates a new
link rather than resurrecting the old one.
7.4 Settle a CIBA link — POST /v1/account-links/ciba-callback
This endpoint exists only for integrations whose agreement includes the CIBA flow — linking that starts in the Bilt app rather than yours. It is enabled per partner during onboarding. If you are integrating only the OAuth flow in §2, you will never receive a backchannel request and can skip this section entirely.In the CIBA flow (Client-Initiated Backchannel Authentication) the direction of §2 reverses:
- The member asks to connect inside Bilt’s app.
- Bilt calls the backchannel endpoint you host — its URL and payload shape are agreed during
onboarding — carrying the member’s verified contact and a single-use
notificationToken. - You match the contact to your customer and push an approval prompt in your own app.
- Your customer approves or declines there: consent is collected on your side, where the account lives.
- You deliver the verdict to this endpoint. That call is what settles the link — until it arrives the link stays pending, and it expires unused after the TTL agreed at onboarding.
Response
200
linkStatus is what is true now; attempt is what this
delivery did:
Redelivery is a
200, never an error. The token is single-use and exactly one delivery
settles the link, so a retry loop can stand down on any 200 and never spins: resending after a
lost response answers 200 with attempt: ALREADY_CLAIMED and the link as it stands. Sustained
ALREADY_CLAIMED above your noise floor means your side is delivering callbacks twice.
Conflicts do not surface as 409 here. Once your delivery claims the token, a conflicting
link (§8.2) settles the link as LINK_FAILED and answers 200 with the conflict code in
attempt — a spent token behind an error response would strand the member on a pending link that
nothing can settle. This is the one place where §8.2’s codes arrive in-band instead of as an error
envelope, and it is why you branch on the attempt/linkStatus pairing rather than on the HTTP
status.
Failure modes
Retry rules live in §9.2 — this table is what each failure means here.
7.5 Link states
These are the valueslinkStatus can take on the endpoints in §7.1–§7.3 (the CIBA settle endpoint
reports a wider set, described in §7.4):
UNLINKING_IN_PROGRESS only reaches you if your integration has Bilt calling you when a link ends;
otherwise you will never see it. A POST /unlink landing in that window gets the 409 described
in §7.3.
Bilt tracks further states internally while a link is being set up or has expired unused, and those
are not returned to you: a link being set up is not yet keyed to a partnerUserId, so GET answers
404 (§7.2) rather than a state name. Do not code against state values outside this table.
UNLINKED and LINK_REVOKED are terminal for that link. A member in either can start over: the
next successful oauth-link creates a fresh link. You do not need to do anything to “reset” a
broken link — just run the OAuth flow again with a new token.
Bilt does not currently push link-state changes to partners. If your product needs to know that a
member unlinked from the Bilt side, poll GET /v1/account-links/{partnerUserId} at whatever cadence
your use case requires, or check it at the point of use.
8. Behaviors you must design for
8.1 Idempotency and retries
Every endpoint is safe to retry, but the rules differ:
The
oauth-link rule exists because each id_token is single-use, tracked by its jti:
- Same token, same
partnerUserId→200with the existing link, as long as that link is still active. This is the sanctioned path for retrying after a timeout or a lost response: keep the exact request body until you get a definitive answer. If the link was broken in the meantime (either side), the same retry answers409 ID_TOKEN_REPLAYEDinstead — so read that code as “this token is spent”, not only as “you used it for a different account”, and start over with a fresh one. - Same token, different
partnerUserId→409 ID_TOKEN_REPLAYED. The token is spent; you cannot use one member’s authorization to link a second account. - A request that fails before committing does not spend the token — a genuine retry with the same token still works.
502, 409 CONCURRENT_UPDATE, and network timeouts with
exponential backoff (3 attempts, starting around 1 second), resending the identical body. For other
4xx, retry only where §9.2 says “New token” — mint a fresh id_token and send it once — or after
fixing the cause. 500 responses are not fixable by retrying. Drive those decisions from the code
table in §9.2.
8.2 The two uniqueness rules
Both are enforced server-side and hold under concurrency, so two simultaneous attempts cannot both win.- One active link per Bilt member, per partner. A member cannot hold two live links to two
different accounts of yours. Attempting it returns
409 ALREADY_LINKED. - One Bilt member per account of yours. A single account in your system cannot be linked by two
different Bilt members. Attempting it returns
409 IDENTITY_ALREADY_LINKED.
ALREADY_LINKED— “Your Bilt account is already connected to a different<partner>account. Disconnect it first.” The member owns both sides and can fix it themselves.IDENTITY_ALREADY_LINKED— “This<partner>account is already connected to another Bilt account.” Somebody else holds the link; this usually means a shared or mistakenly-linked account and needs support involvement.
9. Errors
Business failures — everything in the code table below — use one envelope:
Treat unknown fields as additive: parse leniently rather than rejecting a response that grows a
field.
9.1 Correlation — send x-request-id
The API accepts an x-request-id request header and echoes it back as error.requestId, so your id
and Bilt’s logs line up without either side having to translate. Send a unique value per request
(a UUID is fine) and log it alongside your own request.
If you do not send one, Bilt generates an id for you — with one exception. A request rejected for an
unrecognized API key is stopped before the correlation step runs, so its 401 carries an empty
requestId. Sending your own header does not change that: the id never reaches Bilt’s logs either,
so for that specific failure quote the timestamp and the endpoint instead.
Every other failure — including the token 401s, which come from the handler — carries a
requestId, echoed from your header when you send one.
9.2 Error code reference
This is the normative list for the codes you can expect — the per-endpoint tables in §7 tell you which each endpoint returns, and this table defines what each means and whether to retry. Codes you should never see in normal operation are listed after it.
“New token” means the request can succeed on a retry, but only after you mint a fresh
id_token —
never by resending the same body. Cap those retries at one (§7.1).
Codes you should not see. These exist and are reachable, but every one of them means something is
wrong on Bilt’s side rather than with your request. If you get one, quote the requestId
(§9.1) — there is no client-side fix:
10. Security and data handling
- Server-to-server only. Neither the API key, the
client_secret, the PKCEcode_verifier, nor theid_tokenmay reach a browser or a mobile client. Route all calls through your backend. - TLS 1.2+ on every request, and HTTPS on every production callback URL.
- Send and validate
stateon every authorization request. Generate it server-side, bind it to the customer’s session, and check it on the callback before reading thecode. - Use PKCE with
S256on every authorization request, keeping thecode_verifierserver-side. - No personal data in this API.
partnerUserIdmust be an opaque identifier. Do not put email addresses, phone numbers, or names in it, inidTokenhandling logs, or anywhere else in the contract. - Redact credentials from logs.
id_tokenvalues, authorization codes, theclient_secret, thecode_verifierand the API key must never appear in log aggregation, error trackers, or support tickets.partnerUserId,partnerId, andrequestIdare safe to log. - On the Bilt side, a link in this integration holds only opaque identifiers — your
partnerUserIdand the Bilt member id — plus states and timestamps. No customer personal data and no credential of yours is stored. Every state change is written to an append-only audit trail, and the database is encrypted at rest. - Why the flow has three legs. Authorize, then exchange, then link — rather than linking
straight off the authorization response. The exchange is what turns a browser-supplied code into a
cryptographically signed token naming the Bilt member, so the link cannot be pointed at an account
the member did not authenticate as.
stateblocks a forged callback linking someone else’s Bilt account to your customer; PKCE blocks an intercepted code being redeemed by anyone but you; and the code being single-use and short-lived blocks a stolen one being replayed. Bilt member accounts hold points and cash balances, which is why the extra leg is not optional. - Account deletion. If a customer deletes their account with you, call
POST /unlinkso the Bilt member’s connected-accounts view stays accurate. In the other direction, a link can be broken from Bilt’s end by the member or by Bilt support; automated cleanup when a Bilt account is deleted is planned but not yet in place, so do not rely on it as your only signal.
11. Testing and go-live
Suggested test plan against the staging environment, in order:
Bilt runs the equivalent suite on its side, so mismatched results are worth raising early.
Go-live requires: the checklist in §4 complete, this test plan passing in staging, production
credentials issued, and a joint confirmation to enable the integration in production. Enabling is a
configuration change on Bilt’s side — no deploy, effective within about 30 seconds.
12. Support
When reporting a problem, include:- The
x-request-idyou sent (§9.1) — or therequestIdfrom the error body, or the approximate timestamp in UTC if you have neither, - the environment,
- the endpoint and HTTP status,
- the
partnerUserIdinvolved.
401 there is no server-generated id to
fall back on, and a timestamp alone means a much slower search.
For integration questions, contact your Bilt technical point of contact or use the shared Slack
channel agreed during onboarding. Never include an id_token, an API key, a client_secret, or any
customer personal data in a ticket.