Skip to main content
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.
  1. Your customer chooses to connect their Bilt account.
  2. You send them through Bilt’s standard OAuth authorization-code flow with PKCE (sign-in + consent hosted by Bilt).
  3. You exchange the authorization code at Bilt’s IdP for an id_token.
  4. You call POST /v1/account-links/oauth-link with that id_token plus your own user id.
  5. Bilt validates the token, creates the link, and answers LINKED.
Steps 1–3 are the standard OIDC flow against Bilt’s IdP — endpoints and parameters are in §6.1. Steps 4–5 are this API and are the subject of the rest of this guide.

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_id and client_secret for the Bilt IdP
  • An API key for the account-links API (the x-api-key value)
  • Confirmation of which environments you are provisioned in (base URLs are in §3)
  • A partnerId slug (e.g. grubhub) — informational; it never appears in your requests
You give Bilt:
  • 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 (GET status, partner-initiated unlink)
What you need on your side:
  • A backend server able to make HTTPS requests — the token exchange must be server-to-server
  • Somewhere to store secrets securely (the client_secret and the API key)
  • A JWT library for validating and decoding ID tokens
Go-live gate: your integration stays disabled on the Bilt side until onboarding completes. While disabled, your API key returns 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:
Rules and consequences:
  • The key identifies you. There is no partnerId anywhere 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 401 with 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.
The 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-link immediately 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 the BILT 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 a client_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 the authorization_endpoint:
Use PKCE on every authorization request. The code_verifier is generated and redeemed by the same backend that performs the exchange; only the derived code_challenge ever reaches the browser.

Step 3 — the callback

Your callback handler must, in this order:
  1. Validate state against the value stored in the server-side session. Reject the callback if it is missing or does not match.
  2. Check for error. Treat error=access_denied as the member changing their mind, not as a failure to retry. Other values you may see are invalid_request, unauthorized_client and server_error.
  3. Extract the code — short-lived and single-use.
  4. Exchange it, server-to-server.

Step 4 — the token exchange

Server-to-server, form-encoded, at the token_endpoint. This must not happen from a browser or a mobile client, because it carries your client_secret:
Three things break this call most often: a 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:
A failed exchange returns a non-200 status with a standard OAuth error body: 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/json on requests with a body (a missing or non-JSON content type is rejected with 415);
  • accept an optional x-request-id header for correlation — send one (§9.1);
  • return application/json (except the 204 on 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
Failure modes Retry rules for all of these live in §9.2 — this table is what each one means here. 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. Returns the current link for one of your accounts, including links that are no longer active. Request
Response 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
Response 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:
  1. The member asks to connect inside Bilt’s app.
  2. 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.
  3. You match the contact to your customer and push an approval prompt in your own app.
  4. Your customer approves or declines there: consent is collected on your side, where the account lives.
  5. 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.
Request
Response 200
Read the two fields as a pairlinkStatus 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. These are the values linkStatus 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 partnerUserId200 with 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 answers 409 ID_TOKEN_REPLAYED instead — 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 partnerUserId409 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.
Recommended client behavior: retry 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.
  1. 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.
  2. 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.
The distinction matters for the message you show your customer:
  • 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.
Both conflicts clear as soon as the blocking link is broken by either side, and both are immediate: an unlink on this integration is atomic, so there is no settling period during which a freed account still reports as taken. Neither code is worth retrying — resolve the conflict, then link again.

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 PKCE code_verifier, nor the id_token may 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 state on every authorization request. Generate it server-side, bind it to the customer’s session, and check it on the callback before reading the code.
  • Use PKCE with S256 on every authorization request, keeping the code_verifier server-side.
  • No personal data in this API. partnerUserId must be an opaque identifier. Do not put email addresses, phone numbers, or names in it, in idToken handling logs, or anywhere else in the contract.
  • Redact credentials from logs. id_token values, authorization codes, the client_secret, the code_verifier and the API key must never appear in log aggregation, error trackers, or support tickets. partnerUserId, partnerId, and requestId are safe to log.
  • On the Bilt side, a link in this integration holds only opaque identifiers — your partnerUserId and 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. state blocks 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 /unlink so 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:
  1. The x-request-id you sent (§9.1) — or the requestId from the error body, or the approximate timestamp in UTC if you have neither,
  2. the environment,
  3. the endpoint and HTTP status,
  4. the partnerUserId involved.
The first item is worth wiring up before you need it: on a 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.