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

# Settle a CIBA link

> Delivers your customer's verdict for a link that started in the Bilt
app. Bilt matches `notificationToken` against the pending request
minted for **your** integration — it is the anti-forgery proof, so a
partner credential cannot settle another partner's link — and applies
your `params` through your integration's handler on Bilt's side.

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

| `attempt` | Meaning |
| --------- | ------- |
| `OK` | This delivery was an approval Bilt accepted — ordinarily `linkStatus: LINKED`. If Bilt's expiry sweep had already failed the pending link when your approval arrived, `linkStatus` is `LINK_FAILED`: too late, the member re-initiates |
| `PARTNER_REJECTED` | This delivery settled the link as `LINK_FAILED` because your customer declined. The standard decline code; if your onboarding agreed a different one, that code arrives here instead |
| *(any other code from the guide's §9.2)* | This delivery settled the link as `LINK_FAILED` with that code — most often `ALREADY_LINKED` / `IDENTITY_ALREADY_LINKED` (a conflicting link), or `PARTNER_UNAVAILABLE` (Bilt could not interpret your delivery). Once the token is claimed, even the codes listed as `500`s arrive this way, in-band on a `200` |
| `ALREADY_CLAIMED` | This delivery settled nothing: the token was already claimed — usually your own earlier delivery whose `200` was lost. `linkStatus` carries the settled state; **stand down** |
| `IGNORED` | This delivery settled nothing: recognized as stale or duplicate and dropped |

Redelivery is a `200`, never an error, so a retry loop can stand down
on any `200`. Uniqueness conflicts arrive in-band as `attempt`, never
as a `409` — a spent token behind an error would strand the member. The
only `409` here is a rare `CONCURRENT_UPDATE` raised while settling,
after the token was claimed — not retryable; the member re-initiates.
Full semantics: the guide's
[§7.4](/account-linking/account-links-api#7-api-reference).




## OpenAPI

````yaml /account-linking/account-links-openapi.yaml post /v1/account-links/ciba-callback
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/ciba-callback:
    post:
      tags:
        - CIBA linking
      summary: Settle a CIBA link
      description: >
        Delivers your customer's verdict for a link that started in the Bilt

        app. Bilt matches `notificationToken` against the pending request

        minted for **your** integration — it is the anti-forgery proof, so a

        partner credential cannot settle another partner's link — and applies

        your `params` through your integration's handler on Bilt's side.


        Read the two response fields as a **pair**: `linkStatus` is what is

        true now; `attempt` is what this delivery did.


        | `attempt` | Meaning |

        | --------- | ------- |

        | `OK` | This delivery was an approval Bilt accepted — ordinarily
        `linkStatus: LINKED`. If Bilt's expiry sweep had already failed the
        pending link when your approval arrived, `linkStatus` is `LINK_FAILED`:
        too late, the member re-initiates |

        | `PARTNER_REJECTED` | This delivery settled the link as `LINK_FAILED`
        because your customer declined. The standard decline code; if your
        onboarding agreed a different one, that code arrives here instead |

        | *(any other code from the guide's §9.2)* | This delivery settled the
        link as `LINK_FAILED` with that code — most often `ALREADY_LINKED` /
        `IDENTITY_ALREADY_LINKED` (a conflicting link), or `PARTNER_UNAVAILABLE`
        (Bilt could not interpret your delivery). Once the token is claimed,
        even the codes listed as `500`s arrive this way, in-band on a `200` |

        | `ALREADY_CLAIMED` | This delivery settled nothing: the token was
        already claimed — usually your own earlier delivery whose `200` was
        lost. `linkStatus` carries the settled state; **stand down** |

        | `IGNORED` | This delivery settled nothing: recognized as stale or
        duplicate and dropped |


        Redelivery is a `200`, never an error, so a retry loop can stand down

        on any `200`. Uniqueness conflicts arrive in-band as `attempt`, never

        as a `409` — a spent token behind an error would strand the member. The

        only `409` here is a rare `CONCURRENT_UPDATE` raised while settling,

        after the token was claimed — not retryable; the member re-initiates.

        Full semantics: the guide's

        [§7.4](/account-linking/account-links-api#7-api-reference).
      operationId: partnerCibaCallback
      parameters:
        - $ref: '#/components/parameters/XRequestId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CibaCallbackRequest'
            examples:
              approved:
                summary: Customer approved in your app
                value:
                  notificationToken: nt_sandbox_4f1c9be2
                  params:
                    result: approved
              declined:
                summary: Customer declined in your app
                value:
                  notificationToken: nt_sandbox_4f1c9be2
                  params:
                    result: declined
      responses:
        '200':
          description: |
            Delivery resolved — settled, duplicate, or ignored. Read `attempt`
            together with `linkStatus`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CibaCallbackResponse'
              examples:
                approved:
                  summary: This delivery settled the link
                  value:
                    linkStatus: LINKED
                    attempt: OK
                declined:
                  summary: This delivery recorded the decline
                  value:
                    linkStatus: LINK_FAILED
                    attempt: PARTNER_REJECTED
                redelivered:
                  summary: Your earlier delivery already won — stand down
                  value:
                    linkStatus: LINKED
                    attempt: ALREADY_CLAIMED
                conflict:
                  summary: The approval raced a conflicting link
                  value:
                    linkStatus: LINK_FAILED
                    attempt: IDENTITY_ALREADY_LINKED
        '400':
          $ref: '#/components/responses/CibaBadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '409':
          $ref: '#/components/responses/CibaConflict'
        '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:
    CibaCallbackRequest:
      type: object
      required:
        - notificationToken
      properties:
        notificationToken:
          type: string
          minLength: 1
          maxLength: 512
          description: |
            The single-use token Bilt sent in the backchannel request. The
            anti-forgery proof: Bilt matches it against the pending request
            minted for **your** integration.
          example: nt_sandbox_4f1c9be2
        params:
          type: object
          nullable: true
          maxProperties: 32
          additionalProperties:
            type: string
            maxLength: 2048
          description: |
            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. Up to 32 entries,
            string values, keys up to 128 characters, values up to 2048.
          example:
            result: approved
    CibaCallbackResponse:
      type: object
      required:
        - linkStatus
        - attempt
      properties:
        linkStatus:
          type: string
          description: |
            The link state **after** this delivery — what is true now, not
            what this delivery did. The CIBA settle endpoint reports a wider
            set than the other endpoints: `LINKING_IN_PROGRESS` (the winning
            delivery is still settling) and `LINK_FAILED` (declined, or a
            conflict — see `attempt`) are possible here.
          enum:
            - LINKING_IN_PROGRESS
            - LINKED
            - LINK_FAILED
            - UNLINKED
            - LINK_REVOKED
          example: LINKED
        attempt:
          type: string
          description: >
            What this delivery did: `OK` (an accepted approval — `LINKED`, or

            `LINK_FAILED` when the approval came after the pending link
            expired),

            `ALREADY_CLAIMED` (the token was already claimed — stand down),

            `IGNORED` (stale or duplicate, dropped), or the code the link

            settled `LINK_FAILED` with — `PARTNER_REJECTED` for a decline,

            `ALREADY_LINKED` / `IDENTITY_ALREADY_LINKED` for a conflict,

            `PARTNER_UNAVAILABLE` when Bilt could not interpret the delivery,

            and, once the token is claimed, any other code from the guide's

            §9.2 including the ones listed there as `500`s.
          example: OK
    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:
    CibaBadRequest:
      description: |
        Either a validation failure (`ConstraintViolationResponse`, see the
        guide's [§9 Errors](/account-linking/account-links-api#9-errors)) or
        `INVALID_STATE_TOKEN` in the error
        envelope: the `notificationToken` never existed for your integration,
        or expired before any delivery arrived. Nothing to retry — the member
        starts over in the Bilt app.
      content:
        application/json:
          schema:
            oneOf:
              - $ref: '#/components/schemas/ConstraintViolationResponse'
              - $ref: '#/components/schemas/ErrorResponse'
          examples:
            invalidStateToken:
              value:
                error:
                  code: INVALID_STATE_TOKEN
                  message: Unknown or expired notificationToken
                  requestId: request_sandbox_212
            validation:
              value:
                title: Constraint Violation
                status: 400
                violations:
                  - field: cibaCallback.request.notificationToken
                    message: must not be blank
    Unauthorized:
      description: |
        Credential missing, not recognized, or disabled — or a bearer token
        Bilt rejected (there is no fallback to the API key). This `401` is
        raised before the correlation step and carries an empty `requestId`;
        quote the timestamp and endpoint instead.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          example:
            error:
              code: UNAUTHORIZED
              message: Unknown partner client
              requestId: ''
    CibaConflict:
      description: |
        `CONCURRENT_UPDATE`: two writes hit the link at once while it was being
        settled, after your delivery had claimed the token. Rare, and **not**
        retryable here — the token is spent, a resend answers `ALREADY_CLAIMED`,
        Bilt's sweep fails the pending link and the member re-initiates. Quote
        the `requestId`. Uniqueness conflicts never arrive as a `409` here —
        they come back in-band as `attempt` on a `200`.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          example:
            error:
              code: CONCURRENT_UPDATE
              message: Concurrent update on the same link
              requestId: request_sandbox_215
    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.

````