@atribu/node
Official Node.js SDK for the Atribu API — authorize users, send WhatsApp & Instagram messages, and verify signed webhook deliveries.
Published: @atribu/node on npm · Source: github.com/AtribuCore/atribu-node
@atribu/node is the official Node.js SDK for consumer apps that integrate with Atribu's public API. It wraps every OAuth-consumer endpoint (/api/v1/messages, /api/v1/comments, /api/v1/webhooks/*, the OAuth provider flow at /oauth/*) with typed inputs/responses, drop-in webhook verification, and a small typed error hierarchy.
When to use it
If you're building an app that:
- Sends WhatsApp or Instagram messages on behalf of users you've authorized via Atribu's OAuth provider flow
- Receives signed webhook deliveries from Atribu (inbound messages, delivery receipts, comments)
- Replies to Instagram comments programmatically
- Manages your webhook subscriptions (create, rotate secrets, replay deliveries)
If you're just hitting the public REST API with fetch() and want type safety without writing the types yourself.
Install
npm install @atribu/node
# Optional peer deps:
npm install jose # only if you use @atribu/node/oauth
npm install msw # only if you use @atribu/node/testRuntime support
Node 18+, Bun, Deno (npm:@atribu/node), Vercel Edge, Cloudflare Workers. Uses Web Crypto throughout — no node:crypto imports.
Quick start — send a WhatsApp message
import { AtribuClient } from "@atribu/node";
const atribu = new AtribuClient({ apiKey: process.env.ATRIBU_API_KEY });
const result = await atribu.messages.send({
connection_id: "11111111-1111-1111-1111-111111111111",
channel: "whatsapp",
to: "+15551234567",
content: { type: "text", text: "Hello from @atribu/node!" },
});
console.log("Sent:", result.provider_message_id);Authentication
AtribuClient accepts your atb_live_* API key. Get one from Settings → Developer in the Atribu dashboard.
new AtribuClient({
apiKey: "atb_live_...", // required
baseUrl: "https://www.atribu.app", // default
fetch: customFetch, // optional — bring your own (tracing, edge)
timeoutMs: 30_000, // default 30s
userAgent: "MyApp/1.0", // appended after the SDK User-Agent
});Idempotency-Key headers are auto-sent on every mutating POST. The server's X-Request-Id is surfaced as err.requestId on errors for log correlation.
Send messages
await atribu.messages.send({
connection_id: connectionId,
channel: "whatsapp",
to: "+15551234567",
content: { type: "text", text: "Hello!" },
});await atribu.messages.send({
connection_id: connectionId,
channel: "whatsapp",
to: "+15551234567",
content: {
type: "template",
template_name: "appointment_reminder",
language_code: "en_US",
components: [
{ type: "body", parameters: [{ type: "text", text: "Tuesday at 3pm" }] },
],
},
});// Pre-uploaded media (recommended for high fanout — Meta caches it 30 days):
await atribu.messages.send({
connection_id: connectionId,
channel: "whatsapp",
to: "+15551234567",
content: {
type: "image",
media: { media_id: "1234567890" },
caption: "Your invoice",
},
});
// Or by public HTTPS link (Meta fetches once per send):
await atribu.messages.send({
connection_id: connectionId,
channel: "whatsapp",
to: "+15551234567",
content: {
type: "image",
media: { link: "https://cdn.example.com/invoice.png" },
},
});await atribu.messages.send({
connection_id: connectionId,
channel: "instagram",
to: "17841400000000001", // IGSID
content: { type: "text", text: "Hi from Instagram DM!" },
});await atribu.messages.send({
connection_id: connectionId,
channel: "instagram",
to: "17841400000000001",
content: {
type: "image",
image_url: "https://cdn.example.com/image.png",
},
});Reply to Instagram comments
// Public reply on the comment thread:
await atribu.comments.reply({
comment_id: "ig_comment_id",
connection_id: connectionId,
text: "Thanks! DMing you now.",
});
// Private DM to the commenter (comment-to-DM flow):
await atribu.comments.privateReply({
comment_id: "ig_comment_id",
connection_id: connectionId,
text: "Here are the details ...",
});List authorized connections
Every method that takes a connection_id only succeeds against connections the calling key is authorized for. To enumerate them up front:
const connections = await atribu.connections.list();
for (const conn of connections) {
console.log(`${conn.channel} — ${conn.display_name} (${conn.id})`);
}
// Filter by channel:
const igOnly = await atribu.connections.list({ channel: "instagram" });
// Revoke this OAuth app's authorization for a connection.
// Other consumers + the Atribu app UI keep using it; only this app loses access.
await atribu.connections.revoke(connectionId);Direct admin keys (issued from Settings → Developer) see every connected connection on the profile and cannot self-revoke — connections.revoke() returns 400 invalid_request for them.
WhatsApp templates
messages.send({ content: { type: "template", ... } }) only works against templates that have been Meta-approved. Create them first, then poll until status === "APPROVED":
// 1. List existing templates (all statuses).
const templates = await atribu.whatsapp.templates.list({ connectionId });
// 2. Create a new one. Body text supports `{{param_name}}` placeholders;
// the named-params example block is auto-generated.
const { id, status } = await atribu.whatsapp.templates.create({
connection_id: connectionId,
name: "appointment_reminder",
category: "UTILITY", // or "AUTHENTICATION" | "MARKETING"
language: "en_US",
body_text: "Hi {{customer_name}}, your appointment is at {{appointment_time}}.",
header_text: "Atribu Health", // optional
footer_text: "Reply STOP to opt out", // optional
});
// 3. Delete by name once obsolete.
await atribu.whatsapp.templates.delete("appointment_reminder", { connectionId });Template name must be lowercase letters, digits and underscores only (^[a-z0-9_]+$). Meta enforces the constraint server-side; the SDK + API validate before submission to fail fast.
WhatsApp broadcasts
Two-step flow — create the draft + recipient list, then call send to dispatch:
// 1. Create a draft. Max 1,000 recipients per broadcast.
const broadcast = await atribu.whatsapp.broadcasts.create({
connection_id: connectionId,
template_name: "appointment_reminder",
template_language: "en_US",
recipients: customers.map((c) => ({
phone_number: c.phone,
template_params: { customer_name: c.name, appointment_time: c.timeIso },
})),
name: "Q2 appointment reminders",
});
// 2. Dispatch. Long-running — paces 100ms between recipient sends and
// returns when every recipient has been attempted. The server caps the
// route at 5 minutes; for larger sends extend `timeoutMs` on AtribuClient.
const completed = await atribu.whatsapp.broadcasts.send(broadcast.id);
console.log(`sent: ${completed.sent_count}, failed: ${completed.failed_count}`);To inspect or cancel:
// Get broadcast + first 200 recipient rows with delivery state:
const detail = await atribu.whatsapp.broadcasts.get(broadcast.id);
for (const r of detail.recipients) {
console.log(`${r.phone_number} → ${r.status}`);
}
// Cancel an in-flight broadcast. Recipients not yet sent stay `pending`
// permanently; already-sent messages are NOT recalled.
await atribu.whatsapp.broadcasts.cancel(broadcast.id);WhatsApp interactive buttons
messages.send supports the interactive_buttons content type for WhatsApp — up to 3 reply buttons rendered below a body string. Taps return as messaging_postbacks webhook events with the button id.
await atribu.messages.send({
connection_id: connectionId,
channel: "whatsapp",
to: "+15551234567",
content: {
type: "interactive_buttons",
body: "Pick a plan:",
header: "Pricing", // optional, 60 char max
buttons: [
{ id: "plan_basic", title: "Basic" },
{ id: "plan_pro", title: "Pro" },
{ id: "plan_enterprise", title: "Enterprise" },
],
},
});Instagram comment-to-DM triggers
When a user comments with a configured keyword, Atribu DMs them automatically and (optionally) leaves a public reply on the comment.
// Create a trigger.
const trigger = await atribu.instagram.triggers.create({
connection_id: connectionId,
keyword: "PRICE",
keyword_match_mode: "contains", // "contains" | "exact" | "regex"
case_sensitive: false,
opening_message: "Here's our pricing — happy to chat!",
public_comment_reply: "Sent you a DM!", // optional
agent_context_hint: null, // optional — gets passed to the AI agent
enabled: true,
});
// Test the opening_message against a real IGSID (must have DMed your IG account
// in the past 7 days for Meta to accept the HUMAN_AGENT send).
await atribu.instagram.triggers.testDm(trigger.id, {
recipient_igsid: "1234567890",
});
// Pause / update / delete:
await atribu.instagram.triggers.update(trigger.id, { enabled: false });
await atribu.instagram.triggers.delete(trigger.id);If the comment-to-DM circuit trips after a spam wave, you can clear it from the SDK:
await atribu.instagram.triggers.resumeCircuit({ connectionId });Webhook subscriptions
// Create — secret is shown ONCE in the response
const sub = await atribu.webhooks.subscriptions.create({
url: "https://your.app/api/atribu-webhook",
events: ["message.received", "message.delivery"],
providers: ["whatsapp", "instagram"],
});
console.log("Save this secret somewhere safe:", sub.secret);
// Rotate the HMAC secret — deploy dual-verify on your side BEFORE calling
const rotated = await atribu.webhooks.subscriptions.rotateSecret(sub.id, {
grace_days: 14,
});
// Fire a synthetic event to test your handler end-to-end
await atribu.webhooks.subscriptions.test(sub.id);
// Re-deliver a webhook that failed
await atribu.webhooks.deliveries.replay(deadDeliveryId);Verifying webhooks
Atribu signs every outbound delivery as X-Atribu-Signature: t=<unix>,v1=<hex_hmac_sha256> over <t>.<rawBody> (Stripe-style). The SDK verifier handles parsing, timestamp tolerance, constant-time HMAC compare, and rotation grace.
import { withAtribuWebhook } from "@atribu/node/next";
export const POST = withAtribuWebhook({
secret: process.env.ATRIBU_WEBHOOK_SECRET!,
previousSecret: process.env.ATRIBU_WEBHOOK_PREVIOUS_SECRET,
onEvent: async (event) => {
if (event.type === "message.received" && event.provider === "whatsapp") {
// event.data.wa_message_id, event.data.from, event.data.text — all typed
console.log(`WA from ${event.data.from}: ${event.data.text}`);
}
},
});import { verifyWebhook } from "@atribu/node/webhooks";
export async function POST(req: Request) {
try {
const event = await verifyWebhook({
rawBody: await req.text(),
signature: req.headers.get("x-atribu-signature"),
secret: process.env.ATRIBU_WEBHOOK_SECRET!,
previousSecret: process.env.ATRIBU_WEBHOOK_PREVIOUS_SECRET,
tolerance: 300,
});
// ... handle typed event
return new Response(null, { status: 200 });
} catch {
return new Response("invalid signature", { status: 401 });
}
}The unique event.id and the X-Atribu-Delivery-Id header give you idempotency keys for safe redelivery.
OAuth flow (consumer side)
If you're building an app that connects your end-users' WhatsApp/Instagram accounts through Atribu's OAuth provider, the @atribu/node/oauth subpath has every helper.
Mint an id_token_hint and redirect to consent
import {
buildAuthorizeUrl,
signIdTokenHint,
generateCodeVerifier,
computeCodeChallenge,
} from "@atribu/node/oauth";
const codeVerifier = generateCodeVerifier();
const idTokenHint = await signIdTokenHint({
jwtSigningSecret: process.env.ATRIBU_APP_JWT_SECRET!,
subject: user.id,
email: user.email,
expiresIn: "5m",
});
const url = buildAuthorizeUrl({
clientId: "your-app-id",
redirectUri: "https://your.app/integrations/atribu/callback",
provider: "whatsapp",
scope: "whatsapp",
state: csrfToken,
idTokenHint,
codeChallenge: await computeCodeChallenge(codeVerifier),
codeChallengeMethod: "S256",
});
// Redirect the user to `url`Exchange the code for an access token
import { exchangeCode } from "@atribu/node/oauth";
const { accessToken, connectionId, scope, profileId } = await exchangeCode({
clientId: "your-app-id",
clientSecret: process.env.ATRIBU_APP_CLIENT_SECRET!,
code: callbackQuery.code,
redirectUri: "https://your.app/integrations/atribu/callback",
codeVerifier,
});
// `accessToken` IS the Atribu API key — store it server-side, never expose to the browser.Revoke when the user disconnects
import { revokeToken } from "@atribu/node/oauth";
await revokeToken({
clientId: "your-app-id",
clientSecret: process.env.ATRIBU_APP_CLIENT_SECRET!,
token: accessToken,
});Error handling
import { AtribuApiError } from "@atribu/node";
try {
await atribu.messages.send({ /* ... */ });
} catch (err) {
if (err instanceof AtribuApiError) {
switch (err.retry.action) {
case "retry": return queue.retry(job, { delay: 5_000 });
case "retry_after": return queue.retry(job, { delay: err.retry.retryAfterMs });
case "refresh_token": return refreshOAuthAndRetry();
case "fix_and_retry": logger.error("bad payload", { requestId: err.requestId }); break;
case "do_not_retry": logger.error("permanent failure", { requestId: err.requestId }); break;
}
}
}The SDK throws five typed error classes:
| Error | When |
|---|---|
AtribuApiError | /api/v1/* returned non-2xx — has code, status, requestId, retry, responseBody |
AtribuOauthError | RFC 6749/7009 error from /oauth/* |
AtribuWebhookError | Signature verification failed |
AtribuTransportError | Network glitch / timeout / abort |
AtribuConfigError | Bad client configuration |
Opt-in retries
The SDK doesn't retry automatically — hiding retries amplifies load on a failing server. Opt in per-client:
const atribu = new AtribuClient({ apiKey: "..." }).withRetry({
maxAttempts: 3, // initial + 2 retries
backoff: "exponential", // or "fixed" or "none"
baseDelayMs: 500,
maxDelayMs: 30_000,
jitter: 0.3,
});| Condition | Behavior |
|---|---|
| 5xx, 408, network glitch | Exponential / fixed backoff with jitter |
429 / 503 with Retry-After | Honored exactly, no jitter |
401 (refresh_token) | Not retried — refresh credentials, don't retry |
422 (fix_and_retry) | Not retried — your input is bad |
403 (do_not_retry) | Not retried — permission denied |
Testing your integration
import { setupServer } from "msw/node";
import { atribuMockHandlers, eventFixtures } from "@atribu/node/test";
const server = setupServer(...atribuMockHandlers({
// Every endpoint has a realistic default; override only what you care about.
messages: {
send: { status: 422, body: { error: { code: "validation_error", message: "...", status: 422 } } },
},
}));
// Drive your webhook handler tests with realistic event shapes:
const event = eventFixtures.whatsappMessageReceived({
data: { text: "Custom test message" },
});msw@^2.0.0 is required as a peer dependency for this subpath.
OpenTelemetry / Datadog APM / Sentry
Inject your own fetch to trace every SDK call — no SDK change needed:
import { trace, context, propagation } from "@opentelemetry/api";
import { AtribuClient } from "@atribu/node";
const tracer = trace.getTracer("my-app");
const tracedFetch: typeof fetch = (input, init) =>
tracer.startActiveSpan(`atribu.${(init?.method ?? "GET").toLowerCase()}`, async (span) => {
const headers = new Headers(init?.headers);
propagation.inject(context.active(), headers, { set: (h, k, v) => h.set(k, v) });
try {
const res = await fetch(input, { ...init, headers });
span.setAttribute("http.status_code", res.status);
const requestId = res.headers.get("x-request-id");
if (requestId) span.setAttribute("atribu.request_id", requestId);
return res;
} finally { span.end(); }
});
const atribu = new AtribuClient({ apiKey: "...", fetch: tracedFetch });The SDK's User-Agent and Atribu's X-Request-Id give you log-grep correlation out of the box.
Provenance + supply chain
Every published version of @atribu/node ships with a Sigstore attestation signed by GitHub Actions OIDC. The attestation links the npm tarball back to the exact CI workflow run that built it. You can verify it:
npm audit signaturesIf you're auditing your supply chain, the dist.attestations field on each version on the npm registry contains the SLSA provenance URL.