habuild-connect
v0.3.0
Published
TypeScript SDK for Habuild partner organisations
Downloads
726
Readme
habuild-connect
TypeScript SDK for Habuild partner organisations: sign members up with a phone OTP or log them in by your own member id (the token way), look them up and update them from your backend, and verify the webhooks Habuild sends you.
habuild-connect/clientruns in a browser or webview, with a publishablepk_key.habuild-connect/serverruns on your backend, with a secretsk_key.
Zero runtime dependencies. Node 20 or later (it uses the global fetch). ESM and CommonJS.
habuild-connect/client needs iOS Safari 15+ or an Android WebView with ES2020 support. Please
confirm your oldest supported webview with us before you integrate.
Install
npm install habuild-connectWhich key goes where
| Key | Prefix | Use it in | Can do |
| --- | --- | --- | --- |
| Publishable | pk_ | habuild-connect/client — browser, webview, mobile app | Read your org config, send and verify OTPs |
| Secret | sk_ | habuild-connect/server — your backend only | Log members in by externalRef (login), look up (getMember) and update (updateMember) members |
Never ship an sk_ key to a browser or an app. Anyone who opens the page or unpacks the app can
read it. The SDK guards against the obvious mistakes:
HabuildConnectClientthrowsInvalidKeybefore any request if it is given ansk_key.HabuildConnect(server) throws ifwindowexists, so it will not run in a browser.
Keep the secret key and the webhook secret in environment variables, never in source control.
Client: OTP sign-up in a webview
import {
HabuildConnectClient,
InvalidOtp,
OtpExpired,
RateLimited,
} from "habuild-connect/client";
const habuild = new HabuildConnectClient({ publishableKey: "pk_live_..." });
const config = await habuild.getConfig(); // orgId, slug, configVersion, registrationMethods, config (OrgConfigV1)
// 1. Send the OTP. Phone is E.164, e.g. +919999999999.
const { requestId, expiresInSeconds, resendAfterSeconds } = await habuild.sendOtp({
phone: "+919999999999",
name: "Asha",
});
// 2. Verify the code the member typed.
try {
const result = await habuild.verifyOtp({
phone: "+919999999999",
requestId,
otp: "123456",
// Required when your org config sets consent.required. Both fields are optional;
// acceptedAt defaults to when Habuild receives it.
consent: { version: "v1", acceptedAt: new Date().toISOString() },
});
if ("accountId" in result) {
// The member is now in your org: result.accountId, result.isNew, result.moved,
// result.enrollment, result.session.
} else {
// The account stays in its current org: you get result.enrollment only, no account id or session.
}
} catch (err) {
if (err instanceof InvalidOtp) {
// wrong code: let the member try again
} else if (err instanceof OtpExpired) {
// send a new OTP
} else if (err instanceof RateLimited) {
// wait err.retryAfter seconds (null when the server did not say; always null in a browser)
} else {
throw err;
}
}Events, if you prefer listening to awaiting. on returns an unsubscribe function:
const off = habuild.on("registered", (result) => {
console.log("member in org", result.accountId);
});
habuild.on("error", (err) => console.error(err)); // fires before the call rejects
off();registered fires only when verifyOtp returns an accountId. A listener that throws never breaks
the SDK call.
Retry-After is not readable from browser JS (CORS), so RateLimited.retryAfter is always null in
a browser or webview. Use resendAfterSeconds from sendOtp to pace resends.
If a request carries an Origin header (any browser or webview page does), that origin must be on
your org's allowed-origins list, otherwise the call fails with OriginNotAllowed. Ask Habuild to add
your origins.
Server: look up a member
import { HabuildConnect } from "habuild-connect/server";
const habuild = new HabuildConnect({ secretKey: process.env.HABUILD_SECRET_KEY! });
const member = await habuild.getMember({ phone: "+919999999999" });
if (member.inOrg) {
console.log(member.accountId, member.enrollment);
} else {
// { inOrg: false }
}The phone must be E.164 (+919999999999). A local format such as 09999999999 is rejected with a
ValidationError (400).
Not found means "not an active member of your org". You get exactly
{ inOrg: false } for any phone outside your org — never registered, a regular
Habuild user, or another partner's member — and also for a banned, deleted or staff account in your
own org. Habuild never tells you which.
Enrollment status
member.enrollment is { status, challengeId } or null:
| enrollment | Meaning | What to do |
| --- | --- | --- |
| { status: "ALREADY_ENROLLED" } | Already in a challenge of your org's type | Nothing |
| { status: "IN_ANOTHER_CHALLENGE" } | In a different challenge | Nothing |
| { status: "SKIPPED_PAID" } | A paid Habuild member, so not enrolled | Nothing |
| { status: "FAILED" } | From getMember: your org's setup is incomplete (no challenge type, or no free-member record for this member). A retry never changes it | Ask Habuild to check your org's setup |
| { status: "NOT_ELIGIBLE" } | Not eligible right now (for example no open batch, or their trial is over) | Nothing |
| { status: "NOT_ENROLLED" } | Eligible but in no challenge | Send the member through OTP verify to enrol them |
| null | The eligibility check failed | Retry later |
verifyOtp also returns an enrollment. It adds ENROLLED (enrolled just now), and can return
NOT_ENROLLED when it lost a race with a concurrent move. From verifyOtp, FAILED can also be a temporary
failure: the member's next verify retries enrolment. null only ever comes from getMember.
A paid Habuild member who verifies gets a status-only result with SKIPPED_PAID: they are not moved
into your org and get no session. A banned account is refused earlier, at sendOtp, with
account_banned (403); a staff account gets account_not_eligible (403). The full result's session is
{ accessToken, refreshToken, accessTokenExpiresIn }, where accessTokenExpiresIn is seconds until the
access token expires.
getMember is a GET, so the SDK retries it up to twice on network errors, timeouts and 5xx.
You can also look a member up by your own id: habuild.getMember({ externalRef: "member-42" })
returns the same result. Pass exactly one of phone or externalRef; passing both or neither is
rejected by the SDK before any request, as a ValidationError with status 0. A malformed
externalRef is InvalidExternalRef (400).
Token way + opening Habuild with a handoff URL
Use the token way for members who have no phone number on your side, or when you already know who
they are and don't want an OTP. Your org must have token mode switched on by Habuild; otherwise
login fails with TokenModeDisabled (403). It needs the sk_ key, so it runs on your server only
(habuild-connect/client has no login). Never ship the sk_ key in an app — your app asks your
server, your server calls login.
const { accountId, isNew, enrollment, handoff, session } = await habuild.login({
externalRef: "member-42", // your stable member id
name: "Asha Rao", // used if this login creates the account
consent: { version: "2026-10.v1", acceptedAt: new Date().toISOString() }, // when your config requires it
});
// Send handoff.url to the app and open it in the webview/browser right away.- Call
loginon every open. The handoff code works once and expires in 60 seconds (handoff.expiresInSeconds). Never cache it or the URL; get a fresh one each time. - Open
handoff.urlright away in the webview or browser. It logs the member in to Habuild. session({ accessToken, refreshToken, accessTokenExpiresIn }) is only returned when Habuild enables server sessions for your org: a 1 hour access token and a 7 day refresh token. Otherwise it is absent.externalRefmust be your stable member id, never reused for another person: the sameexternalRefalways logs in the same Habuild account. 1-255 characters fromA-Z a-z 0-9 . _ : @ -. Anything else isInvalidExternalRef(400).- Consent: when your org's config sets
consent.required, passconsent(versionand an ISO-8601acceptedAt), otherwise the call fails withconsent_required(400). enrollmenthas the same meaning as in Enrollment status.
The SDK retries login up to twice, but only when Habuild was unavailable: network errors and
502/503/504 (including 503 token_login_busy). That is safe: the server maps your externalRef to
one account, so a repeat converges on the same account instead of creating a second one. A timeout
or a 500 is not retried, because the first attempt may have succeeded.
isNew can be false on a retry even if the first attempt created the account; to run first-time
onboarding exactly once, use the member.registered webhook.
| Status | err.code | Meaning | What to do |
| --- | --- | --- | --- |
| 403 | token_mode_disabled (TokenModeDisabled) | Token mode is not on for your org | Ask Habuild to enable it |
| 400 | invalid_external_ref (InvalidExternalRef) | externalRef is empty, too long or has disallowed characters | Fix the id |
| 400 | consent_required | Your config requires consent and none was sent | Send consent |
| 400 | invalid_cobranding | cobranding is not an object, or is over 4 KB | Fix the payload |
| 409 | account_in_other_org | The account mapped to this externalRef has moved to another org | Don't retry |
| 403 | account_banned | The account is banned | Don't retry |
| 403 | account_not_eligible | The account mapped to this externalRef is no longer eligible (deleted, or a staff account). Habuild doesn't say which | Don't retry |
| 403 | org_inactive (OrgInactive) | Your org's integration is not active | Ask Habuild |
| 503 | token_login_busy | Habuild is busy with a login for this member | Already retried by the SDK; try again shortly |
| 429 | rate_limited (RateLimited) | Too many requests, or your org's daily new-account cap | Wait retryAfter seconds |
Consent in the Habuild WebView
openHabuild({ externalRef, name }) lets the member consent inside Habuild instead of in your app.
Call it on your backend on every open, then open the returned URL in the WebView right away:
const res = await habuild.openHabuild({ externalRef: user.id, name: user.name });
const url = res.status === "registered" ? res.handoff.url : res.joinUrl;registered: the member is already linked;handoff.urlsigns them in.join:joinUrlshows consent and joins the member. It lasts 30 minutes and works once; if it has expired, callopenHabuildagain.
Errors are the same as login above, except consent_required (open takes no consent). Never put the sk_ key in the app.
Your clients' branding
If you offer Habuild to your own clients, pass cobranding to openHabuild (or login) and Habuild
opens in that client's look. It is layered over your org config, for this member only:
const res = await habuild.openHabuild({
externalRef: user.id,
name: user.name,
cobranding: {
brandKey: "hdfc", // your id for the client: a-z, 0-9 and -, 2-40 characters
branding: {
mode: "co_branded",
partnerName: "HDFC Bank",
logoUrl: "https://cdn.doconline.com/clients/hdfc/logo.png",
theme: { colors: { primary: "#004C8F", primaryForeground: "#FFFFFF" } },
},
copy: { headline: "Yoga for HDFC Bank employees" },
},
});
for (const { field, reason } of res.cobranding?.fallbacks ?? []) {
console.warn(`cobranding ${field} not used: ${reason}`);
}- You can set
branding.mode,displayName,partnerName,logoUrl,logoDarkUrl,faviconUrl,bannerUrl,theme(colours,radius,fontFamily) andcopy.headline,subheadline,successMessage. Consent, the call-to-action, the programme name and page sections always come from your org config. - Images must be
https://URLs on a host Habuild has allowed for your org. Ask Habuild to add your image hosts. - A bad field never fails the call. It is dropped, your org config's value is used instead, and
cobranding.fallbackslists it with a reason:not_hex_color,not_https,host_not_allowed,not_in_allowlist,out_of_range,too_long,unknown_field,contrast_failed,invalid_brand_keyorinvalid_value. Colours you set forprimary/primaryForegroundorforeground/backgroundare dropped ascontrast_failedwhen the pair is below 4.5:1. Only acobrandingthat is not an object, or is over 4 KB, fails the call (invalid_cobranding, 400). - Until Habuild launches co-branding for your org, fields are checked and reported but not shown to
the member, and webhooks carry
brandKey: null. - Token-way
member.registeredandmember.enrolledwebhooks carry thebrandKey, so you can tell which client a member came from.
Server: update a member
const { accountId, updated } = await habuild.updateMember("3f2b8c1e-9a4d-4e7b-8c21-5d6f0a9b7e43", {
name: "Asha Rao",
email: "[email protected]",
dob: "1990-04-21", // YYYY-MM-DD
gender: "FEMALE", // MALE | FEMALE | OTHER | PREFER_NOT_TO_SAY
city: "Pune",
});
// updated: the names of the fields that were filled, e.g. ["dob", "city"]updateMember only backfills: a field the member already has (email included) is skipped quietly
and left out of updated, never overwritten. Every field is optional. The member must be in your org.
An invalid field value is a ValidationError (400). An empty accountId is rejected by the SDK
before any request, as a ValidationError with status 0.
| Status | err.code | Meaning | What to do |
| --- | --- | --- | --- |
| 404 | member_not_in_org | The account is not a member of your org | Don't retry |
| 409 | email_in_use | Another account uses that email | Don't retry with the same email |
| 422 | member_update_rejected | The update was refused and will fail the same way again | Don't retry |
| 503 | member_update_failed | The update could not be completed | Safe to retry with the same body |
After a 503, the first attempt may already have saved. A retry then returns updated: [], meaning
nothing left to change, not a failure.
updateMember is never retried automatically. These errors are plain HabuildConnectErrors; check
err.code and err.status.
Webhooks
Habuild POSTs events to your webhook URL with a Habuild-Signature header. Every event has the same
envelope:
{
"id": "evt_...",
"type": "member.registered",
"createdAt": "2026-10-19T10:00:00.000Z",
"orgId": 42,
"data": {}
}type is one of these, and data depends on it (HabuildWebhookEvent is a union you can narrow on
event.type):
| type | data |
| --- | --- |
| member.registered | accountId, isNew, moved, name?, phone? (E.164, OTP sign-ups), externalRef? (token way), email?, registeredAt (ISO-8601), brandKey? (token way: string or null) |
| member.enrolled | accountId, status (an enrollment status), challengeId (number or null), brandKey? (token way: string or null) |
| member.updated | accountId, changedFields (any of name, email, dob, gender, city) |
| webhook.test | message |
member.registered is sent when a member is created in or moved into your org, not on every login.
New event types may be added. Handle an unknown type by ignoring it and answering 2xx; don't treat
these four as the full list.
Verify every request with constructEvent(rawBody, signatureHeader, secret). It throws
WebhookSignatureInvalid when the signature is wrong, malformed or older than 5 minutes.
The secret is the whole string, whsec_ prefix included, used as UTF-8 bytes. Don't strip the
prefix or hex-decode it.
You need the raw body. The signature covers the exact bytes Habuild sent. If your framework parses the JSON and you stringify it again, the bytes change and every check fails.
Dedupe on event.id. Failed deliveries are retried, so the same event can arrive more than once, and a captured request can be
replayed within the 5-minute window. Store the ids you have processed and skip repeats.
Express
import express from "express";
import { constructEvent, WebhookSignatureInvalid } from "habuild-connect/server";
const app = express();
// express.raw keeps the body as a Buffer. Register it on this route, before any express.json().
app.post("/webhooks/habuild", express.raw({ type: "application/json" }), (req, res) => {
try {
const event = constructEvent(
req.body,
req.header("Habuild-Signature"),
process.env.HABUILD_WEBHOOK_SECRET!,
);
// if (alreadyProcessed(event.id)) return res.sendStatus(200);
console.log(event.type, event.data);
res.sendStatus(200);
} catch (err) {
if (err instanceof WebhookSignatureInvalid) {
res.sendStatus(400);
return;
}
throw err;
}
});Fastify
import Fastify from "fastify";
import { constructEvent, WebhookSignatureInvalid } from "habuild-connect/server";
const app = Fastify();
// Keep JSON bodies as the raw string. This applies to every route in this Fastify instance;
// register the webhook route in its own plugin if other routes need parsed JSON.
app.addContentTypeParser("application/json", { parseAs: "string" }, (_req, body, done) => {
done(null, body);
});
app.post("/webhooks/habuild", async (req, reply) => {
try {
const event = constructEvent(
req.body as string,
req.headers["habuild-signature"] as string | undefined,
process.env.HABUILD_WEBHOOK_SECRET!,
);
// if (alreadyProcessed(event.id)) return reply.code(200).send();
console.log(event.type, event.data);
return reply.code(200).send();
} catch (err) {
if (err instanceof WebhookSignatureInvalid) return reply.code(400).send();
throw err;
}
});constructEvent is also available as habuild.webhooks.constructEvent on a HabuildConnect
instance. Options: { toleranceSeconds } (default 300).
Verifying the signature without this SDK
For a backend in another language. The header looks like:
Habuild-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256(secret, "<t>.<rawBody>")>- Split the header on
,and each part on the first=. Taket(Unix time in seconds) and everyv1value. Reject the request if there is not exactly onet, or nov1. - Reject it if
|now − t|is more than 300 seconds. - Compute
HMAC-SHA256(secret, "<t>.<rawBody>")— the key is your webhook secret, the message is thetvalue, a literal., then the raw request body bytes — and hex-encode it (lowercase). - Compare that hex string with each
v1value using a constant-time comparison (for example Python'shmac.compare_digest, Go'shmac.Equal). Accept if any matches. - Only then parse the body as JSON, and dedupe on its
id.
Test vector: secret whsec_test, t = 1760868000, raw body {"id":"evt_test"} gives
v1=47f29b1bcd4a950167cf303ecb8b39d884e3b826279fb4d0f7918779ed938b2c.
Errors
Every error the SDK throws is a HabuildConnectError with code (string) and status (the HTTP
status, or 0 when no response was received). All classes are exported from both entry points,
except TokenModeDisabled and InvalidExternalRef, which only habuild-connect/server exports.
| Class | code | Typical status | When |
| --- | --- | --- | --- |
| InvalidKey | invalid_key | 401, or 0 | Key unknown, revoked or expired, or a key for the other environment; 0 when the key is malformed or the wrong kind for the entry point |
| OrgInactive | org_inactive | 403 | Your org's integration is not active |
| ScopeNotAllowed | scope_not_allowed | 403 | The key does not have the scope this call needs |
| OriginNotAllowed | origin_not_allowed | 403 | The page's origin is not on your allowed list |
| InvalidOtp | invalid_otp | 400 | Wrong OTP |
| OtpExpired | otp_expired | 400 | The OTP expired; send a new one |
| TokenModeDisabled | token_mode_disabled | 403 | Token mode is not enabled for your org (server only) |
| InvalidExternalRef | invalid_external_ref | 400 | The externalRef is malformed (server only) |
| RateLimited | rate_limited | 429 | Too many requests; retryAfter is the wait in seconds, or null |
| ValidationError | validation_error | 400, or 0 | The request was invalid; details lists why. 0 when the SDK rejected it before sending (e.g. an empty accountId) |
| WebhookSignatureInvalid | webhook_signature_invalid | 0 | A webhook failed verification |
| HabuildConnectError | anything else | any | The base class, used directly for every other code |
Other codes you may see on a plain HabuildConnectError: org_not_found (404 from getConfig),
otp_session_expired, account_banned,
account_not_eligible, consent_required, account_in_other_org, otp_delivery_failed,
member_not_in_org, member_update_failed, member_update_rejected, email_in_use,
token_login_busy. Errors the SDK
raises itself, with status 0: timeout (10 s per attempt), network_error, invalid_config. An
unexpected 2xx body is invalid_response, and any other HTTP failure is http_<status>.
Error messages never contain your key.
Environments
| env | Base URL |
| --- | --- |
| "production" (default) | https://auth-service.habuild.in |
| "staging" | https://auth-service.hb-stg.in |
Each environment accepts only its own keys: production takes pk_live_ / sk_live_ keys, staging
takes pk_test_ / sk_test_ keys. A key for the other environment fails with InvalidKey (401).
new HabuildConnect({ secretKey: process.env.HABUILD_SECRET_KEY!, env: "staging" });
// baseUrl overrides env, e.g. for a local mock server:
new HabuildConnect({ secretKey: process.env.HABUILD_SECRET_KEY!, baseUrl: "http://localhost:4010" });Releasing
Maintainers only. Releases publish from main with Changesets:
- Add a changeset (
npx changeset) in your PR todevelopment. - Merge
developmentintomain. The Release workflow opens a "Version Packages" PR againstmain. It is opened withGITHUB_TOKEN, so CI does not run on it: check it by hand. - Merge the Version Packages PR. The Release workflow publishes to npm.
- Back-merge
mainintodevelopment.
Coming in R2
- Bulk registration of members.
- Automatic retries for POST requests, made safe with an
Idempotency-Key.
License
MIT
