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

@atribu/node

v0.3.0

Published

Official Node.js SDK for the Atribu API — authorize users, send WhatsApp & Instagram messages, and verify signed webhook deliveries.

Downloads

809

Readme

The official Node.js SDK for the Atribu API — typed access to messaging, IG comment replies, webhook subscriptions, OAuth 2.0 consumer helpers, and signed-webhook verification.

Installation

npm install @atribu/node

# Optional peer deps:
npm install jose      # only if you use the @atribu/node/oauth helpers
npm install msw       # only if you use @atribu/node/test

Quick Start

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

Configuration

const atribu = 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 is auto-sent on every mutating POST. request_id is surfaced on every error.

Examples

Send a WhatsApp template

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" }] },
    ],
  },
});

Send a WhatsApp image

// Either pre-uploaded media (recommended for high fanout):
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, no caching):
await atribu.messages.send({
  connection_id: connectionId,
  channel: "whatsapp",
  to: "+15551234567",
  content: {
    type: "image",
    media: { link: "https://cdn.example.com/invoice.png" },
  },
});

Reply to an Instagram comment

// 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:
await atribu.comments.privateReply({
  comment_id: "ig_comment_id",
  connection_id: connectionId,
  text: "Here are the details you asked about ...",
});

Send WhatsApp reply buttons (interactive)

await atribu.messages.send({
  connection_id: connectionId,
  channel: "whatsapp",
  to: "+15551234567",
  content: {
    type: "interactive_buttons",
    body: "Pick a plan:",
    header: "Pricing",
    buttons: [
      { id: "plan_basic", title: "Basic" },
      { id: "plan_pro", title: "Pro" },
      { id: "plan_enterprise", title: "Enterprise" },
    ],
  },
});
// Tap returns the chosen `id` on the `messaging_postbacks` webhook field.

Manage WhatsApp templates

// List every template on the WABA (all statuses).
const templates = await atribu.whatsapp.templates.list({ connectionId });

// Create one — submits to Meta for review.
// Body text uses `{{param_name}}` placeholders; named example block is auto-generated.
const { id, status } = await atribu.whatsapp.templates.create({
  connection_id: connectionId,
  name: "appointment_reminder",
  category: "UTILITY",
  language: "en_US",
  body_text: "Hi {{customer_name}}, your appointment is at {{appointment_time}}.",
});

// Once status === "APPROVED" you can send it via messages.send({ type: "template", ... }).

// Delete by name when you're done with it:
await atribu.whatsapp.templates.delete("appointment_reminder", { connectionId });

Send a WhatsApp broadcast

// 1. Create a draft (capped at 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. Send it. This is a long-running call (100ms pacing between recipients).
//    The Atribu server caps the route at 5 minutes — wrap your fetch with a
//    matching timeout, or extend `timeoutMs` on AtribuClient.
const completed = await atribu.whatsapp.broadcasts.send(broadcast.id);
console.log(`sent: ${completed.sent_count}, failed: ${completed.failed_count}`);

// Cancel an in-flight broadcast (already-sent messages are NOT recalled):
await atribu.whatsapp.broadcasts.cancel(broadcast.id);

Manage Instagram comment-to-DM triggers

// Create a trigger — when an inbound comment matches the keyword,
// the commenter gets `opening_message` as a DM (HUMAN_AGENT tag).
const trigger = await atribu.instagram.triggers.create({
  connection_id: connectionId,
  keyword: "PRICE",
  keyword_match_mode: "contains",          // or "exact" | "regex"
  case_sensitive: false,
  opening_message: "Here's our pricing — happy to chat!",
  public_comment_reply: "Sent you a DM!",  // optional public reply on the comment
  enabled: true,
});

// QA the opening_message against a real IGSID (must have DMed your account in last 7d):
await atribu.instagram.triggers.testDm(trigger.id, {
  recipient_igsid: "1234567890",
});

// Update / pause / delete:
await atribu.instagram.triggers.update(trigger.id, { enabled: false });
await atribu.instagram.triggers.delete(trigger.id);

// Clear the circuit-breaker if it tripped after a comment-spam wave:
await atribu.instagram.triggers.resumeCircuit({ connectionId });

List authorized connections

// What can this key act on?
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 one connection (other consumers
// + the Atribu app UI continue to use it; only this app loses access):
await atribu.connections.revoke(connectionId);

Manage webhook subscriptions

// One subscription per (app, profile, URL):
const sub = await atribu.webhooks.subscriptions.create({
  url: "https://your.app/api/atribu-webhook",
  events: ["message.received", "message.delivery"],
  providers: ["whatsapp", "instagram"],
});
console.log("Webhook secret (shown once):", sub.secret);

// Rotate the HMAC secret with a grace window — deploy dual-verify BEFORE calling this:
const rotated = await atribu.webhooks.subscriptions.rotateSecret(sub.id, {
  grace_days: 14,
});

// Fire a synthetic event to verify your handler:
await atribu.webhooks.subscriptions.test(sub.id);

// Re-deliver a dead webhook:
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 verifier handles parsing, timestamp tolerance, constant-time HMAC comparison, and rotation grace.

Next.js App Router

// app/api/atribu-webhook/route.ts
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 is typed string
      console.log(`WA message from ${event.data.from}: ${event.data.text}`);
    }
  },
});

Manual verification

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, // during rotation
      tolerance: 300, // seconds; default 5 min
    });
    // ... handle the 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

If you're building an app that connects your end-users' WhatsApp/Instagram accounts via Atribu, @atribu/node/oauth has every helper for the consumer-side flow.

import {
  buildAuthorizeUrl,
  signIdTokenHint,
  exchangeCode,
  revokeToken,
  generateCodeVerifier,
  computeCodeChallenge,
} from "@atribu/node/oauth";

// 1. Redirect to consent
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`.

// 2. Handle the callback
const { accessToken, connectionId, scope } = await exchangeCode({
  clientId: "your-app-id",
  clientSecret: process.env.ATRIBU_APP_CLIENT_SECRET!,
  code: callbackQuery.code,
  redirectUri: "https://your.app/integrations/atribu/callback",
  codeVerifier,
});

// Persist accessToken + connectionId per user. accessToken IS the API key.

// 3. Revoke later (e.g. user disconnects)
await revokeToken({
  clientId: "your-app-id",
  clientSecret: process.env.ATRIBU_APP_CLIENT_SECRET!,
  token: accessToken,
});

Error Handling

import { AtribuClient, 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;
    }
  }
}

| Error | Thrown when | | --- | --- | | AtribuApiError | /api/v1/* returned non-2xx. Has code, status, requestId, retry, responseBody. | | AtribuOauthError | RFC 6749/7009 error from /oauth/*. Has code, description, status. | | AtribuWebhookError | Signature verification failed. Has code: "missing_signature" \| "malformed_header" \| "expired_timestamp" \| "invalid_signature". | | AtribuTransportError | Network glitch / timeout / abort. Has cause. | | AtribuConfigError | Bad client configuration. |

The server's X-Request-Id is surfaced as err.requestId so you can grep server logs.

Meta-classified errors (v0.3.0+)

WhatsApp + Instagram failures from the underlying Meta APIs now arrive with the right HTTP status + code instead of a flat 502 provider_error:

| Cause | Status + code | Notes | | --- | --- | --- | | Token revoked / app uninstalled | 401 unauthorized | The server flips data_connections.status='error' automatically when this fires mid-broadcast. User must re-OAuth. | | Rate-limited by Meta | 429 rate_limit_exceeded | Retry-After header surfaces as err.retry.retryAfterMs. For broadcasts, the broadcast resets to draft so retrying send after Retry-After picks up where it left off. | | Meta App Review pending | 403 forbidden | The capability needs Meta App Review for non-tester users. No retry. | | Permanently rejected (e.g. recipient stopped marketing on WA — code 131050) | Recipient row only — broadcast keeps going. whatsapp_broadcast_recipients.error_reason_code carries the stable Meta classifier code (e.g. "meta_131050") so you can dedupe permanently-failed recipients on the next broadcast create. | | Request too complex | 400 invalid_request | Caller must split into smaller batches. | | Transient Meta failure | 502 provider_error | Retry with backoff (err.retry.action === "retry"). |

Retries

The SDK doesn't retry automatically — hiding retries amplifies load on a failing server and obscures backpressure. 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,
});

The wrapper respects the typed retry hint exactly:

| 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

import { setupServer } from "msw/node";
import { atribuMockHandlers, eventFixtures } from "@atribu/node/test";

const server = setupServer(...atribuMockHandlers({
  // Override specific endpoints; everything else gets a realistic default.
  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 must be installed.

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.

SDK Reference

Messaging + cross-channel — @atribu/node

| Method | Description | |---|---| | messages.send() | Send a WhatsApp or Instagram message (text / template / image / video / audio / document / interactive_buttons / quick_replies) | | comments.reply() | Public reply on an IG comment thread | | comments.privateReply() | Send a DM to the user who left an IG comment | | connections.list() | List the connections this OAuth app is authorized for | | connections.get() | Fetch one connection by id | | connections.revoke() | Revoke this OAuth app's authorization for a connection | | webhooks.subscriptions.list() | List your webhook subscriptions | | webhooks.subscriptions.create() | Create a webhook subscription (secret shown once) | | webhooks.subscriptions.update() | Update URL / events / providers / status | | webhooks.subscriptions.delete() | Delete a webhook subscription | | webhooks.subscriptions.rotateSecret() | Rotate the HMAC secret with a grace window | | webhooks.subscriptions.test() | Fire a synthetic event to test your handler | | webhooks.deliveries.replay() | Re-deliver a dead webhook | | withRetry() | Return a new client that retries transient errors |

WhatsApp namespace — client.whatsapp

| Method | Description | |---|---| | whatsapp.templates.list() | List every message template on the WABA (all statuses) | | whatsapp.templates.create() | Submit a new template for Meta review | | whatsapp.templates.delete() | Delete a template by name | | whatsapp.broadcasts.list() | List up to 50 most-recent broadcasts | | whatsapp.broadcasts.create() | Create a draft broadcast with recipients | | whatsapp.broadcasts.get() | Get a broadcast + its recipients (max 200 returned) | | whatsapp.broadcasts.cancel() | Cancel an in-flight broadcast | | whatsapp.broadcasts.send() | Start sending a draft broadcast (long-running) |

Instagram namespace — client.instagram

| Method | Description | |---|---| | instagram.triggers.list() | List comment-to-DM triggers for a connection | | instagram.triggers.create() | Create a new comment-to-DM trigger | | instagram.triggers.update() | Partial-update a trigger | | instagram.triggers.delete() | Delete a trigger | | instagram.triggers.testDm() | Send the opening_message as a one-off DM to a test IGSID | | instagram.triggers.resumeCircuit() | Manually clear a tripped comment-to-DM circuit |

Webhook verification — @atribu/node/webhooks

| Symbol | Description | |---|---| | verifyWebhook() | Verify a signed payload — Web Crypto, rotation grace, constant-time | | AtribuWebhookEvent | Discriminated union — every event shape, fully typed |

OAuth helpers — @atribu/node/oauth

| Symbol | Description | |---|---| | buildAuthorizeUrl() | Construct the /oauth/authorize redirect URL | | exchangeCode() | Trade an authorization code for an access token | | revokeToken() | RFC 7009 revocation | | signIdTokenHint() | Sign an id_token_hint JWT (HS256, requires jose) | | generateCodeVerifier() | PKCE verifier (RFC 7636) | | computeCodeChallenge() | PKCE challenge (SHA-256, base64url) |

Next.js — @atribu/node/next

| Symbol | Description | |---|---| | withAtribuWebhook() | Wrap a Next.js App Router route handler in signature verification |

Test helpers — @atribu/node/test

| Symbol | Description | |---|---| | atribuMockHandlers() | MSW v2 handlers for every endpoint (with overrides) | | eventFixtures | Pre-canned event shapes — deep-merge any field | | responseFixtures | Pre-canned API response envelopes |

Runtime Support

| Runtime | Supported | |---|---| | Node 18+ | ✅ | | Bun | ✅ | | Deno | ✅ — npm:@atribu/node | | Vercel Edge | ✅ | | Cloudflare Workers | ✅ | | Browser | ❌ by design — API keys don't belong in client JS |

Uses Web Crypto throughout — no node:crypto imports.

Requirements

Links

License

MIT