> ## Documentation Index
> Fetch the complete documentation index at: https://docs.bilt.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Partner to Bilt Account Linking

> Partner integration guide: linking a partner account to a Bilt member with Bilt as the identity provider, plus the optional CIBA flow that starts in the Bilt app

<Info>
  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](/account-linking/bilt-to-partner-account-linking).
</Info>

## 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.

```mermaid theme={null}
sequenceDiagram
    actor U as Your customer
    participant P as Your app / site
    participant PS as Your server
    participant IDP as Bilt IdP<br/>(Keycloak)
    participant API as Bilt Account Links API

    U->>P: "Connect my Bilt account"
    P->>PS: Start linking
    PS->>PS: Generate PKCE verifier + challenge and state,<br/>store against the session
    PS-->>P: Redirect to Bilt
    P->>IDP: Authorization request (browser redirect)<br/>code_challenge + state
    IDP->>U: Bilt sign-in + consent
    IDP-->>P: Redirect back with authorization code + state
    P->>PS: Deliver code + state
    PS->>PS: Validate state before reading the code
    PS->>IDP: Exchange code (server-to-server)<br/>client_secret + code_verifier
    IDP-->>PS: id_token
    PS->>API: POST /v1/account-links/oauth-link<br/>x-api-key + { idToken, partnerUserId }
    API->>API: Validate id_token (signature, iss, aud, exp, jti)
    API-->>PS: 200 { linkStatus: "LINKED", linkedAt }
    PS->>U: Account connected
```

***

## 3. Environments

| Environment   | Account Links API base URL                   | Bilt IdP issuer (`iss`)                       |
| ------------- | -------------------------------------------- | --------------------------------------------- |
| Production    | `https://partnerapi.biltrewards.com`         | `https://www.bilt.com/realms/BILT`            |
| Staging       | `https://staging.partnerapi.biltrewards.com` | `https://staging.biltrewards.com/realms/BILT` |
| Development\* | `https://dev.partnerapi.biltrewards.com`     | `https://dev.biltrewards.com/realms/BILT`     |

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:

```http theme={null}
x-api-key: <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.

| Claim          | Requirement                                                                                                              |
| -------------- | ------------------------------------------------------------------------------------------------------------------------ |
| `alg` (header) | `RS256`. No other algorithm is accepted                                                                                  |
| `iss`          | Exactly the `issuer` value from that environment's discovery document (§3)                                               |
| `aud`          | Your Bilt OAuth `client_id`. A token minted for another partner is rejected even with a valid API key                    |
| `sub`          | The Bilt member id (a stable, immutable UUID). This is what identifies the member — you do not send a member id yourself |
| `exp`          | Must not be expired. 30 seconds of clock skew is tolerated                                                               |
| `jti`          | Required, and **single-use** (see §8.1)                                                                                  |

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.

| Environment | OIDC discovery URL                                                             |
| ----------- | ------------------------------------------------------------------------------ |
| Production  | `https://www.bilt.com/realms/BILT/.well-known/openid-configuration`            |
| Staging     | `https://staging.biltrewards.com/realms/BILT/.well-known/openid-configuration` |
| Development | `https://dev.biltrewards.com/realms/BILT/.well-known/openid-configuration`     |

The fields you need from it:

| Field                    | Use                                                              |
| ------------------------ | ---------------------------------------------------------------- |
| `issuer`                 | The expected `iss` when you validate the `id_token`              |
| `authorization_endpoint` | Where you redirect the customer (step 1)                         |
| `token_endpoint`         | The server-to-server code exchange (step 2)                      |
| `jwks_uri`               | Public keys for verifying the `id_token` signature               |
| `userinfo_endpoint`      | Additional claims, if you want them — linking does not need them |
| `end_session_endpoint`   | Logout, if you need it                                           |

For reference only, these are the production values the discovery document currently returns. **Use
the discovery document, not this table, in your implementation:**

| Purpose       | Production URL                                                      |
| ------------- | ------------------------------------------------------------------- |
| Discovery     | `https://www.bilt.com/realms/BILT/.well-known/openid-configuration` |
| Authorization | `https://www.bilt.com/realms/BILT/protocol/openid-connect/auth`     |
| Token         | `https://www.bilt.com/realms/BILT/protocol/openid-connect/token`    |
| JWKS          | `https://www.bilt.com/realms/BILT/protocol/openid-connect/certs`    |
| UserInfo      | `https://www.bilt.com/realms/BILT/protocol/openid-connect/userinfo` |
| Logout        | `https://www.bilt.com/realms/BILT/protocol/openid-connect/logout`   |

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:

| Value            | Purpose                                                                                    |
| ---------------- | ------------------------------------------------------------------------------------------ |
| `code_verifier`  | A cryptographically random 43–128 character URL-safe string. Needed for the token exchange |
| `code_challenge` | `BASE64URL(SHA256(code_verifier))`, sent on the authorization request                      |
| `state`          | A cryptographically random string. Validated on the callback                               |
| `redirect_uri`   | The exact callback URL you used, which must match byte-for-byte at exchange time           |

#### Step 2 — the authorization request

A browser redirect to the `authorization_endpoint`:

```
https://www.bilt.com/realms/BILT/protocol/openid-connect/auth
  ?client_id=<your-client-id>
  &redirect_uri=https%3A%2F%2Fpartner.example%2Foauth%2Fbilt%2Fcallback
  &response_type=code
  &scope=openid
  &state=<your-csrf-token>
  &code_challenge=<base64url-sha256-of-verifier>
  &code_challenge_method=S256
```

| Parameter               | Required | Notes                                                                                                                                                                                                                                                                                |
| ----------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `client_id`             | Yes      | Issued at onboarding (§4). Also the `aud` of the resulting token                                                                                                                                                                                                                     |
| `redirect_uri`          | Yes      | Must exactly match one you registered at onboarding                                                                                                                                                                                                                                  |
| `response_type`         | Yes      | `code`                                                                                                                                                                                                                                                                               |
| `scope`                 | Yes      | `openid` is all account linking needs — the link is keyed on `sub`. Add `email` or `profile` only if **you** want those claims; they change nothing on Bilt's side                                                                                                                   |
| `state`                 | Yes      | Your CSRF token, echoed back unchanged. Generate it server-side, and validate it on return before you read anything else from the callback. Bilt's IdP will not reject a request that omits it — the protection is entirely yours to implement, and §10 explains the attack it stops |
| `code_challenge`        | Yes      | PKCE challenge derived from the `code_verifier` you stored                                                                                                                                                                                                                           |
| `code_challenge_method` | Yes      | `S256`. The IdP also advertises `plain`; do not use it                                                                                                                                                                                                                               |
| `nonce`                 | Optional | Echoed into the token if supplied                                                                                                                                                                                                                                                    |

<Warning>
  **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.
</Warning>

#### Step 3 — the callback

```
# approved
https://partner.example/oauth/bilt/callback?code=<authorization-code>&state=<your-csrf-token>

# refused or failed
https://partner.example/oauth/bilt/callback?error=access_denied&error_description=...&state=<your-csrf-token>
```

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`:

```bash theme={null}
curl -X POST https://www.bilt.com/realms/BILT/protocol/openid-connect/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=authorization_code" \
  -d "code=<authorization-code>" \
  -d "redirect_uri=https://partner.example/oauth/bilt/callback" \
  -d "client_id=<your-client-id>" \
  -d "client_secret=<your-client-secret>" \
  -d "code_verifier=<the-verifier-you-stored>"
```

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`:

```json theme={null}
{
  "access_token": "eyJ...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "id_token": "eyJ...",
  "scope": "openid"
}
```

A failed exchange returns a non-200 status with a standard OAuth error body:

| Error                   | Cause                                | Resolution                                                |
| ----------------------- | ------------------------------------ | --------------------------------------------------------- |
| `invalid_grant`         | Code expired or already used         | Restart from step 1                                       |
| `invalid_client`        | Wrong `client_id` or `client_secret` | Verify your credentials                                   |
| `invalid_request`       | Missing required parameters          | Verify every field above is present                       |
| `redirect_uri_mismatch` | Callback URL does not match          | Make the `redirect_uri` byte-for-byte identical to step 2 |

**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**

```http theme={null}
POST /v1/account-links/oauth-link
x-api-key: <your-api-key>
Content-Type: application/json

{
  "idToken": "eyJhbGciOiJSUzI1NiIsImtpZCI6...",
  "partnerUserId": "p-123456"
}
```

| Field           | Type            | Required | Notes                                                           |
| --------------- | --------------- | -------- | --------------------------------------------------------------- |
| `idToken`       | string (≤ 8192) | yes      | The Bilt-minted `id_token` (§6)                                 |
| `partnerUserId` | string (≤ 256)  | yes      | Your opaque, **stable** identifier for the account being linked |

`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`**

```json theme={null}
{
  "partnerUserId": "p-123456",
  "linkStatus": "LINKED",
  "linkedAt": "2026-07-31T13:45:00Z"
}
```

**Failure modes**

Retry rules for all of these live in §9.2 — this table is what each one means here.

| Status | `code`                                     | What it tells you                                                                          |
| ------ | ------------------------------------------ | ------------------------------------------------------------------------------------------ |
| 400    | *(validation failure)*                     | A field was missing, blank, or too long, or the body was not valid JSON                    |
| 401    | `UNAUTHORIZED`                             | Bad or disabled API key                                                                    |
| 401    | `INVALID_ID_TOKEN`                         | Signature, issuer, or `sub` invalid — or Bilt could not verify against its IdP (see below) |
| 401    | `ID_TOKEN_EXPIRED`                         | Token past `exp`                                                                           |
| 401    | `ID_TOKEN_AUDIENCE_MISMATCH`               | `aud` is not your client id — a configuration error on your side                           |
| 409    | `ID_TOKEN_REPLAYED`                        | This token was already used for a *different* link (§8.1)                                  |
| 409    | `ALREADY_LINKED`                           | This Bilt member is already linked to a **different** account of yours (§8.2)              |
| 409    | `IDENTITY_ALREADY_LINKED`                  | This account of yours is already linked to a **different** Bilt member (§8.2)              |
| 409    | `CONCURRENT_UPDATE`                        | Two writes hit the same link at once                                                       |
| 502    | `IDP_UNAVAILABLE`                          | Bilt could not reach its own IdP to verify the token                                       |
| 500    | `PARTNER_MISCONFIGURED` / `INTERNAL_ERROR` | A Bilt-side problem — report it with the `requestId`                                       |

**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**

```http theme={null}
GET /v1/account-links/p-123456
x-api-key: <your-api-key>
```

**Response `200`**

```json theme={null}
{
  "partnerUserId": "p-123456",
  "linkStatus": "LINKED",
  "linkedAt": "2026-07-31T13:45:00Z",
  "partnerDetails": {}
}
```

| Field            | Notes                                                                                                               |
| ---------------- | ------------------------------------------------------------------------------------------------------------------- |
| `linkStatus`     | One of the states in §7.5                                                                                           |
| `linkedAt`       | ISO-8601 UTC                                                                                                        |
| `partnerDetails` | Non-sensitive metadata Bilt holds about the link. Empty (`{}`) for this integration model — reserved for future use |

**Failure modes**

| Status | `code`                      | What it tells you                     |
| ------ | --------------------------- | ------------------------------------- |
| 400    | *(validation failure)*      | `partnerUserId` was blank or too long |
| 401    | `UNAUTHORIZED`              | Bad or disabled API key               |
| 404    | `PARTNER_ACCOUNT_NOT_FOUND` | No link is keyed to this account      |

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**

```http theme={null}
POST /v1/account-links/unlink
x-api-key: <your-api-key>
Content-Type: application/json

{ "partnerUserId": "p-123456" }
```

**Response `204`** — no body. The link moves to `LINK_REVOKED`.

**Failure modes**

| Status | `code`                      | What it tells you                                                                               |
| ------ | --------------------------- | ----------------------------------------------------------------------------------------------- |
| 400    | *(validation failure)*      | `partnerUserId` was missing, blank, or too long                                                 |
| 401    | `UNAUTHORIZED`              | Bad or disabled API key                                                                         |
| 404    | `PARTNER_ACCOUNT_NOT_FOUND` | No link is keyed to this account — it never existed, or your `oauth-link` has not completed yet |
| 409    | `CONCURRENT_UPDATE`         | Two writes hit the same link at once                                                            |
| 409    | `INVALID_STATE_TRANSITION`  | The link was mid-transition — see below. Retryable                                              |

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.

```mermaid theme={null}
sequenceDiagram
    actor U as The member
    participant B as Bilt app
    participant API as Bilt Account Links API
    participant PS as Your server
    participant PA as Your app

    U->>B: "Connect <partner>"
    B->>API: Start CIBA linking (Bilt-internal)
    API->>PS: Backchannel request to YOUR endpoint (agreed at onboarding)<br/>member contact + notificationToken
    PS->>PA: Push approval prompt to your customer
    U->>PA: Approve or decline
    PS->>API: POST /v1/account-links/ciba-callback<br/>x-api-key + { notificationToken, params }
    API-->>PS: 200 { linkStatus, attempt }
    B->>API: Member's app polls link status
    API-->>B: LINKED
```

**Request**

```http theme={null}
POST /v1/account-links/ciba-callback
x-api-key: <your-api-key>
Content-Type: application/json

{
  "notificationToken": "nt-4f1c9be2…",
  "params": { "result": "approved" }
}
```

| Field               | Type                                                            | Required      | Notes                                                                                                                                                                                                                    |
| ------------------- | --------------------------------------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `notificationToken` | string (≤ 512)                                                  | yes           | The single-use token Bilt sent in the backchannel request. It is the anti-forgery proof: Bilt matches it against the pending request minted for **your** integration, so an API key cannot settle another partner's link |
| `params`            | object (≤ 32 entries; string values; keys ≤ 128, values ≤ 2048) | per agreement | Your approve/deny payload. Its exact shape is agreed during onboarding — it is interpreted by your integration's handler on Bilt's side and is not part of this envelope                                                 |

**Response `200`**

```json theme={null}
{
  "linkStatus": "LINKED",
  "attempt": "OK"
}
```

Read the two fields as a **pair** — `linkStatus` is what is true now; `attempt` is what this
delivery did:

| `attempt`          | Meaning                                                                                                                                                                                                                                                                                           |
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `OK`               | This delivery settled the link. With `linkStatus: LINKED` it is the ordinary approval; with `LINK_FAILED`, the decline or failure you reported                                                                                                                                                    |
| `ALREADY_CLAIMED`  | The token was already claimed — usually your own earlier delivery whose `200` was lost to a timeout. `linkStatus` carries the settled state; **stand down, do not retry**. `LINKING_IN_PROGRESS` here means the winning delivery is still settling and its own response carries the final verdict |
| `IGNORED`          | Bilt recognized the delivery as stale or duplicate and dropped it                                                                                                                                                                                                                                 |
| *(a failure code)* | The link settled `LINK_FAILED` with that code — e.g. `ALREADY_LINKED` or `IDENTITY_ALREADY_LINKED` when the approval raced a conflicting link (§8.2's uniqueness rules apply to CIBA links too)                                                                                                   |

**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.

| Status | `code`                                     | What it tells you                                                                                                                                  |
| ------ | ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| 400    | *(validation failure)*                     | A field was missing or too long, or the body was not valid JSON                                                                                    |
| 400    | `INVALID_STATE_TOKEN`                      | The token never existed for your integration, or it expired before any delivery arrived. Nothing to retry — the member starts over in the Bilt app |
| 401    | `UNAUTHORIZED`                             | Bad or disabled API key                                                                                                                            |
| 500    | `PARTNER_MISCONFIGURED` / `INTERNAL_ERROR` | A Bilt-side problem — report it with the `requestId`                                                                                               |

### 7.5 Link states

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):

| State                   | Meaning                                                                                             | Can you act on it?                                                                               |
| ----------------------- | --------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
| `LINKED`                | Active link. The member is connected                                                                | Yes — this is the only usable state                                                              |
| `UNLINKED`              | Broken by the member (in the Bilt app) or by Bilt support                                           | Member may re-link later                                                                         |
| `LINK_REVOKED`          | Broken from your side, via `POST /unlink`                                                           | Member may re-link later                                                                         |
| `UNLINKING_IN_PROGRESS` | Bilt is in the middle of calling you to end this link. Not usable, and not finished breaking either | No — treat it as unusable, do not treat it as terminal, and re-read later. It settles on its own |

`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:

| Call                  | Retry rule                                                                                             |
| --------------------- | ------------------------------------------------------------------------------------------------------ |
| `GET` status          | Always safe                                                                                            |
| `POST /unlink`        | Always safe — repeats return `204`                                                                     |
| `POST /ciba-callback` | Always safe — a redelivered token answers `200` with `attempt: ALREADY_CLAIMED`, never an error (§7.4) |
| `POST /oauth-link`    | Safe **only if you resend the byte-identical request** (same `idToken` *and* same `partnerUserId`)     |

The `oauth-link` rule exists because each `id_token` is single-use, tracked by its `jti`:

* **Same token, same `partnerUserId`** → `200` 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 `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.

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:

```json theme={null}
{
  "error": {
    "code": "ID_TOKEN_REPLAYED",
    "message": "id_token has already been used",
    "requestId": "your-correlation-id"
  }
}
```

| Field       | Use                                                                                                                             |
| ----------- | ------------------------------------------------------------------------------------------------------------------------------- |
| `code`      | **Branch on this, never on `message`.** Codes are stable; messages are not                                                      |
| `message`   | Detail for your logs. Not intended for end users                                                                                |
| `requestId` | Correlation id — your `x-request-id` when you send one (§9.1). **Log it for every failed call** and quote it in support tickets |

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 `401`s, 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.

| HTTP | Code                         | Retryable | Endpoints      | Meaning                                                                                                                                          |
| ---- | ---------------------------- | --------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| 401  | `UNAUTHORIZED`               | No        | all            | API key not recognized                                                                                                                           |
| 401  | `INVALID_ID_TOKEN`           | New token | link           | Token failed signature/issuer/subject validation, **or** Bilt could not verify it against its IdP                                                |
| 401  | `ID_TOKEN_EXPIRED`           | New token | link           | Token past its expiry                                                                                                                            |
| 401  | `ID_TOKEN_AUDIENCE_MISMATCH` | No        | link           | Token was minted for a different OAuth client                                                                                                    |
| 400  | `INVALID_STATE_TOKEN`        | No        | ciba-callback  | The `notificationToken` never existed for your integration, or expired before any delivery arrived — the member re-initiates from the Bilt app   |
| 404  | `PARTNER_ACCOUNT_NOT_FOUND`  | No        | status, unlink | No link is keyed to that `partnerUserId`                                                                                                         |
| 409  | `ALREADY_LINKED`             | No        | link           | Member is linked to a different account of yours. On `ciba-callback` this code arrives in-band instead — `200` with `attempt` carrying it (§7.4) |
| 409  | `IDENTITY_ALREADY_LINKED`    | No        | link           | That account is already linked to a different Bilt member. Same in-band rule on `ciba-callback` (§7.4)                                           |
| 409  | `ID_TOKEN_REPLAYED`          | New token | link           | Token already used for a different link                                                                                                          |
| 409  | `CONCURRENT_UPDATE`          | Yes       | link, unlink   | Two writes raced; resend the identical request                                                                                                   |
| 409  | `INVALID_STATE_TRANSITION`   | Yes       | unlink         | The link was mid-transition, not a bad request. Back off a second and resend — see §7.3                                                          |
| 502  | `IDP_UNAVAILABLE`            | Yes       | link           | Bilt's IdP was unreachable during token validation                                                                                               |
| 500  | `PARTNER_MISCONFIGURED`      | No        | all            | Bilt-side configuration problem — report it                                                                                                      |
| 500  | `INTERNAL_ERROR`             | No        | all            | Unexpected Bilt-side failure — report 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:

| HTTP | Code                             | What it means                                                                                                                  |
| ---- | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| 500  | `ACCOUNT_LINK_PERSISTENCE_ERROR` | A database failure while reading or writing the link                                                                           |
| 500  | `INTERNAL_SERVER_ERROR`          | An unhandled failure; the `message` is raw diagnostic text, not for display                                                    |
| 409  | `PARTNER_DISABLED`               | Your integration was disabled while your request was in flight — a brief window during which the gateway still accepts the key |

***

## 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:

| #  | Scenario                                                                                       | Expected                                           |
| -- | ---------------------------------------------------------------------------------------------- | -------------------------------------------------- |
| 1  | Link with a valid, fresh token                                                                 | `200`, `linkStatus: LINKED`                        |
| 2  | `GET` the same `partnerUserId`                                                                 | `200`, `LINKED`, with `linkedAt`                   |
| 3  | Resend the *identical* link request                                                            | `200`, same link (idempotent retry)                |
| 4  | Reuse the same token with a different `partnerUserId`                                          | `409 ID_TOKEN_REPLAYED`                            |
| 5  | Link a second account of yours for the same Bilt member, **with a fresh token**                | `409 ALREADY_LINKED`                               |
| 6  | Link the same account of yours as a second Bilt member, **with a fresh token for that member** | `409 IDENTITY_ALREADY_LINKED`                      |
| 7  | Link with an expired token                                                                     | `401 ID_TOKEN_EXPIRED`                             |
| 8  | Link with a token minted for another client                                                    | `401 ID_TOKEN_AUDIENCE_MISMATCH`                   |
| 9  | Call any endpoint with no / wrong `x-api-key`                                                  | `401`                                              |
| 10 | Send a blank or 257-character `partnerUserId`                                                  | `400`                                              |
| 11 | Send `x-request-id` on a failing call                                                          | The same value comes back in `error.requestId`     |
| 12 | Complete the OIDC flow with a mismatched `state`                                               | Your callback rejects it before reading the `code` |
| 13 | Exchange a code twice                                                                          | `invalid_grant` from the IdP on the second attempt |
| 14 | Unlink the linked account                                                                      | `204`                                              |
| 15 | Unlink again                                                                                   | `204` (idempotent)                                 |
| 16 | `GET` after unlink                                                                             | `200`, `LINK_REVOKED`                              |
| 17 | Unlink an unknown `partnerUserId`                                                              | `404 PARTNER_ACCOUNT_NOT_FOUND`                    |
| 18 | Re-link after unlink with a fresh token                                                        | `200`, `LINKED` (a new link)                       |

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.
