Skip to main content
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.

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

2.2 What Bilt implements


3. End-to-end linking flow


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 Body 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).

4.2 Response

Account found — 200 OK
Account not found — 200 OK
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

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

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

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

5.5 Example

5.6 Errors

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

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)

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.

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
Like /bilt/link-account, serve this endpoint over mutual TLS (mTLS) and validate Bilt’s client certificate in addition to the bearer token.
Body

7.2 Response

200 OK
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 }.

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.
Unlike the rest of this page, this endpoint is already live — and its contract is specified on Partner to Bilt Account Linking §7.3. 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.
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