> ## 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. For the flow that starts in *your* app, see
  [Partner to Bilt Account Linking](/account-linking/account-links-api).
</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 — there is no signed token to verify. The trust anchor is
the mutually authenticated transport (mTLS + bearer token). You 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, and specified on [Partner to Bilt Account Linking](/account-linking/account-links-api) |
| **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   | mTLS + Bilt bearer token | Establish the link after verifying the request                             | §5   |
| `/bilt/unlink-account` | POST   | mTLS + 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   | `x-api-key` | Remove the link when the member disconnects in **your** app | [Partner to Bilt §7.3](/account-linking/account-links-api) |

***

## 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/>(mTLS, Authorization: Bearer,<br/>{ contact: { value, type, isVerified },<br/>biltMemberId, partnerMemberId, idempotencyKey })
    PartnerBackend->>PartnerBackend: Verify bearer token and mTLS client certificate
    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 |

<Warning>
  **Serve this endpoint over mutual TLS (mTLS).** Validate Bilt's client certificate in addition to
  the bearer token, so a leaked bearer token alone cannot be used from an unrecognized client.
  Nothing in the request body is independently signed, so the mutually authenticated transport is
  the *only* integrity anchor for these claims — mTLS is mandatory, not an optional hardening step.
</Warning>

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

<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 two credentials on the connection plus the
checks you run on the body itself. Run all of them:

1. **Verify the mTLS client certificate.** It must chain to the certificate authority agreed at
   onboarding and present the client identity Bilt registered with you. Reject the connection
   otherwise — do not fall back to the bearer token alone.
2. **Verify the bearer token** in the `Authorization` header, against the credential you issued Bilt
   at onboarding.
3. **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`.
4. **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.
5. **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.

<Warning>
  Because the transport is the only integrity anchor, **the checks above are not optional
  hardening** — they are the whole of the security model. In particular, an endpoint that accepts
  the bearer token without validating the client certificate can be driven by anyone who obtains
  that token.
</Warning>

### 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; mTLS client certificate not recognized                                                                                                                              |
| `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. 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
  (see [Partner to Bilt §8.1](/account-linking/account-links-api)).
</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/>(x-api-key, { partnerUserId })
        BiltAPI->>BiltAPI: Resolve the partner from the API key
        BiltAPI->>BiltAPI: Look up the link by partnerUserId

        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 keyed to that partnerUserId
            BiltAPI-->>PartnerAPI: 404 PARTNER_ACCOUNT_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/>(mTLS, Authorization: Bearer,<br/>{ biltMemberId, partnerMemberId })
        PartnerAPI->>PartnerAPI: Verify bearer token and mTLS client certificate
        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 |

<Warning>
  Like `/bilt/link-account`, serve this endpoint over **mutual TLS (mTLS)** and validate Bilt's
  client certificate in addition to the bearer token.
</Warning>

**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 — and its contract is specified on
  [Partner to Bilt Account Linking §7.3](/account-linking/account-links-api). Implement it from
  there.**

  Three things differ from what the rest of this page would lead you to expect: it authenticates
  with an `x-api-key` header rather than a bearer token, it keys on `partnerUserId` rather than
  `partnerMemberId`, and it answers `204` with no body rather than a `status` object. Its error
  codes and envelope are the shared Bilt ones documented alongside it.
</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

* [ ] Serve `/bilt/link-account` and `/bilt/unlink-account` over **mTLS**, validating Bilt's client
  certificate in addition to the bearer token
* [ ] Validate the Bilt bearer token on all three `/bilt/*` endpoints
* [ ] 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
