@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.
To retry a SEND safely, pass your own key
The auto-sent key is generated fresh per attempt, so it deduplicates
nothing — not even this SDK's own RetryingHttpClient replays. The send routes
honour a key you supply ({ idempotencyKey }), replaying the first response
for 24 hours and refusing a retry that arrives while the first send is
still in flight. See
Retrying a send safely.
await atribu.messages.send(input, { idempotencyKey: message.id });
await atribu.conversations.sendMessage(id, { text }, { idempotencyKey: message.id });Server-side purchase tracking
The most reliable way to tie a sale to the ad that drove it: send the purchase from your server of record with the anonymousId you captured in the browser. It survives ad-blockers and ITP, the amount is authoritative, and it's idempotent on your orderId.
The flow is always the same — capture the anonymousId client-side, send the purchase server-side:
1. Client — grab the attribution and hand it to your server. The browser tracker's getAttribution() returns the anonymous_id (and click-ids). Post it to your checkout/confirm endpoint (never expose your atb_live_* key to the browser).
// browser, on/just before checkout
const { anonymous_id, session_id } = window.atribuTracker.getAttribution();
await fetch("/api/confirm-order", {
method: "POST",
body: JSON.stringify({ anonymousId: anonymous_id, sessionId: session_id, cart }),
});2a. Next.js — a route handler / server action does the charge, then records the purchase:
// app/api/confirm-order/route.ts (server-only — the API key lives here)
import { AtribuClient } from "@atribu/node";
const atribu = new AtribuClient({ apiKey: process.env.ATRIBU_API_KEY! });
export async function POST(req: Request) {
const { anonymousId, sessionId, cart } = await req.json();
const order = await chargeAndCreateOrder(cart); // your payment of record
await atribu.events.purchase({
anonymousId, // ← the deterministic ad link
sessionId,
value: order.total,
currency: order.currency, // e.g. "USD", "CLP"
orderId: order.id, // ← idempotency key: retries never double-count
userTraits: { email: order.email },
});
return Response.json({ ok: true });
}2b. Node (Express / Fastify / any webhook handler) — identical, framework-agnostic:
import { AtribuClient } from "@atribu/node";
const atribu = new AtribuClient({ apiKey: process.env.ATRIBU_API_KEY! });
app.post("/confirm-order", async (req, res) => {
const order = await chargeAndCreateOrder(req.body.cart);
await atribu.events.purchase({
anonymousId: req.body.anonymousId,
value: order.total,
currency: order.currency,
orderId: order.id,
userTraits: { email: order.email },
});
res.json({ ok: true });
});events.purchase uses your orderId as the idempotency key, so a retried request (or an at-least-once webhook) collapses to a single event — no double-count. For any other event, use atribu.events.track({ event_name, anonymous_id, properties, idempotency_key }).
Cash vs. gross
By default purchase is recorded as a gross order. If this server-side purchase is your authoritative cash record (you don't also run a Stripe/MercadoPago webhook for the same sale), create a conversion goal that maps purchase → cash in Settings → Goals (or atribu goals API). Don't map it to cash if a payment-provider webhook already reports the same sale — that would count the cash twice.
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",
},
});// START a thread: `to` + a subject, and no thread anchors at all.
const sent = await atribu.messages.send({
connection_id: connectionId,
channel: "email",
to: "[email protected]",
content: {
type: "email",
subject: "Your quote",
text: "Hi Alice — the quote is attached.",
// Bulk mail only. One-click needs an https:// url (RFC 8058).
list_unsubscribe: { url: "https://example.com/unsubscribe?t=abc", one_click: true },
},
});
// Keep these two. `thread_id` is the same identifier the inbound
// `message.received` event carries, so it is the thread the reply arrives on;
// `rfc822_message_id` is what a bounce names in
// `delivery_failure.original_rfc822_message_id`.
sent.thread_id; // "18f2a1c9d4e5b6a7" — the thread just created
sent.rfc822_message_id; // "<[email protected]>"
// REPLY in-thread: round-trip the anchors from the inbound event. The response
// then names the thread it replied into, not a new one.
await atribu.messages.send({
connection_id: connectionId,
channel: "email",
to: "[email protected]",
content: {
type: "email",
text: "Tomorrow at 10 works.",
thread_id: event.data.thread_id,
in_reply_to: event.data.rfc822_message_id,
references: event.data.references,
// Outlook only — Graph then owns the threading against the original.
reply_to_message_id: event.data.message_id,
},
});Not every inbound email is a person writing back
message.received for provider: "email" carries three fields that say what
kind of message it is, and a follow-up sequence should read all three before
treating one as an answerable reply: auto_submitted (the header value,
lowercased — a vacation responder), precedence ("bulk", "list", …) and
delivery_failure, which is null for an ordinary message and otherwise
{ failed_recipient, status, action, diagnostic_code, original_rfc822_message_id }
parsed out of the delivery-status report the bounce itself carries. status is
the enhanced status code verbatim — 5.x.x is a hard bounce (stop sending to
that address), 4.x.x is transient — and action says what the reporting
server did ("failed", or "delayed" while it is still retrying).
diagnostic_code is the remote server's own text, for display. original_rfc822_message_id equals the
rfc822_message_id your send returned, which is how a bounce attaches to the
exact message that failed instead of being guessed at from the postmaster's
address.
atribu.messages.send answers thread_id and rfc822_message_id on
channel: "email" only — neither concept exists on WhatsApp or Instagram, so
they are absent from those responses rather than present and null. On Outlook
provider_message_id is the Microsoft Graph immutable id of the sent message
(its copy in Sent Items). Inbound Outlook events carry Graph's default-format
id, so recognise your own message coming back by rfc822_message_id, which is
the same on both providers, never by comparing ids.
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" },
],
},
});Click-to-WhatsApp ads and their greeting
A click-to-WhatsApp ad shows the customer the business's own greeting — the
"Saludo automático" — before they type anything, plus either a pre-filled first
message or ice-breaker buttons. Meta never delivers any of it over the webhook:
the referral object on the customer's first inbound message carries the ad's
headline, body and thumbnail and stops there. So an assistant composing the
first reply greets someone who has already been greeted, and asks what it can
help with after they have already said.
ads.list() answers about the whole account, with no conversation required.
let after: string | undefined;
do {
const page = await atribu.whatsapp.ads.list({ after });
for (const ad of page.ads) {
// `greeting_status`, never `greeting === null`: `none` is a normal ad whose
// advertiser configured no greeting, `unreadable` is a creative Meta would
// not show us — worth asking again once the Meta connection is fixed.
if (ad.greeting_status === "none") {
console.log(`${ad.ad_name} opens ${ad.destination.whatsapp_phone_number} with no greeting`);
}
}
after = page.nextCursor ?? undefined;
} while (after);The list is served from Atribu's nightly Meta sync, never a live Graph call, so
polling it costs the advertiser's ads_management quota nothing. Each row
carries last_synced_at; an ad launched since the last sync is not listed yet.
ads.welcomeMessage(adId) answers about one ad — and that ad id is
referral.source_id on the customer's first inbound message, which is how you
learn it in the first place.
const { welcome, cached, fetched_at } = await atribu.whatsapp.ads.welcomeMessage(
referral.source_id,
);
// `welcome: null` means the ad has no greeting configured — a complete answer,
// not a failure. Check the field; do not rely on a throw.
if (welcome?.autofill_message && firstMessage === welcome.autofill_message) {
// They tapped. They did not type this.
}It answers a fresh synced row without touching Meta and falls back to a live
read for an ad the sync has not seen. AtribuApiError with status 404 means
Meta does not show Atribu the ad (deleted, or invisible to this workspace's
token — Graph does not distinguish the two), 409 that the profile has no
usable Meta Ads connection, 502 a transient Meta failure worth retrying.
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 });Commerce (Shopify)
A connected Shopify store gives you two things: a catalogue you pull and transitions Atribu pushes. The split is deliberate — an event tells you something changed, the API tells you what it now is. No commerce event ever carries the catalogue.
Pull: products and orders
// The catalogue, as a CHANGE FEED. Keep the newest `updated_at` you have seen
// and pass it back — the page order is ascending, so a cursor stays valid no
// matter how many products change while you walk.
let updatedSince = lastSeen; // from your own store
let cursor: string | undefined;
do {
const page = await atribu.commerce.products.list({
updated_since: updatedSince,
limit: "100",
...(cursor ? { cursor } : {}),
});
for (const product of page.data) {
// `product.variants[]` carries sku, price, currency, inventory_quantity
// and `inventory_state` (in_stock | low_stock | out_of_stock | discontinued).
// `active: false` means the store deactivated or DELETED it — mirror that.
upsertLocally(product);
}
cursor = page.pagination.cursor;
} while (cursor);
// "Where is my order" — by number, email or phone. At LEAST ONE is required.
const orders = await atribu.commerce.orders.list({ phone: senderPhone });
const latest = orders.data[0]; // newest first
// latest.status: placed | paid | fulfilled | delivered | cancelledcommerce.orders returns no customer identity — no name, no email, no
phone. You already hold the contact you searched with, and the same row is
reachable by order number, so returning one would turn a guessable reference
into a lookup of the person behind it.
Prices are exact decimal strings, never JSON numbers. Parse before doing
arithmetic, and keep sorting server-side: "9.00" > "10.00" lexically, and that
failure is silent.
Push: the five shopify event kinds
| Event | Fires when | data carries | What it is for |
|---|---|---|---|
catalog.updated | a synced page of products changed | updated_since, product_count | A tickle. Pull with commerce.products.list({ updated_since }). |
product.stock.changed | a variant CROSSES a stock threshold | variant_id, product_id, previous_state, state, quantity | Back-in-stock and low-stock follow-ups. |
product.price.changed | a variant's price DROPS | variant_id, product_id, previous_price, price | Price-drop follow-ups. |
checkout.abandoned | a checkout is abandoned | checkout_id, email, phone, abandoned_checkout_url | Cart-recovery outreach. |
order.status.changed | an order is fulfilled or delivered | order_id, status, email, phone | Shipping notifications; pair with commerce.orders.list(). |
Subscribe to them like any other event:
await atribu.webhooks.subscriptions.create({
url: "https://your.app/api/atribu-webhook",
events: ["product.stock.changed", "order.status.changed"],
providers: ["shopify"],
});Four properties worth relying on:
- Transitions only.
product.stock.changedfires when the band changes, not on every inventory write, andprevious_stateandstatealways differ.product.price.changedis drop-only:price < previous_pricealways holds. - One fact, one event id. A checkout gets ONE
checkout.abandonedhowever many times the store updates it, and an order gets oneorder.status.changedper(order, status)— Shopify firesorders/updatedfor a tag or a note, and those must not re-notify the customer. Deduplicate onevent.idanyway: delivery is at-least-once. fulfilledanddeliveredare two events, because they are two things a customer wants to hear about.deliveredoutranksfulfilled.- Contacts are frequently null.
checkout.abandonedcarries whatever the shopper had typed when they left. A recovery flow has to tolerate having neither an email nor a phone rather than assuming one.
Platform lifecycle: the nine atribu event kinds
Everything above is something that happened at a channel — a message, a comment, a product. These nine are something that happened to your integration with Atribu: a connector went live or died, a recompute finished, a goal produced its first attributed conversion.
They exist so you never have to poll. Before them, GET /api/v1/profile/freshness
was documented as the one route a consumer was expected to poll, and connect and
export completion had no signal at all.
They are opt-in, and the opt-in is the providers array, not events.
Delivery matches on providers and events, so a subscription whose
providers is ["whatsapp"] receives none of these however you set events:
await atribu.webhooks.subscriptions.create({
url: "https://your.app/api/atribu-webhook",
events: [
"connection.connected",
"connection.reconnect_required",
"recompute.completed",
"conversion.attributed",
],
providers: ["atribu"],
});| Event | Fires when | data carries |
|---|---|---|
connection.connected | an OAuth connect (or re-connect) finishes and Atribu can read the connector | provider, external_account_id, external_account_name, reconnected |
connection.reconnect_required | a connector's grant died and only a human re-consent fixes it | provider, status, reason, reconnect: { method, path } |
connection.revoked | a connector was removed — nothing to repair | provider, external_account_id, reason |
handoff.completed | a pending hand-off (connect, pick, sign_dpa, checkout, approve) was completed by a human | handoff_id, kind, result |
recompute.completed | an attribution recompute finished for the profile | profile_id, skipped_early_exit, conversion_id, attribution_model |
conversion.attributed | a conversion definition produces its first attributed conversion | conversion_definition_id, conversion_key, display_name, revenue_type, conversion_id, conversion_time |
export.completed | a conversion-export run finished with nothing failed | profile_id, meta, google, duration_ms, replay |
export.failed | a conversion-export run finished with at least one failed candidate | same shape as export.completed |
profile.freshness.changed | the profile's attribution freshness advanced | profile_id, recomputed_at, status, previous_recomputed_at |
Six properties worth relying on:
connection_idisnullon the ones with no connector behind them —recompute.completed,conversion.attributed,profile.freshness.changed, andexport.*(an export run spans every configured destination, each with its own connection, so there is no single one to name). Everyconnection.*event always carries it. The SDK typesconnection_idasstring | nullfor exactly this reason — narrow before you use it.data.providercarries the connector's real provider onconnection.*—meta_ads,google_ads,google_search_console,gohighlevel,whatsapp, … The top-levelprovideris always"atribu", and is what the subscription matched on.conversion.attributedfires exactly once per definition, ever. The "first" is recorded durably on the definition row, so a restart, a replay or a redelivery cannot re-announce a milestone that already passed. This is the onboarding signal: it is the moment a goal stops being configuration and starts being data.connection.reconnect_requiredgives you a route, not a link.data.reconnectis{ method: "POST", path: "/api/v1/connections/{provider}/handoff" }— substitutedata.providerand call it with your own credential. A signed reconnect URL is a capability, and Atribu will not mint one on a failure path. The same object appears on the error envelope of a 401 withreconnect_required: true.export.failedis aboutfailed, neverskipped. A run that correctly suppressed every candidate (consent withheld, a late sibling already sent, a legal gate) isexport.completedwithsent: 0. Both events carry the whole run's counts, so a partial run tells you it sent 400 and failed 2.- A run with nothing to do emits nothing. The hourly export sweep finding no candidates, or a recompute that never started, are silent — the events mark work that happened, not clocks that ticked.
handoff.completedfires exactly once, and only on completion. The transition is guarded onstatus = 'pending', so a human double-tapping the button, a redelivered callback and a lost race all settle without re-announcing. A hand-off thatfailedor wascancelledemits nothing — an agent polling it reads those off the object it already holds. A WORKSPACE-level hand-off (checkout, minted without a profile) emits nothing either: subscriptions are per profile, and there is no honest profile to attribute it to.
Deduplicate on event.id as always: delivery is at-least-once. The ids are
keyed on the FACT, so a redelivery of the same connect, the same first
attribution or the same export run collapses.
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);Your endpoint must be a public host. The URL has to be https:// on a
resolvable public DNS name — no literal IPs, no .internal / .local /
tailnet names, no single labels, no embedded credentials, and every A/AAAA
record must be publicly routable (loopback, RFC 1918, 169.254.169.254,
CGNAT 100.64/10, unique-local and their IPv6-mapped forms are all refused).
A URL that fails answers 422 naming the address and the range. The same check
runs again immediately before each delivery, so a host that stops being public
later stops receiving events — and redirects are not followed, so register
the final URL rather than one that 3xxs to it.
Any atb_live_ key can manage subscriptions for its own profile. These
routes used to answer 403 forbidden — "Subscription management is only
available to keys issued via the OAuth flow" — to a key a workspace minted for
itself, so the persona most likely to want push instead of polling was the one
persona that could not have it. That gate is gone: a self-serve key creates,
lists, patches, rotates, tests and deletes subscriptions on its own profile,
through the same validation and the same profile scoping an OAuth-app key gets.
Nothing changes for OAuth-app keys, and a self-serve key still cannot see or
touch an OAuth app's subscriptions (or vice versa).
A client_credentials-delegated key is an OAuth-app key, not a third
kind. An app-provisioned profile (no person ever signs into Atribu for it)
has nothing to mint a self-serve key with, so it manages its own subscriptions
through a key minted by POST /oauth/token with
grant_type=client_credentials — the same oauth_app_id-bearing key shape an
authorization-code exchange mints, just issued server-to-server instead of
through a consent redirect. It creates, lists, patches, rotates, tests and
deletes subscriptions on its own profile through this identical path: same
validation, same profile scoping, same signature — proven end to end,
including the four platform lifecycle events below, in src/app/api/v1/ webhooks/subscriptions/client-credentials.integration.test.ts.
webhooks.subscriptions.test(id) fires a synthetic matching the subscription's
FIRST provider: message.received for whatsapp/instagram/email/shopify,
calendar.event.changed for google_calendar, and recompute.completed for
atribu — which has no message.received, so testing an atribu-only
subscription exercises the parser you will actually need.
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}`);
// Media messages include a hosted, browser-fetchable URL (signed, ~7-day
// TTL) — same `attachments` shape as Instagram, so no extra call needed:
// event.data.attachments = [{ type: "image", payload: { url } }]
}
},
});For media messages, Atribu resolves Meta's opaque media_id for you and
includes an attachments:[{ type, payload: { url } }] array in the delivery
(type ∈ image/video/audio/file). If you only kept the media_id and
want to resolve it later, call atribu.whatsapp.media.get(mediaId, { connectionId })
→ { url, mime_type, expires_at } (requires the whatsapp scope; media IDs
expire 7 days after the webhook).
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",
// Your own id for the merchant this consent is for. Required once Atribu has
// set your app to `profile_resolution: "external_ref"`; ignored before that,
// so it is safe to send early. See below.
externalRef: merchant.id,
});
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``externalRef` — naming the profile yourself
Without it, Atribu picks the profile from the hint's email, which keys a
merchant on a person: one operator connecting two merchants lands both on one
profile. externalRef emits an external_ref claim so consent resolves
(client_id, external_ref) against the profile you already provisioned under
that same ref — and skips every email-keyed branch.
Once your app is on external_ref mode the claim is mandatory. Omit it and the
callback receives error=invalid_request with error_description=external_ref_required;
name a ref you never provisioned and it is unknown_external_ref, at which
point you provision the merchant and retry. Use the option, not an
extraClaims entry: the claim name is Atribu's contract, and a typo there
signs a token that verifies and then fails at consent.
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.