@infloapi/node
v0.4.4
Published
Official Node.js SDK for the Inflo third-party API
Maintainers
Readme
@infloapi/node
Official Node.js SDK for the Inflo third-party API — combined user + group search, user discovery, identifier resolution, notifications, organization members, invitations, connections, and webhook signature verification, with typed responses and built-in auth/retry.
Node >= 18 (uses the built-in global
fetch). For older Node versions, pass afetchimplementation via thefetchoption.
Install
npm install @infloapi/node
# or
pnpm add @infloapi/node
# or
yarn add @infloapi/nodeQuick start
import { InfloClient } from "@infloapi/node";
// OAuth2 client credentials (token is fetched and refreshed automatically):
const inflo = new InfloClient({
clientId: process.env.INFLO_CLIENT_ID!,
clientSecret: process.env.INFLO_CLIENT_SECRET!,
scope: "users:search notifications:write", // optional, this is the default
});
// …or a Personal Access Token (PAT):
const inflo = new InfloClient({ pat: process.env.INFLO_PAT! });
// Combined search — returns both users and groups in one call:
// Pass userToken when the caller is acting on behalf of a specific Inflo user
// (e.g. an OIDC access token received after login). Omit for M2M server calls.
const accessToken = "oidc-access-token-from-your-sso-callback"; // from your auth flow
const { users, groups } = await inflo.search(
{ q: "jane" },
{ userToken: accessToken },
);
console.log(users[0].display_name, groups[0]?.name);Configuration
new InfloClient({
// exactly one of:
pat: "infpat_…",
// or:
clientId: "…",
clientSecret: "…",
scope: "users:search notifications:write",
baseUrl: "https://infloapp.com", // override for staging/tests
timeout: 30_000, // per-request timeout, ms
retry: {
maxRetries: 3, // 5xx/429/network errors only
baseDelayMs: 300,
maxDelayMs: 8_000,
},
defaultHeaders: { "X-My-Trace": "abc" },
fetch, // optional custom fetch
});Methods
search(params, opts) — combined users + groups
Search for Inflo users and groups in one call. Requires the signed-in user's OIDC access token. The server rejects requests without a delegated JWT with 403 app_context_required.
Users are scoped to those who have already authenticated with your app (presence gate). When Inflo staff enable Global Search for your app, all active Inflo users matching the query are returned. Groups are always searched across all public groups.
const { users, groups, total_users, has_more } = await inflo.search(
{ q: "jane", limit: 20, offset: 0 },
{ userToken: oidcAccessToken },
);
// users: [{ uid, display_name, username, avatar_url }]
// groups: [{ id, name, description, avatar_url }]Each user's uid can be passed directly to inflo.invitations.send({ uid }).
users.search(params, opts)
Search for users only (no groups). Same auth and presence-gate rules as inflo.search().
const { users, total, has_more } = await inflo.users.search(
{ q: "jane", limit: 20, offset: 0 },
{ userToken: oidcAccessToken },
);users.get(sub)
const user = await inflo.users.get("usr_abc123");users.identifiers.resolve({ identifier })
const r = await inflo.users.identifiers.resolve({ identifier: "[email protected]" });
if (r.exists) console.log(r.user?.infloUserKey);users.identifiers.lookup({ type, value })
const r = await inflo.users.identifiers.lookup({ type: "phone", value: "+15551234567" });
if (r.found) console.log(r.infloUserKey);notifications.create({ sub, title, body, deepLink? })
const res = await inflo.notifications.create({
sub: "usr_abc123",
title: "Invoice ready",
body: "Your March invoice is available.",
deepLink: "https://yourapp.com/inv/123",
});
if (!res.delivered) {
// Recipient has not opted in (API returned 202 with no body).
}organizations.members.list(orgId)
const { members } = await inflo.organizations.members.list("org_42");invitations.send(body, opts?)
Send a single invitation. At least one of uid, email, or phone is required.
The server may respond with one of two outcomes — check result.outcome:
"invited"— an invitation was created and the recipient will be notified."connected"— the target was already on Inflo and a direct connection was established (no email sent).
// By Inflo UID — no email address needed (recommended)
const result = await inflo.invitations.send({ uid: "uid_abc123", message: "Come join us!" });
if (result.outcome === "invited") {
console.log("Invitation id:", result.invitation.id);
console.log("Email status:", result.invitation.delivery.email.status);
} else {
// result.outcome === "connected"
console.log("Connected directly:", result.user.uid, "state:", result.state);
}
// By email
const result = await inflo.invitations.send({
email: "[email protected]",
expires_in_days: 7,
notify: { sms: false },
});
// SSO-delegated (send on behalf of a signed-in user)
const result = await inflo.invitations.send(
{ email: "[email protected]" },
{ userToken: oidcAccessToken },
);End-to-end: search → send invitation by UID → handle webhook
// 1. Combined search — returns users AND groups in one call
// Response: { users, groups, total_users, has_more }
const { users } = await inflo.search(
{ q: "jane", limit: 20 },
{ userToken: oidcAccessToken },
);
const target = users[0];
// 2. Send by UID — no email lookup required
const result = await inflo.invitations.send({
uid: target.uid,
message: "Join my workspace on AwesomeApp!",
redirect_uri: "https://yourapp.com/welcome",
});
if (result.outcome === "invited") {
console.log("Invitation sent:", result.invitation.id);
} else {
// outcome === "connected" — target was already on Inflo, connected directly
console.log("Connected:", result.user.uid);
}
// 3. When the recipient accepts, Inflo fires an `invitation.accepted` webhook
// and the connection goes live — use verifyWebhookSignature to validate it.invitations.list(opts?)
// Default: returns both sent and received, page 1, limit 20
const { invitations, pagination } = await inflo.invitations.list();
// Filter to sent only, page 2
const { invitations, pagination } = await inflo.invitations.list({
direction: "sent",
status: "pending",
page: 2,
limit: 50,
});
console.log(invitations[0].direction); // "sent" or "received"
console.log(invitations[0].inviter.uid);
console.log(pagination.total, pagination.has_more);invitations.get(id, opts?)
const inv = await inflo.invitations.get(42);
console.log(inv.status, inv.invitee_email, inv.delivery.email.status);invitations.accept(id, opts?)
// Email invitation — no token required
const result = await inflo.invitations.accept(42, { userToken: oidcAccessToken });
console.log(result.connection.state); // "accepted"
console.log(result.connection.inviter.uid);
console.log(result.redirect_uri); // redirect URL if set by the inviter
// SMS-only invitation — must supply the opaque token from the invite link.
// The server uses this to prove the caller possessed the original SMS URL.
const result = await inflo.invitations.accept(42, {
userToken: oidcAccessToken,
token: smsTokenFromInviteUrl, // hex string from /invite/<token>
});invitations.decline(id, opts?)
const result = await inflo.invitations.decline(42);
console.log(result.invitation.status); // "declined"
console.log(result.message);invitations.cancel(id, opts?)
Cancel a pending sent invitation (inviter only). Returns void on success (204).
await inflo.invitations.cancel(42);invitations.resend(id, opts?)
Resend a pending invitation (rotates the token and re-sends the notification).
const result = await inflo.invitations.resend(42);
console.log(result.invitation.delivery.email.status); // "sent" or "failed"
console.log(result.message);
// With a per-channel delivery override:
await inflo.invitations.resend(42, { notify: { email: false, sms: true } });invitations.sendBulk(request, opts?)
Send up to 100 invitations in one request (HTTP 207). The server returns a per-entry
results array and a summary. An Idempotency-Key header is automatically attached
by the SDK.
const result = await inflo.invitations.sendBulk({
invitations: [
{ uid: "uid_abc123" },
{ email: "[email protected]", message: "Come join!" },
{ phone: "+15551234567" },
],
expires_in_days: 14,
notify: { sms: false },
});
console.log(result.summary); // { created: 2, skipped: 1, failed: 0 }
for (const entry of result.results) {
if (entry.status === "created") {
console.log(`Entry ${entry.index}: invitation #${entry.invitation_id} sent`);
} else if (entry.status === "already_connected") {
console.log(`Entry ${entry.index}: already connected`);
} else if (entry.status === "already_pending") {
console.log(`Entry ${entry.index}: invitation #${entry.invitation_id} already pending`);
} else {
console.warn(`Entry ${entry.index} failed: ${entry.code}`);
}
}connections.list(opts?)
const { connections, total, not_yet_visible_count } = await inflo.connections.list({
userToken: oidcAccessToken,
});connections.remove(uid, opts?)
Remove an accepted Inflo connection.
await inflo.connections.remove("uid_abc123");
// SSO-delegated
await inflo.connections.remove("uid_abc123", { userToken: oidcAccessToken });verifyWebhookSignature({ signatureV2, body, secret })
As of 2026-11-03 Inflo emits only X-Inflo-Signature-V2. Pass it as
signatureV2 — the SDK checks the embedded timestamp (replay protection,
300-second window) and the HMAC together:
import { verifyWebhookSignature } from "@infloapi/node";
app.post("/webhooks/inflo", express.raw({ type: "application/json" }), (req, res) => {
const ok = verifyWebhookSignature({
signatureV2: req.header("X-Inflo-Signature-V2") ?? "",
body: req.body, // Buffer — must be raw bytes before JSON.parse
secret: process.env.INFLO_WEBHOOK_SECRET!,
});
if (!ok) return res.status(401).end();
// process JSON.parse(req.body.toString("utf8"))
res.status(204).end();
});The v2 format signs "<unixTimestamp>.<rawBody>" with HMAC-SHA256 and
encodes the result as t=<unix>,v1=<hex> in the X-Inflo-Signature-V2
header. Pass an array to secret during a key rotation — each secret is
tried in order.
Errors
All HTTP failures throw InfloApiError:
import { InfloApiError } from "@infloapi/node";
try {
await inflo.users.search({ q: "x" });
} catch (err) {
if (err instanceof InfloApiError) {
console.error(err.status, err.code, err.hint, err.requestId);
}
}Transient failures (HTTP 408/425/429/5xx and network errors) are retried with
exponential backoff + jitter, capped by retry.maxRetries.
TypeScript types
Domain types like PublicUser, IdentifierResolveResult,
NotificationCreateResult, InAppSearchResponse, SearchedUser, and
SearchedGroup are exported from the package root. Raw OpenAPI types can be
regenerated from docs/third-party-api/openapi.yaml:
npm run generateThis writes src/generated/api.d.ts using openapi-typescript.
Versioning
This package follows semver:
- Patch — bug fixes, doc tweaks.
- Minor — additive: new methods, new optional fields, retry tweaks.
- Major — breaking changes to method signatures or constructor shape.
We will only release a major bump when a request/response field is removed or renamed in a breaking way, or when an existing argument changes meaning.
See CHANGELOG.md for the release log.
Examples
See the examples/ directory for runnable scripts.
Related packages
| Package | Purpose |
|---------|---------|
| @infloapi/react | React auth layer — InfloProvider, AuthGuard, PKCE SSO, useInfloAuth. Use alongside this SDK (server calls) when building a React frontend. |
| @infloapi/react-social | React UI for connection/group selection — CirclesPickerDialog, ConnectionCard. Wires to this SDK's connections.list() via your server proxy. |
License
MIT © Inflo
