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

# Create a link

> Completes a link for a member who has just authorized you through
Bilt's IdP. Bilt validates the `idToken` (signature against the
environment's JWKS, `iss`, `aud`, `exp`, single-use `jti`), reads the
Bilt member id from `sub`, and links it to your `partnerUserId`.

`partnerUserId` must be **stable** for the lifetime of the account. It
is the only handle you have on the link afterwards: status lookups are
keyed by it, and unlinks name it. Do not send an email address, a
phone number, or any other personal data in this field.

**Idempotent only for a byte-identical retry.** Resending the same
`idToken` with the same `partnerUserId` returns `200` with the
existing link while it is still active — the sanctioned path after a
timeout or a lost response. The same token with a different
`partnerUserId`, or after the link was broken, is
`409 ID_TOKEN_REPLAYED`: the token is spent, mint a fresh one.

Keep the `linkId` from the response or fetch it later from the status
read — `POST /unlink` requires it.




## OpenAPI

````yaml /account-linking/account-links-openapi.yaml post /v1/account-links/oauth-link
openapi: 3.0.3
info:
  title: Bilt Account Linking SDK
  version: 1.0.0
  description: >
    Endpoint reference for the `/v1/account-links` API — the partner-facing
    surface any external

    partner calls to link and unlink its customers' accounts with Bilt members.
    The integration

    contract (environments, authentication, the `id_token` flow, idempotency,
    link states and the

    normative error table) is the

    [Partner to Bilt Account Linking](/account-linking/account-links-api) guide;
    these pages are

    its §7 as a browsable reference and never say more than it does.
servers:
  - url: https://partnerapi.biltrewards.com
    description: Production — partner (third-party) gateway
  - url: https://staging.partnerapi.biltrewards.com
    description: Staging — partner (third-party) gateway
security:
  - partnerBearer: []
  - apiKey: []
tags:
  - name: Linking
    description: >-
      Linking that starts in your app — OIDC against Bilt's IdP, then one call
      here.
  - name: Link status
    description: Read the current link for one of your accounts.
  - name: Unlinking
    description: Break a link from your side.
  - name: CIBA linking
    description: >-
      Settle a link that started in the Bilt app — only for integrations whose
      agreement includes CIBA.
paths:
  /v1/account-links/oauth-link:
    post:
      tags:
        - Linking
      summary: Create a link
      description: |
        Completes a link for a member who has just authorized you through
        Bilt's IdP. Bilt validates the `idToken` (signature against the
        environment's JWKS, `iss`, `aud`, `exp`, single-use `jti`), reads the
        Bilt member id from `sub`, and links it to your `partnerUserId`.

        `partnerUserId` must be **stable** for the lifetime of the account. It
        is the only handle you have on the link afterwards: status lookups are
        keyed by it, and unlinks name it. Do not send an email address, a
        phone number, or any other personal data in this field.

        **Idempotent only for a byte-identical retry.** Resending the same
        `idToken` with the same `partnerUserId` returns `200` with the
        existing link while it is still active — the sanctioned path after a
        timeout or a lost response. The same token with a different
        `partnerUserId`, or after the link was broken, is
        `409 ID_TOKEN_REPLAYED`: the token is spent, mint a fresh one.

        Keep the `linkId` from the response or fetch it later from the status
        read — `POST /unlink` requires it.
      operationId: partnerOauthLink
      parameters:
        - $ref: '#/components/parameters/XRequestId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/OAuthLinkRequest'
            example:
              idToken: >-
                eyJhbGciOiAiUlMyNTYiLCAidHlwIjogIkpXVCIsICJraWQiOiAic2FuZGJveCJ9.eyJpc3MiOiAiaHR0cHM6Ly9zdGFnaW5nLmJpbHRyZXdhcmRzLmNvbS9yZWFsbXMvQklMVCIsICJhdWQiOiAiYWNtZS1pZHAtY2xpZW50LXNhbmRib3giLCAic3ViIjogIjRmNjZlNTkxLTJjZDktNGFiMS05ZmY3LTBiMmU2MmNiMjAxNSIsICJleHAiOiAxNzg3NjA5NzEyLCAiaWF0IjogMTc4NzYwOTQxMiwgImp0aSI6ICI3YzllNjY3OS03NDI1LTQwZGUtOTQ0Yi1lMDdmYzFmOTBhZTcifQ.sandbox_signature
              partnerUserId: acme_customer_sandbox_123
      responses:
        '200':
          description: |
            Link created — or, on an idempotent retry of the identical request,
            the existing active link. `partnerDetails` is not part of this
            response; read it from the status endpoint.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/LinkResponse'
              example:
                linkId: 9f1c2e4a-7b30-4d55-8e21-6c0b9a3f5d78
                partnerUserId: acme_customer_sandbox_123
                linkStatus: LINKED
                linkedAt: '2026-07-31T13:45:00Z'
        '400':
          $ref: '#/components/responses/ValidationFailure'
        '401':
          $ref: '#/components/responses/UnauthorizedOrInvalidToken'
        '409':
          $ref: '#/components/responses/LinkConflict'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '500':
          $ref: '#/components/responses/ServerError'
        '502':
          $ref: '#/components/responses/IdpUnavailable'
components:
  parameters:
    XRequestId:
      name: x-request-id
      in: header
      required: false
      description: |
        Optional but strongly recommended: a unique id per request (a UUID is
        fine). Echoed back as `error.requestId` so your logs and Bilt's line
        up. Quote it in every support ticket.
      schema:
        type: string
        example: 6f6b1afd-22e0-4137-a096-b15e41cdc890
  schemas:
    OAuthLinkRequest:
      type: object
      required:
        - idToken
        - partnerUserId
      properties:
        idToken:
          type: string
          minLength: 1
          maxLength: 8192
          description: |
            The `id_token` Bilt's IdP returned from the authorization-code
            exchange, passed through exactly as the compact JWS string. Its
            `sub` identifies the Bilt member; its `aud` must be your IdP
            client id; its `jti` is single-use.
        partnerUserId:
          type: string
          minLength: 1
          maxLength: 256
          description: |
            Your opaque, **stable** identifier for the account being linked.
            The only handle you have on the link afterwards. Never an email
            address, phone number, or name.
          example: acme_customer_sandbox_123
    LinkResponse:
      type: object
      description: |
        Partner-facing view of a link. Returned by `oauth-link` (without
        `partnerDetails`) and by the status read.
      required:
        - linkId
        - partnerUserId
        - linkStatus
      properties:
        linkId:
          type: string
          format: uuid
          description: |
            The id of this link **episode**, and the value `POST /unlink`
            requires. One account legitimately owns several historical links,
            so ending one means naming which. Published on both the create
            response and the status read, so you can always recover it rather
            than having to store it.
          example: 9f1c2e4a-7b30-4d55-8e21-6c0b9a3f5d78
        partnerUserId:
          type: string
          description: Your opaque user id, echoed back.
          example: acme_customer_sandbox_123
        linkStatus:
          $ref: '#/components/schemas/LinkStatus'
        linkedAt:
          type: string
          format: date-time
          nullable: true
          description: When the link became `LINKED`, ISO-8601 UTC.
          example: '2026-07-31T13:45:00Z'
        partnerDetails:
          type: object
          additionalProperties: true
          nullable: true
          description: |
            Non-sensitive metadata Bilt holds about the link. Present on the
            status read only; empty (`{}`) for this integration model —
            reserved for future use.
    LinkStatus:
      type: string
      description: |
        The states you can observe on the `oauth-link` response and the
        status read. `LINKED` is the only usable state. `UNLINKED` and
        `LINK_REVOKED` are terminal for that link; the next successful
        `oauth-link` creates a fresh one. `UNLINKING_IN_PROGRESS` is only
        reachable for integrations where Bilt calls you on unlink — unusable
        but not terminal; re-read later. Do not code against values outside
        this list.
      enum:
        - LINKED
        - UNLINKED
        - LINK_REVOKED
        - UNLINKING_IN_PROGRESS
      example: LINKED
    ConstraintViolationResponse:
      type: object
      description: |
        The shape of every validation `400` on this API — **not** the error
        envelope. Documented so you do not write a parser that assumes
        `error.code` on every non-2xx response.
      required:
        - title
        - status
        - violations
      properties:
        title:
          type: string
          example: Constraint Violation
        status:
          type: integer
          example: 400
        violations:
          type: array
          items:
            type: object
            properties:
              field:
                type: string
                example: partnerUserId
              message:
                type: string
                example: must not be blank
    ErrorResponse:
      type: object
      description: |
        Error envelope for every business failure (`401`, `404`, `409`,
        `500`, `502`, and the CIBA `400 INVALID_STATE_TOKEN`). Bean Validation
        failures do **not** use it — see `ConstraintViolationResponse`. Parse
        leniently: the envelope may carry additional fields beyond the three
        documented here, and none of them changes how you should react.
      required:
        - error
      properties:
        error:
          type: object
          required:
            - code
          properties:
            code:
              type: string
              description: >-
                **Branch on this, never on `message`.** Codes are stable;
                messages are not.
              example: ID_TOKEN_REPLAYED
            message:
              type: string
              description: Detail for your logs. Not intended for end users.
              example: id_token has already been used
            requestId:
              type: string
              description: |
                Your `x-request-id` when you sent one, else a generated id.
                Empty whenever your credential is refused — the `401` for a
                missing, unrecognized or disabled API key or partner access
                token, and the `502 IDP_UNAVAILABLE` raised while validating a
                partner access token — because those are rejected before the
                correlation step runs (guide §9.1).
              example: 6f6b1afd-22e0-4137-a096-b15e41cdc890
  responses:
    ValidationFailure:
      description: |
        A field was missing, blank, or too long. **Not the error envelope** —
        the framework answers with a `ConstraintViolationResponse`. **Treat
        the body as optional:** a body that is not valid JSON, or a value of
        the wrong type (a non-UUID `linkId`), fails deserialization first and
        answers `400` with no parseable body at all, so branch on the status
        before you parse. Fix the request; do not retry as-is.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ConstraintViolationResponse'
          example:
            title: Constraint Violation
            status: 400
            violations:
              - field: partnerUserId
                message: must not be blank
    UnauthorizedOrInvalidToken:
      description: |
        Either the partner credential was rejected (`UNAUTHORIZED`), or the
        `idToken` failed validation. `INVALID_ID_TOKEN` also covers Bilt
        being unable to verify against its own IdP — mint a fresh token and
        retry **once**, then stop and alert.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          examples:
            unauthorized:
              value:
                error:
                  code: UNAUTHORIZED
                  message: Unknown partner client
                  requestId: ''
            invalidIdToken:
              value:
                error:
                  code: INVALID_ID_TOKEN
                  message: id_token signature, issuer, or subject is invalid
                  requestId: request_sandbox_201
            idTokenExpired:
              value:
                error:
                  code: ID_TOKEN_EXPIRED
                  message: id_token has expired
                  requestId: request_sandbox_202
            audienceMismatch:
              value:
                error:
                  code: ID_TOKEN_AUDIENCE_MISMATCH
                  message: id_token aud does not match the partner's IdP client
                  requestId: request_sandbox_203
    LinkConflict:
      description: |
        The link could not be created as requested. `ALREADY_LINKED` and
        `IDENTITY_ALREADY_LINKED` are the two uniqueness rules — resolve the
        conflict, then link again. `ID_TOKEN_REPLAYED` means the token is
        spent — mint a fresh one. `CONCURRENT_UPDATE` is a race — resend the
        identical request.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          examples:
            alreadyLinked:
              summary: This Bilt member is linked to a different account of yours
              value:
                error:
                  code: ALREADY_LINKED
                  message: Bilt member already linked to another partner account
                  requestId: request_sandbox_206
            identityAlreadyLinked:
              summary: This account of yours is linked to a different Bilt member
              value:
                error:
                  code: IDENTITY_ALREADY_LINKED
                  message: Partner account already linked to another Bilt member
                  requestId: request_sandbox_207
            idTokenReplayed:
              summary: The id_token was already used
              value:
                error:
                  code: ID_TOKEN_REPLAYED
                  message: id_token has already been used
                  requestId: request_sandbox_208
            concurrentUpdate:
              summary: Two writes raced — resend the identical request
              value:
                error:
                  code: CONCURRENT_UPDATE
                  message: Concurrent update on the same link
                  requestId: request_sandbox_209
    UnsupportedMediaType:
      description: |
        The request body was missing or sent with a `Content-Type` other than
        `application/json`. Framework-level response, no body.
    ServerError:
      description: |
        A Bilt-side problem — `PARTNER_MISCONFIGURED` or `INTERNAL_ERROR`,
        or one of the "codes you should not see" in the guide's
        [§9.2](/account-linking/account-links-api#9-2-error-code-reference).
        Not fixable by retrying — report it with the `requestId`.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          example:
            error:
              code: INTERNAL_ERROR
              message: Unexpected server error
              requestId: request_sandbox_214
    IdpUnavailable:
      description: |
        Bilt could not reach its own IdP to validate a token — the `idToken`
        on `oauth-link`, or your partner access token on any endpoint. Retry
        with backoff, resending the identical request. When it is your
        credential being validated, the response is raised before the
        correlation step and carries an empty `requestId` (guide §9.1); on
        `ciba-callback` nothing was settled and the token is not spent.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          examples:
            idTokenValidation:
              summary: oauth-link — validating the idToken
              value:
                error:
                  code: IDP_UNAVAILABLE
                  message: Bilt IdP unreachable during id_token validation
                  requestId: request_sandbox_213
            partnerTokenValidation:
              summary: any endpoint — validating your partner access token
              value:
                error:
                  code: IDP_UNAVAILABLE
                  message: Bilt IdP unreachable during partner token validation
                  requestId: ''
  securitySchemes:
    partnerBearer:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: >
        **Preferred.** A partner access token minted by **your partner

        client** with the client-credentials grant — the client you

        authenticate **as**, with no member involved. Send it as

        `Authorization: Bearer <access_token>` and on no other header. It is

        a different client from the IdP one that mints member `id_token`s,

        and it is not the member `access_token` the code exchange returns —

        see the guide's

        [§5
        Authentication](/account-linking/account-links-api#5-authentication).
    apiKey:
      type: apiKey
      in: header
      name: x-api-key
      description: |
        **Being retired.** The older per-partner credential, still issued at
        onboarding and still accepted; the partner client is what
        authenticates these calls once you adopt it. Do not send it alongside
        a bearer token — a rejected token is never rescued by a valid key.

````