@atribu/sdk 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +41 -0
- package/LICENSE +21 -0
- package/README.md +431 -0
- package/dist/admin/index.cjs +326 -0
- package/dist/admin/index.cjs.map +1 -0
- package/dist/admin/index.d.cts +46 -0
- package/dist/admin/index.d.ts +46 -0
- package/dist/admin/index.js +323 -0
- package/dist/admin/index.js.map +1 -0
- package/dist/api.d-BXINTQo6.d.cts +3547 -0
- package/dist/api.d-BXINTQo6.d.ts +3547 -0
- package/dist/errors-D3ApBz8J.d.cts +86 -0
- package/dist/errors-D3ApBz8J.d.ts +86 -0
- package/dist/index.cjs +549 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +198 -0
- package/dist/index.d.ts +198 -0
- package/dist/index.js +536 -0
- package/dist/index.js.map +1 -0
- package/dist/next/index.cjs +153 -0
- package/dist/next/index.cjs.map +1 -0
- package/dist/next/index.d.cts +43 -0
- package/dist/next/index.d.ts +43 -0
- package/dist/next/index.js +151 -0
- package/dist/next/index.js.map +1 -0
- package/dist/oauth/index.cjs +299 -0
- package/dist/oauth/index.cjs.map +1 -0
- package/dist/oauth/index.d.cts +117 -0
- package/dist/oauth/index.d.ts +117 -0
- package/dist/oauth/index.js +291 -0
- package/dist/oauth/index.js.map +1 -0
- package/dist/test/index.cjs +443 -0
- package/dist/test/index.cjs.map +1 -0
- package/dist/test/index.d.cts +321 -0
- package/dist/test/index.d.ts +321 -0
- package/dist/test/index.js +437 -0
- package/dist/test/index.js.map +1 -0
- package/dist/types-Dc6tIN_V.d.cts +101 -0
- package/dist/types-Dc6tIN_V.d.ts +101 -0
- package/dist/webhooks/index.cjs +97 -0
- package/dist/webhooks/index.cjs.map +1 -0
- package/dist/webhooks/index.d.cts +35 -0
- package/dist/webhooks/index.d.ts +35 -0
- package/dist/webhooks/index.js +94 -0
- package/dist/webhooks/index.js.map +1 -0
- package/package.json +102 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `@atribu/sdk` will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [0.2.0] — 2026-05-15
|
|
11
|
+
|
|
12
|
+
DX polish bundle. No breaking changes to v0.1.0 — every new feature is opt-in or additive.
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
|
|
16
|
+
- **Opt-in retry layer**: `new AtribuClient({...}).withRetry({ maxAttempts, backoff, baseDelayMs, jitter })`. Respects the typed retry hint — only retries `retry` / `retry_after` actions, never `do_not_retry` / `fix_and_retry` / `refresh_token`. Honors `Retry-After` exactly (no jitter); applies jittered exponential / fixed backoff otherwise.
|
|
17
|
+
- **`isRetryableError(err)`** helper for consumers building their own retry logic.
|
|
18
|
+
- **`@atribu/sdk/test` subpath** with MSW v2 handlers (`atribuMockHandlers()`) covering every OAuth-consumer endpoint, plus `eventFixtures` and `responseFixtures` for driving handler tests with realistic event shapes. Deep-merge overrides for any field.
|
|
19
|
+
- **Compile-time type tests** via `vitest/expectTypeOf` — 12 narrowing assertions on the webhook event union, message content union, error hierarchy, retry-hint shape, and client surface. Fail-fast on accidental type-shape drift.
|
|
20
|
+
- **OpenTelemetry recipe** in README — drop-in `tracedFetch` wrapper threading `traceparent`, surfacing `X-Request-Id` as a span attribute, recording HTTP status / errors. Works with any OTel-compatible tracer (Datadog APM, Sentry, native).
|
|
21
|
+
|
|
22
|
+
### Changed
|
|
23
|
+
|
|
24
|
+
- Resources now accept a `HttpClientLike` interface instead of the concrete `HttpClient` class so the retry wrapper stacks cleanly on top. Pure internal refactor, public API unchanged.
|
|
25
|
+
- `msw` added as optional peer dependency (for `@atribu/sdk/test` users only).
|
|
26
|
+
|
|
27
|
+
## [0.1.0] — 2026-05-15
|
|
28
|
+
|
|
29
|
+
Initial pre-1.0 release. OAuth-consumer surface only — analytics endpoints land in a separate SDK once Atribu's add-on monetization path is built.
|
|
30
|
+
|
|
31
|
+
### Added
|
|
32
|
+
|
|
33
|
+
- `AtribuClient` with typed `messages.send`, `comments.reply`, `comments.privateReply`, `webhooks.subscriptions.{list,create,update,delete,rotateSecret,test}`, `webhooks.deliveries.replay`.
|
|
34
|
+
- `@atribu/sdk/webhooks` — `verifyWebhook` via Web Crypto (Node + Edge runtimes), rotation grace, timestamp tolerance, constant-time HMAC compare.
|
|
35
|
+
- `@atribu/sdk/oauth` — `buildAuthorizeUrl`, `exchangeCode`, `revokeToken`, `signIdTokenHint` (jose), `generateCodeVerifier`, `computeCodeChallenge` (PKCE RFC 7636).
|
|
36
|
+
- `@atribu/sdk/admin` — `AtribuAdminClient` with `oauthApps.{create,update,suspend,rotateClientSecret,rotateJwtSecret}`.
|
|
37
|
+
- `@atribu/sdk/next` — `withAtribuWebhook` HOF for Next.js App Router route handlers.
|
|
38
|
+
- Typed error hierarchy: `AtribuError`, `AtribuApiError`, `AtribuOauthError`, `AtribuWebhookError`, `AtribuTransportError`, `AtribuConfigError`.
|
|
39
|
+
- Retry hint surfaced on every `AtribuApiError` (`retry`, `retry_after`, `refresh_token`, `fix_and_retry`, `do_not_retry`). SDK does not auto-retry.
|
|
40
|
+
- `Idempotency-Key` automatically sent on every mutating call.
|
|
41
|
+
- OpenAPI-driven request/response types — zero drift from server.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Atribu
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
# @atribu/sdk
|
|
2
|
+
|
|
3
|
+
Official TypeScript SDK for the Atribu OAuth-consumer API. Send WhatsApp + Instagram messages on behalf of users who authorized your app, verify signed webhook deliveries, and run the consumer-side OAuth 2.0 flow without rolling your own JWT/PKCE crypto.
|
|
4
|
+
|
|
5
|
+
Runs on Node 18+, Bun, Deno, Vercel Edge, and Cloudflare Workers via Web Crypto. No browser support — API keys don't belong in client-side JS.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install @atribu/sdk
|
|
11
|
+
# optional, only needed for the OAuth /oauth helpers:
|
|
12
|
+
npm install jose
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Quickstart — send a message
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import { AtribuClient } from "@atribu/sdk";
|
|
19
|
+
|
|
20
|
+
const atribu = new AtribuClient({
|
|
21
|
+
apiKey: process.env.ATRIBU_API_KEY!, // "atb_live_..."
|
|
22
|
+
// baseUrl: "http://localhost:3000", // for local dev
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const result = await atribu.messages.send({
|
|
26
|
+
connection_id: "11111111-1111-1111-1111-111111111111",
|
|
27
|
+
channel: "whatsapp",
|
|
28
|
+
to: "+15551234567",
|
|
29
|
+
content: { type: "text", text: "Hi! Thanks for reaching out." },
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
console.log(result.provider_message_id);
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Verifying inbound webhooks
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
// app/api/atribu-webhook/route.ts (Next.js App Router)
|
|
39
|
+
import { withAtribuWebhook } from "@atribu/sdk/next";
|
|
40
|
+
|
|
41
|
+
export const POST = withAtribuWebhook({
|
|
42
|
+
secret: process.env.ATRIBU_WEBHOOK_SECRET!,
|
|
43
|
+
previousSecret: process.env.ATRIBU_WEBHOOK_PREVIOUS_SECRET, // optional, during rotation
|
|
44
|
+
onEvent: async (event) => {
|
|
45
|
+
if (event.type === "message.received" && event.provider === "whatsapp") {
|
|
46
|
+
console.log("new WA message:", event.data.text);
|
|
47
|
+
// event.data is typed: { wa_message_id, from, to, contact_name, type, text, raw }
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Or roll your own handler:
|
|
54
|
+
|
|
55
|
+
```ts
|
|
56
|
+
import { verifyWebhook } from "@atribu/sdk/webhooks";
|
|
57
|
+
|
|
58
|
+
export async function POST(req: Request) {
|
|
59
|
+
const rawBody = await req.text();
|
|
60
|
+
const signature = req.headers.get("x-atribu-signature");
|
|
61
|
+
try {
|
|
62
|
+
const event = await verifyWebhook({
|
|
63
|
+
rawBody,
|
|
64
|
+
signature,
|
|
65
|
+
secret: process.env.ATRIBU_WEBHOOK_SECRET!,
|
|
66
|
+
});
|
|
67
|
+
// handle event ...
|
|
68
|
+
return new Response(null, { status: 200 });
|
|
69
|
+
} catch {
|
|
70
|
+
return new Response("invalid signature", { status: 401 });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Atribu signs every delivery as `X-Atribu-Signature: t=<unix>,v1=<hex_hmac_sha256>` over `<t>.<rawBody>` (Stripe-style). The verifier:
|
|
76
|
+
|
|
77
|
+
- enforces a timestamp tolerance (default 5 minutes) to defend against replay
|
|
78
|
+
- constant-time compares the HMAC
|
|
79
|
+
- accepts the `previousSecret` during rotation grace so you can dual-verify safely
|
|
80
|
+
|
|
81
|
+
The unique `event.id` plus the `X-Atribu-Delivery-Id` header give you idempotency keys for safe redelivery.
|
|
82
|
+
|
|
83
|
+
## Consumer-side OAuth flow
|
|
84
|
+
|
|
85
|
+
If you're building an app that connects your end-users' WhatsApp/Instagram accounts via Atribu (Zernio-style), the `/oauth` subpath has every helper you need.
|
|
86
|
+
|
|
87
|
+
```ts
|
|
88
|
+
import {
|
|
89
|
+
signIdTokenHint,
|
|
90
|
+
buildAuthorizeUrl,
|
|
91
|
+
generateCodeVerifier,
|
|
92
|
+
computeCodeChallenge,
|
|
93
|
+
exchangeCode,
|
|
94
|
+
revokeToken,
|
|
95
|
+
} from "@atribu/sdk/oauth";
|
|
96
|
+
|
|
97
|
+
// 1. Redirect the user to Atribu's consent page
|
|
98
|
+
const idTokenHint = await signIdTokenHint({
|
|
99
|
+
jwtSigningSecret: process.env.ATRIBU_APP_JWT_SECRET!,
|
|
100
|
+
subject: user.id,
|
|
101
|
+
email: user.email,
|
|
102
|
+
expiresIn: "5m",
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const codeVerifier = generateCodeVerifier();
|
|
106
|
+
const codeChallenge = await computeCodeChallenge(codeVerifier);
|
|
107
|
+
|
|
108
|
+
const authorizeUrl = buildAuthorizeUrl({
|
|
109
|
+
clientId: "vitrina",
|
|
110
|
+
redirectUri: "https://vitrina.app/integrations/atribu/callback",
|
|
111
|
+
provider: "whatsapp",
|
|
112
|
+
scope: "whatsapp",
|
|
113
|
+
state: csrfToken,
|
|
114
|
+
idTokenHint,
|
|
115
|
+
codeChallenge,
|
|
116
|
+
codeChallengeMethod: "S256",
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// 2. In your /callback handler:
|
|
120
|
+
const { accessToken, connectionId, scope, profileId } = await exchangeCode({
|
|
121
|
+
clientId: "vitrina",
|
|
122
|
+
clientSecret: process.env.ATRIBU_APP_CLIENT_SECRET!,
|
|
123
|
+
code: callbackQuery.code,
|
|
124
|
+
redirectUri: "https://vitrina.app/integrations/atribu/callback",
|
|
125
|
+
codeVerifier,
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// store accessToken (it's an Atribu API key) + connectionId per user
|
|
129
|
+
|
|
130
|
+
// 3. To revoke later (e.g. user disconnects):
|
|
131
|
+
await revokeToken({
|
|
132
|
+
clientId: "vitrina",
|
|
133
|
+
clientSecret: process.env.ATRIBU_APP_CLIENT_SECRET!,
|
|
134
|
+
token: accessToken,
|
|
135
|
+
});
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## Managing webhook subscriptions
|
|
139
|
+
|
|
140
|
+
```ts
|
|
141
|
+
// the OAuth flow creates a subscription automatically; manage it after:
|
|
142
|
+
const subs = await atribu.webhooks.subscriptions.list();
|
|
143
|
+
|
|
144
|
+
await atribu.webhooks.subscriptions.update(subs[0].id, {
|
|
145
|
+
url: "https://vitrina.app/api/atribu-webhook-v2",
|
|
146
|
+
events: ["message.received", "message.delivery"],
|
|
147
|
+
providers: ["whatsapp", "instagram"],
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// rotate the HMAC secret — capture the new secret and deploy dual-verify BEFORE rotating
|
|
151
|
+
const rotated = await atribu.webhooks.subscriptions.rotateSecret(subs[0].id, { grace_days: 14 });
|
|
152
|
+
console.log("new webhook secret (shown once):", rotated.secret);
|
|
153
|
+
|
|
154
|
+
// fire a synthetic event to test your handler
|
|
155
|
+
await atribu.webhooks.subscriptions.test(subs[0].id);
|
|
156
|
+
|
|
157
|
+
// re-deliver a dead webhook
|
|
158
|
+
await atribu.webhooks.deliveries.replay(deadDeliveryId);
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
## IG comment replies
|
|
162
|
+
|
|
163
|
+
```ts
|
|
164
|
+
// public reply on the comment thread
|
|
165
|
+
await atribu.comments.reply({
|
|
166
|
+
comment_id: "ig_comment_id",
|
|
167
|
+
connection_id: connectionId,
|
|
168
|
+
text: "Thanks for the message! DMing you details.",
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// private DM to the user who left the comment
|
|
172
|
+
await atribu.comments.privateReply({
|
|
173
|
+
comment_id: "ig_comment_id",
|
|
174
|
+
connection_id: connectionId,
|
|
175
|
+
text: "Here are the details you asked about ...",
|
|
176
|
+
});
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
## Opt-in retry
|
|
180
|
+
|
|
181
|
+
The SDK doesn't retry automatically (hiding retries amplifies load on a failing server and obscures backpressure). Opt in per-client when you want it:
|
|
182
|
+
|
|
183
|
+
```ts
|
|
184
|
+
import { AtribuClient } from "@atribu/sdk";
|
|
185
|
+
|
|
186
|
+
const client = new AtribuClient({ apiKey: process.env.ATRIBU_API_KEY! })
|
|
187
|
+
.withRetry({
|
|
188
|
+
maxAttempts: 3, // initial + 2 retries
|
|
189
|
+
backoff: "exponential", // or "fixed" or "none"
|
|
190
|
+
baseDelayMs: 500, // first retry waits ~500ms (+ jitter)
|
|
191
|
+
maxDelayMs: 30_000,
|
|
192
|
+
jitter: 0.3, // 0..1, multiplies delay by 1+random*jitter
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
await client.messages.send({ ... }); // retries on 5xx / 408 / 429 / network glitches
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
What gets retried:
|
|
199
|
+
|
|
200
|
+
| Condition | Behavior |
|
|
201
|
+
|---|---|
|
|
202
|
+
| 5xx, 408, `AtribuTransportError` (network) | Exponential backoff, jittered |
|
|
203
|
+
| 429 / 503 with `Retry-After` | Honors the server's instruction exactly (no jitter) |
|
|
204
|
+
| 401 (`refresh_token` hint) | **Not retried** — you need to refresh credentials, not retry |
|
|
205
|
+
| 422 (`fix_and_retry` hint) | **Not retried** — your input is bad; retrying won't help |
|
|
206
|
+
| 403 (`do_not_retry` hint) | **Not retried** — permission denied |
|
|
207
|
+
|
|
208
|
+
`.withRetry()` returns a *new* client; the original is untouched. Chain it once at client construction and you're done.
|
|
209
|
+
|
|
210
|
+
If you want to inspect retryability without using the wrapper:
|
|
211
|
+
|
|
212
|
+
```ts
|
|
213
|
+
import { isRetryableError } from "@atribu/sdk";
|
|
214
|
+
|
|
215
|
+
try {
|
|
216
|
+
await client.messages.send({ ... });
|
|
217
|
+
} catch (err) {
|
|
218
|
+
if (isRetryableError(err)) {
|
|
219
|
+
// queue this for retry on your side
|
|
220
|
+
} else {
|
|
221
|
+
throw err;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
## Testing with `@atribu/sdk/test`
|
|
227
|
+
|
|
228
|
+
A drop-in MSW v2 setup so you can mock every Atribu endpoint with three lines:
|
|
229
|
+
|
|
230
|
+
```ts
|
|
231
|
+
// Install once: npm i -D msw
|
|
232
|
+
import { afterAll, afterEach, beforeAll } from "vitest";
|
|
233
|
+
import { setupServer } from "msw/node";
|
|
234
|
+
import { atribuMockHandlers, fixtures } from "@atribu/sdk/test";
|
|
235
|
+
|
|
236
|
+
const server = setupServer(...atribuMockHandlers());
|
|
237
|
+
|
|
238
|
+
beforeAll(() => server.listen());
|
|
239
|
+
afterEach(() => server.resetHandlers());
|
|
240
|
+
afterAll(() => server.close());
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
Every endpoint defaults to a realistic happy-path response. Override specific endpoints:
|
|
244
|
+
|
|
245
|
+
```ts
|
|
246
|
+
const server = setupServer(
|
|
247
|
+
...atribuMockHandlers({
|
|
248
|
+
messages: {
|
|
249
|
+
send: {
|
|
250
|
+
status: 422,
|
|
251
|
+
body: { error: { code: "validation_error", message: "bad content", status: 422 } },
|
|
252
|
+
},
|
|
253
|
+
},
|
|
254
|
+
}),
|
|
255
|
+
);
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
Or generate event payloads for your own webhook handler tests:
|
|
259
|
+
|
|
260
|
+
```ts
|
|
261
|
+
import { eventFixtures } from "@atribu/sdk/test";
|
|
262
|
+
|
|
263
|
+
const event = eventFixtures.whatsappMessageReceived({
|
|
264
|
+
data: { text: "Custom test message", from: "+15551111111" },
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
// drive your handler with it
|
|
268
|
+
await myWebhookHandler(event);
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
Available fixtures: `eventFixtures.{whatsappMessageReceived, whatsappMessageDelivery, instagramFbLoginMessage, instagramPostback, instagramIgLoginChange, instagramMessageDelivery}`. All deep-merge their overrides into a sensible default.
|
|
272
|
+
|
|
273
|
+
## Error handling
|
|
274
|
+
|
|
275
|
+
Every API error is an `AtribuApiError` with a typed `code` + machine-readable `retry` hint. **The SDK never retries automatically** — your queue / job system decides whether to act on the hint.
|
|
276
|
+
|
|
277
|
+
```ts
|
|
278
|
+
import { AtribuApiError } from "@atribu/sdk";
|
|
279
|
+
|
|
280
|
+
try {
|
|
281
|
+
await atribu.messages.send({ ... });
|
|
282
|
+
} catch (err) {
|
|
283
|
+
if (err instanceof AtribuApiError) {
|
|
284
|
+
switch (err.retry.action) {
|
|
285
|
+
case "retry": // 5xx, transient
|
|
286
|
+
return queue.retry(job, { delay: 5_000 });
|
|
287
|
+
case "retry_after": // 429 with Retry-After
|
|
288
|
+
return queue.retry(job, { delay: err.retry.retryAfterMs });
|
|
289
|
+
case "refresh_token": // 401
|
|
290
|
+
return refreshOAuthAndRetry();
|
|
291
|
+
case "fix_and_retry": // 422, 4xx caller error
|
|
292
|
+
return logger.error("bad payload", { err, requestId: err.requestId });
|
|
293
|
+
case "do_not_retry": // 403, etc.
|
|
294
|
+
return logger.error("permanent failure", { err, requestId: err.requestId });
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
throw err;
|
|
298
|
+
}
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
| Error class | When it's thrown |
|
|
302
|
+
| --- | --- |
|
|
303
|
+
| `AtribuApiError` | The server returned a non-2xx response for a `/api/v1/*` route. Has `code`, `status`, `requestId`, `retry`, `responseBody`. |
|
|
304
|
+
| `AtribuOauthError` | The OAuth `/oauth/token` or `/oauth/revoke` endpoint returned an RFC-shaped error. Has `code`, `description`, `status`. |
|
|
305
|
+
| `AtribuWebhookError` | A webhook signature failed verification. Has `code: "missing_signature" | "malformed_header" | "expired_timestamp" | "invalid_signature"`. |
|
|
306
|
+
| `AtribuTransportError` | The HTTP request itself failed (network, abort, timeout). Has `cause`. |
|
|
307
|
+
| `AtribuConfigError` | The SDK was configured incorrectly (missing API key, no `fetch` available, etc.). |
|
|
308
|
+
|
|
309
|
+
Every successful request and every `AtribuApiError` surfaces the server's `X-Request-Id` header on `err.requestId` so you can grep server logs for "why".
|
|
310
|
+
|
|
311
|
+
## Configuration
|
|
312
|
+
|
|
313
|
+
```ts
|
|
314
|
+
new AtribuClient({
|
|
315
|
+
apiKey: string, // required
|
|
316
|
+
baseUrl?: string, // default "https://www.atribu.app"
|
|
317
|
+
fetch?: typeof fetch, // default globalThis.fetch
|
|
318
|
+
timeoutMs?: number, // default 30000
|
|
319
|
+
userAgent?: string, // appended after the SDK UA
|
|
320
|
+
defaultIdempotencyKeyGenerator?: () => string,
|
|
321
|
+
});
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
- **Custom `fetch`**: useful for instrumentation (Datadog APM, OTel), for testing (msw), or for runtimes where `globalThis.fetch` isn't the default.
|
|
325
|
+
- **Timeout**: aborts the request via `AbortController` after the budget. Surface as `AtribuTransportError` with `name: "AbortError"` on the cause.
|
|
326
|
+
- **Idempotency keys**: every mutating call automatically sends `Idempotency-Key: <uuid>`. Override per-call via `{ idempotencyKey }` or globally with `defaultIdempotencyKeyGenerator`.
|
|
327
|
+
- **User-Agent**: format `@atribu/sdk/<version> (<runtime>)`. Server uses it for per-version analytics.
|
|
328
|
+
|
|
329
|
+
## OpenTelemetry tracing
|
|
330
|
+
|
|
331
|
+
The custom-`fetch` hook is enough — wrap your tracer around it and every SDK call becomes a child span. No SDK changes needed:
|
|
332
|
+
|
|
333
|
+
```ts
|
|
334
|
+
import { trace, SpanStatusCode, context, propagation } from "@opentelemetry/api";
|
|
335
|
+
import { AtribuClient } from "@atribu/sdk";
|
|
336
|
+
|
|
337
|
+
const tracer = trace.getTracer("my-app");
|
|
338
|
+
|
|
339
|
+
const tracedFetch: typeof fetch = async (input, init) => {
|
|
340
|
+
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
|
341
|
+
const method = init?.method ?? "GET";
|
|
342
|
+
|
|
343
|
+
return tracer.startActiveSpan(`atribu.${method.toLowerCase()} ${new URL(url).pathname}`, async (span) => {
|
|
344
|
+
span.setAttributes({
|
|
345
|
+
"http.method": method,
|
|
346
|
+
"http.url": url,
|
|
347
|
+
"peer.service": "atribu-api",
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
// Inject W3C traceparent headers so server-side spans link up.
|
|
351
|
+
const headers = new Headers(init?.headers);
|
|
352
|
+
propagation.inject(context.active(), headers, {
|
|
353
|
+
set: (carrier, key, value) => carrier.set(key, value),
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
try {
|
|
357
|
+
const res = await fetch(input, { ...init, headers });
|
|
358
|
+
span.setAttribute("http.status_code", res.status);
|
|
359
|
+
const requestId = res.headers.get("x-request-id");
|
|
360
|
+
if (requestId) span.setAttribute("atribu.request_id", requestId);
|
|
361
|
+
if (res.status >= 400) {
|
|
362
|
+
span.setStatus({ code: SpanStatusCode.ERROR, message: `HTTP ${res.status}` });
|
|
363
|
+
}
|
|
364
|
+
return res;
|
|
365
|
+
} catch (err) {
|
|
366
|
+
span.recordException(err as Error);
|
|
367
|
+
span.setStatus({ code: SpanStatusCode.ERROR });
|
|
368
|
+
throw err;
|
|
369
|
+
} finally {
|
|
370
|
+
span.end();
|
|
371
|
+
}
|
|
372
|
+
});
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
const atribu = new AtribuClient({
|
|
376
|
+
apiKey: process.env.ATRIBU_API_KEY!,
|
|
377
|
+
fetch: tracedFetch,
|
|
378
|
+
});
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
Atribu's `X-Request-Id` response header lands as an attribute on every span — same key the server logs use, so you can pivot from a span to the server log with a single grep. Works the same with Datadog APM (`@datadog/dd-trace`), Sentry tracing, or any other fetch-instrumentation pattern.
|
|
382
|
+
|
|
383
|
+
## Admin client (Atribu staff)
|
|
384
|
+
|
|
385
|
+
If you're staff managing consumer apps, the `/admin` subpath wraps the `/api/v1/admin/oauth-apps/*` surface. Requires the `ATRIBU_ADMIN_SECRET`.
|
|
386
|
+
|
|
387
|
+
```ts
|
|
388
|
+
import { AtribuAdminClient } from "@atribu/sdk/admin";
|
|
389
|
+
|
|
390
|
+
const admin = new AtribuAdminClient({
|
|
391
|
+
adminSecret: process.env.ATRIBU_ADMIN_SECRET!,
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
const app = await admin.oauthApps.create({
|
|
395
|
+
client_id: "vitrina",
|
|
396
|
+
name: "Vitrina",
|
|
397
|
+
redirect_uris: ["https://vitrina.app/integrations/atribu/callback"],
|
|
398
|
+
allowed_scopes: ["whatsapp", "instagram"],
|
|
399
|
+
created_by: "your-user-uuid",
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
// secrets are shown ONCE — store them safely
|
|
403
|
+
console.log(app.client_secret, app.jwt_signing_secret);
|
|
404
|
+
|
|
405
|
+
await admin.oauthApps.rotateClientSecret(app.id, { grace_days: 14 });
|
|
406
|
+
await admin.oauthApps.suspend(app.id); // kill-switch — revokes every api_key minted by this app
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
## Runtime support
|
|
410
|
+
|
|
411
|
+
| Runtime | Supported | Notes |
|
|
412
|
+
| --- | --- | --- |
|
|
413
|
+
| Node 18+ | ✅ | Uses `globalThis.fetch` + Web Crypto |
|
|
414
|
+
| Node 16 | ❌ | No native fetch; install `undici` and inject |
|
|
415
|
+
| Bun | ✅ | |
|
|
416
|
+
| Deno | ✅ | Import via `npm:@atribu/sdk` |
|
|
417
|
+
| Vercel Edge | ✅ | |
|
|
418
|
+
| Cloudflare Workers | ✅ | |
|
|
419
|
+
| Browser | ❌ by design | API keys belong on a server |
|
|
420
|
+
|
|
421
|
+
## Versioning
|
|
422
|
+
|
|
423
|
+
SDK versions follow semver. Pre-1.0 (`0.x`) means the surface may still evolve based on real consumer feedback. Once Vitrina has shipped and the surface is stable, we'll bump to `1.0.0`.
|
|
424
|
+
|
|
425
|
+
## Repository
|
|
426
|
+
|
|
427
|
+
Source: [github.com/atribu/atribu/tree/main/packages/sdk](https://github.com/atribu/atribu/tree/main/packages/sdk)
|
|
428
|
+
|
|
429
|
+
## License
|
|
430
|
+
|
|
431
|
+
MIT
|