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

# Bilt to Partner Account Linking

> Guidelines for the APIs you build so Bilt can link and unlink a member's account from the Bilt app

<Info>
  For engineering teams at partner companies. **Bilt does not host the linking endpoints described
  here — you do.** This page is the contract for the three APIs you implement so Bilt can call them,
  plus the one Bilt endpoint you call back.
</Info>

## 1. What this flow is

In this flow the member links their accounts **from inside the Bilt app**. They explicitly initiate
and confirm the link there, and Bilt then calls your backend to establish it.

Linking completes in a **single request/response**: you establish the link and return the result in
the same HTTP response. There is no callback, no polling, and no redirect through a browser.

Because the member's agreement is captured on the Bilt side, Bilt conveys it to you as the member's
**verified contact** on the link request. The trust anchor is the Bilt bearer token on the request:
it is what proves the call came from Bilt. You verify the token, verify the request, and link the
accounts.

Unlinking is **bidirectional**. A linked account can be disconnected from either side, and both
directions converge on the same end state — no link on either system:

| Direction | Who calls whom | Endpoint |
| - | - | - |
| **Partner-initiated** | You call Bilt | `POST /v1/account-links/unlink` — live today (§8) |
| **Bilt-initiated** | Bilt calls you | `POST /bilt/unlink-account` — §7 |

Both unlink endpoints are **idempotent**: unlinking an already-unlinked account is treated as
success, so the two sides reconcile safely even if an unlink is started from both at once.

***

## 2. Who hosts what

Every endpoint below is shown as a **path only**. Base URLs per environment are agreed during
onboarding.

By convention, `/bilt/*` endpoints are implemented by **you** and Bilt is the caller; `/v1/*`
endpoints are implemented by **Bilt**. The only Bilt endpoint you call in this flow is
`POST /v1/account-links/unlink`.

### 2.1 What you implement

| Endpoint | Method | Auth | Purpose | Spec |
| - | - | - | - | - |
| `/bilt/lookup-contact` | POST | Bilt bearer token | Check whether an account exists for a contact — a pre-link existence check | §4 |
| `/bilt/link-account` | POST | Bilt bearer token | Establish the link after verifying the request | §5 |
| `/bilt/unlink-account` | POST | Bilt bearer token | Remove the link when the member disconnects in the **Bilt** app | §7 |

### 2.2 What Bilt implements

| Endpoint | Method | Auth | Purpose | Spec |
| - | - | - | - | - |
| `/v1/account-links/unlink` | POST | **Your** partner access token — client credentials, not the token you issue Bilt (or the `x-api-key` it replaces) | Remove the link when the member disconnects in **your** app | — |

***

## 3. End-to-end linking flow

```mermaid theme={null}
sequenceDiagram
    autonumber
    participant User
    participant BiltApp as Bilt app<br/>(web/mobile)
    participant BiltBackend as Bilt backend
    participant Database as Bilt database
    participant PartnerBackend as Your backend

    Note over User,PartnerBackend: Phase 0 — contact lookup (pre-link existence check)

    User->>BiltApp: Tap "Link partner account"
    BiltApp->>BiltBackend: Request a contact lookup<br/>(Bilt-internal, member's session)
    BiltBackend->>Database: Get member's contact (email/phone)<br/>and isVerified flag
    Database-->>BiltBackend: contactValue, contactType,<br/>isVerified=true
    BiltBackend->>PartnerBackend: POST /bilt/lookup-contact<br/>{ contactType, contactValue }
    PartnerBackend-->>BiltBackend: 200 OK<br/>{ exists, partnerMemberId }

    alt Contact exists on your side
        BiltBackend-->>BiltApp: 200 OK<br/>{ exists: true, partnerMemberId }
        BiltApp->>BiltApp: Display contact and link confirmation
    else Contact not found
        BiltBackend-->>BiltApp: 200 OK<br/>{ exists: false }
        BiltApp->>User: "No partner account found<br/>for this contact"
    end

    Note over User,BiltBackend: Phase 1 — member confirms the link in the Bilt app

    User->>BiltApp: Confirm contact and tap "Link accounts"<br/>(explicit confirmation captured here)
    BiltApp->>BiltBackend: Request the link<br/>(Bilt-internal, member's session)
    BiltBackend->>Database: Check whether the member is already linked
    Database-->>BiltBackend: No existing link found
    BiltBackend->>Database: Get isVerified state
    Database-->>BiltBackend: isVerified=true
    BiltBackend->>BiltBackend: Prepare link request:<br/>{ biltMemberId,<br/>contact: { value, type, isVerified } }

    Note over BiltBackend,PartnerBackend: Phase 2 — link

    BiltBackend->>PartnerBackend: POST /bilt/link-account<br/>(Authorization: Bearer,<br/>{ contact: { value, type, isVerified },<br/>biltMemberId, partnerMemberId, idempotencyKey })
    PartnerBackend->>PartnerBackend: Verify bearer token
    PartnerBackend->>PartnerBackend: Verify the request<br/>(contact.isVerified == true,<br/>field consistency)
    PartnerBackend->>PartnerBackend: Resolve the account by verified contact /<br/>partnerMemberId (exact match, no auto-create)

    alt Verified and account resolved
        PartnerBackend->>PartnerBackend: Create link record<br/>(biltMemberId, partnerMemberId)
        PartnerBackend-->>BiltBackend: 200 OK<br/>{ status: "linked", partnerMemberId }
        BiltBackend->>Database: Create linked-account record
        BiltBackend-->>BiltApp: 200 OK { isLinked: true }
        BiltApp->>User: "Partner account linked"
        PartnerBackend->>User: Phase 3 — out-of-band notification<br/>"Your account was linked to Bilt."
    else Verification failed / no match / conflict
        PartnerBackend-->>BiltBackend: 4xx { error, error_description }
        BiltBackend-->>BiltApp: Error
        BiltApp->>User: "We couldn't link your partner account"
    end
```

***

## 4. Contact lookup — `POST /bilt/lookup-contact`

Checks whether an account exists on your side for a given contact. Bilt calls this **before**
starting the link so the Bilt app can fail fast with a clear message when there is no matching
account. When there is a match, return the `partnerMemberId`; Bilt reuses it as input to the
subsequent `POST /bilt/link-account` call.

This is a **read-only existence check**. It must not create a link or mutate any state on your side.
`POST` is used rather than `GET` so the contact value travels in the request body and never appears
in URLs, access logs, or proxy caches.

### 4.1 Request

**Headers**

| Header | Required | Description |
| - | - | - |
| `Content-Type` | Yes | `application/json` |
| `Authorization` | Yes | Bearer token authenticating the Bilt service |

**Body**

| Parameter | Type | Required | Description |
| - | - | - | - |
| `contactType` | string (`email` or `phone`) | Yes | Names which identifier is supplied. Reject a type you do not support with `400` |
| `contactValue` | string | Yes | The contact to look up. For `email`, a valid RFC 5322 address; for `phone`, **E.164** format (for example `+14155552671`). Carried in the body, not the URL, to keep personal data out of logs and caches |

**Normalization.** Agree normalization rules with Bilt during onboarding so lookups match
consistently — E.164 for phone, and a documented policy for email (lowercasing, plus-addressing).

```http theme={null}
POST /bilt/lookup-contact HTTP/1.1
Content-Type: application/json
Authorization: Bearer <bilt-token>

{
  "contactType": "email",
  "contactValue": "user@example.com"
}
```

### 4.2 Response

**Account found — `200 OK`**

```json theme={null}
{
  "exists": true,
  "partnerMemberId": "PARTNER123456789"
}
```

| Field | Type | Description |
| - | - | - |
| `exists` | boolean | `true` when an account matches the contact |
| `partnerMemberId` | string | Your member id for the matched account. Return it only when `exists` is `true` |

**Account not found — `200 OK`**

```json theme={null}
{
  "exists": false
}
```

Answer a missing account with `200 OK` and `exists: false` rather than `404 Not Found`, so a
successful lookup with a negative result is not confused with a routing error and account existence
cannot be inferred from status codes alone.

**Rate-limit this endpoint per authenticated client** to mitigate contact enumeration (see `429`
below). Consider stricter limits for `phone` lookups, whose number space is denser and more
guessable than email.

### 4.3 Errors

| Code | Scenario | Common causes |
| - | - | - |
| `400` | Invalid or malformed request | Missing `contactType` or `contactValue`; unsupported `contactType`; value does not match the format implied by `contactType`; malformed JSON |
| `401` | Authentication failed | Missing `Authorization` header; expired or invalid bearer token |
| `429` | Rate limit exceeded | Excessive lookups in a short window; suspected contact enumeration |
| `500` | Unexpected error on your side | Backend unavailable; database connection failure |

All error responses use the OAuth 2.0 error format, with `error` and `error_description`.

***

## 5. Link account — `POST /bilt/link-account`

Establishes the link. You authenticate the caller, verify the request body, and resolve the target
account, then create the link and **return the result in the same response**. This single
request/response completes the linking.

### 5.1 Request

**Headers**

| Header | Required | Description |
| - | - | - |
| `Content-Type` | Yes | `application/json` |
| `Authorization` | Yes | Bearer token authenticating the Bilt service |

**Body**

| Parameter | Type | Required | Description |
| - | - | - | - |
| `contact` | object | Yes | The member's verified contact, used to resolve the target account |
| `contact.value` | string | Yes | The contact value. For `email`, a valid RFC 5322 address; for `phone`, **E.164** format (for example `+14155552671`) |
| `contact.type` | string (`email` or `phone`) | Yes | Type of the value in `contact.value` |
| `contact.isVerified` | boolean | Yes | Whether Bilt has verified the member owns `contact.value`. **Reject the link if this is not `true`** |
| `biltMemberId` | string (UUID) | Yes | The Bilt member id |
| `partnerMemberId` | string | No | Your member id, typically from the contact lookup. When supplied, use it to disambiguate the target account — the resolved account must still match the verified contact |
| `idempotencyKey` | string (UUID) | Yes | Generated by Bilt, unique per link attempt. A retry carries the same key, and must never leave a second link behind — §5.6 gives two ways to guarantee that |

On a partner's link and create operations Bilt names this field `linkId`: it is the idempotency key
of that write and the id of the link episode, the value Bilt sends again on `/bilt/unlink-account`
and keys `POST /v1/account-links/unlink` on, so you store one value and send it back.

<Note>
  The two endpoints spell the contact differently, so do not share a serializer between them:
  `/bilt/lookup-contact` takes flat `contactType` and `contactValue` fields, while
  `/bilt/link-account` takes a nested `contact` object with `value`, `type`, and `isVerified`.
</Note>

### 5.2 Verifying the request

There is **no signed consent token in this flow**. Nothing in the body proves on its own that Bilt
sent it, so every guarantee you have comes from the bearer token on the request plus the checks you
run on the body itself. Run all of them:

1. **Verify the bearer token** in the `Authorization` header, against the credential you issued Bilt
   at onboarding. Reject the request with `401` otherwise — do not process the body first.
2. **Verify `contact.isVerified` is `true`.** This is the member's agreement carried into your
   system: Bilt asserts the member owns that contact and confirmed the link in the Bilt app.
   Answer `403` when it is anything else, and never treat a missing field as `true`.
3. **Verify the fields are internally consistent** — `contact.value` matches the format implied by
   `contact.type` (RFC 5322 for `email`, E.164 for `phone`), and `biltMemberId` and
   `idempotencyKey` are well-formed UUIDs. Answer `400` otherwise.
4. **Verify the retry is safe to apply.** A retry carries the same `idempotencyKey` and must never
   produce a second link. §5.6 gives two mechanisms that guarantee that; implement one.

### 5.3 Resolving the account

Resolve the target account by the verified contact, disambiguated by `partnerMemberId` when Bilt
sends one. Match **exactly**: do not create an account, and do not fall back to a fuzzy match. If
nothing resolves, answer `404` — Bilt surfaces that to the member as "no account found" rather than
retrying.

### 5.4 Response

**`200 OK`**

```json theme={null}
{
  "status": "linked",
  "partnerMemberId": "PARTNER123456789"
}
```

| Field | Type | Description |
| - | - | - |
| `status` | string | `"linked"` when the accounts were successfully linked |
| `partnerMemberId` | string | The member id the Bilt account was linked to |

### 5.5 Example

```http theme={null}
POST /bilt/link-account HTTP/1.1
Content-Type: application/json
Authorization: Bearer <bilt-token>

{
  "contact": {
    "value": "+14155552671",
    "type": "phone",
    "isVerified": true
  },
  "biltMemberId": "550e8400-e29b-41d4-a716-446655440000",
  "partnerMemberId": "PARTNER123456789",
  "idempotencyKey": "7c9e6679-7425-40de-944b-e07fc1f90ae7"
}
```

### 5.6 Errors

| Code | Scenario | Common causes |
| - | - | - |
| `400` | Invalid or malformed request | Missing `contact.value`, `contact.type`, `contact.isVerified`, `biltMemberId`, or `idempotencyKey`; contact format does not match `contact.type`; invalid UUID in `biltMemberId` or `idempotencyKey` |
| `401` | Authentication failed | Missing or invalid bearer token |
| `403` | Link not permitted | `contact.isVerified` is not `true` |
| `404` | No account resolved | The verified contact matches no account; `partnerMemberId` does not match; the account would have to be created, and auto-create is not allowed |
| `409` | Linking state conflict | Your account is already linked to a different Bilt member; the Bilt member is already linked; a concurrent link is in progress |
| `422` | Account ineligible | Locked, suspended, or otherwise ineligible for linking |
| `429` | Rate limit exceeded | Excessive link attempts; abuse throttling triggered |
| `500` | Unexpected error on your side | Backend unavailable; database failure |

All error responses use the OAuth 2.0 error format, with `error` and `error_description`.

**Idempotency.** A retry must never create a second link. Either mechanism satisfies that, and you
only need one:

* **Store the key.** Record each `idempotencyKey` with its outcome and return that outcome for any
  repeat. Bilt's `linkId` is that key, so storing it is also what lets you refuse a stale unlink
  later. Tell Bilt the retention window when you do — once a key ages out, a late retry is
  indistinguishable from a fresh request.
* **Enforce it structurally.** Keep at most one active link per account and per Bilt member, and
  treat a repeat of an already-linked pair as success rather than a conflict. Nothing needs storing:
  the retry finds the link already there and succeeds.

The two differ on a *failed* link. Structural enforcement re-evaluates the request, so a link that
failed for a reason that has since cleared will succeed on retry; stored keys replay the original
error instead. Bilt does not depend on verbatim replay of an earlier error — what it depends on is
that a retry never leaves two links behind.

<Warning>
  **Neither mechanism survives an unlink, and structural enforcement is the one that fails
  silently.** Once a link is removed, a straggler retry of the attempt that created it looks exactly
  like a fresh request: structural enforcement finds no active link and makes one, reconnecting an
  account the member disconnected, with no new confirmation anywhere. Stored keys have the same hole
  once the retention window passes.

  **Bilt closes this from its side** — it re-reads link state before reprocessing anything, and never
  re-sends a link attempt for a link that has since been unlinked. Nothing in the request body would
  tell you on its own: a straggler carrying `contact.isVerified: true` looks exactly like a fresh,
  confirmed request.

  **You can close it on your side too, and it does not depend on Bilt behaving:** treat
  `idempotencyKey` as **single-use** — spent once the attempt commits — rather than as a lookup key
  for a live link. A straggler arriving after an unlink then finds a spent key and is refused instead
  of applied. That is how Bilt handles the mirror case on its own endpoint: a retry whose link was
  unlinked in the meantime answers `409` rather than re-creating it, because the credential is spent.
</Warning>

***

## 6. Unlinking

Breaking a link is **bidirectional** and **idempotent**. Each side exposes one endpoint that the other
calls, and unlinking an already-unlinked account is treated as success, so both sides converge on
the same end state even under concurrent unlinks.

### 6.1 Partner-initiated (your app → Bilt)

```mermaid theme={null}
sequenceDiagram
    autonumber
    participant User as Member (in your app)
    participant PartnerUI as Your web/mobile app
    participant PartnerAPI as Your backend
    participant BiltAPI as Bilt backend

    User->>PartnerUI: Tap "Disconnect Bilt Rewards"
    PartnerUI->>User: Confirmation dialog
    User->>PartnerUI: Confirm

    PartnerUI->>PartnerAPI: Disconnect request
    PartnerAPI->>PartnerAPI: Resolve the member's id from the authenticated session
    PartnerAPI->>PartnerAPI: Verify the member has an active Bilt link

    alt Active link
        PartnerAPI->>BiltAPI: POST /v1/account-links/unlink<br/>(your partner access token, { partnerUserId, linkId })
        BiltAPI->>BiltAPI: Resolve the partner from the credential
        BiltAPI->>BiltAPI: Look up the link by linkId

        alt Link found, or already broken
            BiltAPI-->>PartnerAPI: 204 No Content
            PartnerAPI->>PartnerAPI: Remove the link record, clear the stored biltMemberId
            PartnerAPI-->>PartnerUI: 200 OK
            PartnerUI-->>User: "Bilt account disconnected"
        else No link of yours with that linkId
            BiltAPI-->>PartnerAPI: 404 LINK_NOT_FOUND
            PartnerAPI->>PartnerAPI: Clean up any stale link records
            PartnerAPI-->>PartnerUI: 200 OK (idempotent)
            PartnerUI-->>User: "Bilt account disconnected"
        end
    else No active link
        PartnerAPI-->>PartnerUI: 400 { error: "NO_LINKED_ACCOUNT" }
        PartnerUI-->>User: "No Bilt account is currently linked"
    end
```

### 6.2 Bilt-initiated (Bilt app → you)

The mirror image. The member disconnects in the Bilt app, Bilt calls the symmetric endpoint you
expose, you remove your link record, and Bilt removes its own.

```mermaid theme={null}
sequenceDiagram
    autonumber
    participant User as Member (in the Bilt app)
    participant BiltUI as Bilt web/mobile
    participant BiltAPI as Bilt backend
    participant PartnerAPI as Your backend

    User->>BiltUI: Tap "Disconnect partner"
    BiltUI->>User: Confirmation dialog
    User->>BiltUI: Confirm

    BiltUI->>BiltAPI: Disconnect request (with JWT auth token)
    BiltAPI->>BiltAPI: Resolve biltMemberId from the authenticated session
    BiltAPI->>BiltAPI: Look up the active link, get partnerMemberId

    alt Active link
        BiltAPI->>PartnerAPI: POST /bilt/unlink-account<br/>(Authorization: Bearer,<br/>{ biltMemberId, partnerMemberId })
        PartnerAPI->>PartnerAPI: Verify bearer token
        PartnerAPI->>PartnerAPI: Look up the link by partnerMemberId

        alt Link found
            PartnerAPI->>PartnerAPI: Delete the link record, clear the stored biltMemberId
            PartnerAPI-->>BiltAPI: 200 OK { status: "unlinked" }
            BiltAPI-->>BiltUI: 200 OK
            BiltUI-->>User: "Partner account disconnected"
        else Link not found
            PartnerAPI-->>BiltAPI: 404 { error: "LINK_NOT_FOUND" }
            BiltAPI->>BiltAPI: Clean up any stale link records
            BiltAPI-->>BiltUI: 200 OK (idempotent)
            BiltUI-->>User: "Partner account disconnected"
        end
    else No active link
        BiltAPI-->>BiltUI: 400 { error: "NO_LINKED_ACCOUNT" }
        BiltUI-->>User: "No partner account is currently linked"
    end
```

***

## 7. Unlink, Bilt-initiated — `POST /bilt/unlink-account`

The counterpart to the Bilt-hosted unlink in §8. You expose this so **Bilt can start an unlink
from the Bilt side**. Remove the link between the Bilt member and your member, then stop sending
reward-earning activity and clear the stored `biltMemberId`.

### 7.1 Request

**Headers**

| Header | Required | Description |
| - | - | - |
| `Content-Type` | Yes | `application/json` |
| `Authorization` | Yes | Bilt bearer token, as `Bearer <bilt-token>` — the same service credential Bilt uses for `/bilt/link-account`, issued by you during integration setup |

**Body**

| Parameter | Type | Required | Description |
| - | - | - | - |
| `partnerMemberId` | string | Yes | Your member id, used to look up the existing link |
| `biltMemberId` | string (UUID) | No | The Bilt member id on the link. When supplied, verify it matches the stored link as a safety check before unlinking |

```http theme={null}
POST /bilt/unlink-account HTTP/1.1
Content-Type: application/json
Authorization: Bearer <bilt-token>

{
  "partnerMemberId": "partner_123456789",
  "biltMemberId": "550e8400-e29b-41d4-a716-446655440000"
}
```

### 7.2 Response

**`200 OK`**

```json theme={null}
{
  "status": "unlinked"
}
```

| Field | Type | Description |
| - | - | - |
| `status` | string | Always `"unlinked"` on success |

After returning success: delete the stored `biltMemberId`, remove any local link-status records for
that member, and stop sending their reward-earning activity to Bilt APIs.

### 7.3 Errors

All responses use `{ error, errorDescription }`.

| Code | Error | Scenario and resolution |
| - | - | - |
| `404` | `LINK_NOT_FOUND` | No link exists for the `partnerMemberId`. **Idempotent** — Bilt treats this as a successful unlink and cleans up stale records |
| `401` | `INVALID_TOKEN` | Bilt bearer token missing or invalid |
| `400` | `INVALID_REQUEST` | A required field is missing, such as `partnerMemberId` |
| `409` | `MEMBER_ID_MISMATCH` | `biltMemberId` was supplied but does not match the one stored on the link. Guards against unlinking the wrong account when records drift |
| `500` | `INTERNAL_ERROR` | Unexpected error on your side. Bilt retries with exponential backoff |

```http theme={null}
HTTP/1.1 409 Conflict
Content-Type: application/json

{
  "error": "MEMBER_ID_MISMATCH",
  "errorDescription": "Provided biltMemberId does not match the linked account"
}
```

***

## 8. Unlink, partner-initiated — `POST /v1/account-links/unlink`

Bilt hosts this one, and you call it when the member disconnects in **your** app. It removes the
link between your member account and the Bilt member account, after which the member no longer
earns Bilt rewards for activity on your platform.

<Warning>
  **Unlike the rest of this page, this endpoint is already live.**

  Two things differ from what the rest of this page would lead you to expect: it keys on
  `partnerUserId` **plus** the Bilt-minted `linkId` naming the link episode, rather than on
  `partnerMemberId`, and it answers `204` with no body rather than a `status` object. Its error
  codes and envelope are the shared Bilt ones — note that a `404` there is
  `LINK_NOT_FOUND`, keyed on the `linkId`.

  Authentication is still a difference, but of a different kind: that endpoint takes **your own**
  partner access token — the client-credentials token Bilt issues you, sent as `Authorization:
      Bearer` — alongside the `x-api-key` it is retiring. It is not the "Bilt bearer token" the rest of
  this page means, which is the credential **you** issue Bilt for the `/bilt/*` calls; sending that
  one here is a `401 UNAUTHORIZED`.
</Warning>

The full request and response are deliberately not repeated here. One live endpoint with two
published specifications is how the two drift apart, and this page's other endpoints describe a
model that is still being agreed — so the two are not on the same footing and should not be read
the same way.

What does carry over from §6.1 is the behaviour rather than the fields: the call is **idempotent**,
so repeating it against an already-broken link succeeds rather than failing. That is what lets both
unlink directions converge on the same end state.

***

## 9. Implementation checklist

* [ ] Validate the Bilt bearer token on all three `/bilt/*` endpoints before processing the body,
  and keep the token out of logs
* [ ] Run every check in §5.2 on `/bilt/link-account`, and reject the link when `contact.isVerified`
  is not `true`
* [ ] Make retries safe — either store `idempotencyKey` values with their outcome, or keep one
  active link per account and per Bilt member and treat a repeat as success
* [ ] Decide what a retry that arrives *after* an unlink should do — neither mechanism recognizes one
  on its own, and the structural option silently re-links (§5.6)
* [ ] Resolve accounts by **exact** contact match, and never auto-create one
* [ ] Rate-limit `/bilt/lookup-contact` per client to mitigate contact enumeration
* [ ] Return `200 OK` with `exists: false` — not `404` — for a lookup with no match
* [ ] Make both unlink paths idempotent: treat an already-unlinked account as success
* [ ] Agree contact normalization rules (E.164 phone, email casing and plus-addressing) with Bilt
* [ ] Exchange base URLs, credentials, and technical contacts for each environment during onboarding


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.