npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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/client runs in a browser or webview, with a publishable pk_ key.
  • habuild-connect/server runs on your backend, with a secret sk_ 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-connect

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

  • HabuildConnectClient throws InvalidKey before any request if it is given an sk_ key.
  • HabuildConnect (server) throws if window exists, 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 login on 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.url right 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.
  • externalRef must be your stable member id, never reused for another person: the same externalRef always logs in the same Habuild account. 1-255 characters from A-Z a-z 0-9 . _ : @ -. Anything else is InvalidExternalRef (400).
  • Consent: when your org's config sets consent.required, pass consent (version and an ISO-8601 acceptedAt), otherwise the call fails with consent_required (400).
  • enrollment has 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.url signs them in.
  • join: joinUrl shows consent and joins the member. It lasts 30 minutes and works once; if it has expired, call openHabuild again.

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) and copy.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.fallbacks lists 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_key or invalid_value. Colours you set for primary / primaryForeground or foreground / background are dropped as contrast_failed when the pair is below 4.5:1. Only a cobranding that 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.registered and member.enrolled webhooks carry the brandKey, 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>")>
  1. Split the header on , and each part on the first =. Take t (Unix time in seconds) and every v1 value. Reject the request if there is not exactly one t, or no v1.
  2. Reject it if |now − t| is more than 300 seconds.
  3. Compute HMAC-SHA256(secret, "<t>.<rawBody>") — the key is your webhook secret, the message is the t value, a literal ., then the raw request body bytes — and hex-encode it (lowercase).
  4. Compare that hex string with each v1 value using a constant-time comparison (for example Python's hmac.compare_digest, Go's hmac.Equal). Accept if any matches.
  5. 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:

  1. Add a changeset (npx changeset) in your PR to development.
  2. Merge development into main. The Release workflow opens a "Version Packages" PR against main. It is opened with GITHUB_TOKEN, so CI does not run on it: check it by hand.
  3. Merge the Version Packages PR. The Release workflow publishes to npm.
  4. Back-merge main into development.

Coming in R2

  • Bulk registration of members.
  • Automatic retries for POST requests, made safe with an Idempotency-Key.

License

MIT