@atribu/node 0.1.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.
Files changed (46) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/LICENSE +21 -0
  3. package/README.md +423 -0
  4. package/dist/admin/index.cjs +326 -0
  5. package/dist/admin/index.cjs.map +1 -0
  6. package/dist/admin/index.d.cts +46 -0
  7. package/dist/admin/index.d.ts +46 -0
  8. package/dist/admin/index.js +323 -0
  9. package/dist/admin/index.js.map +1 -0
  10. package/dist/api.d-BXINTQo6.d.cts +3547 -0
  11. package/dist/api.d-BXINTQo6.d.ts +3547 -0
  12. package/dist/errors-D3ApBz8J.d.cts +86 -0
  13. package/dist/errors-D3ApBz8J.d.ts +86 -0
  14. package/dist/index.cjs +549 -0
  15. package/dist/index.cjs.map +1 -0
  16. package/dist/index.d.cts +198 -0
  17. package/dist/index.d.ts +198 -0
  18. package/dist/index.js +536 -0
  19. package/dist/index.js.map +1 -0
  20. package/dist/next/index.cjs +153 -0
  21. package/dist/next/index.cjs.map +1 -0
  22. package/dist/next/index.d.cts +43 -0
  23. package/dist/next/index.d.ts +43 -0
  24. package/dist/next/index.js +151 -0
  25. package/dist/next/index.js.map +1 -0
  26. package/dist/oauth/index.cjs +299 -0
  27. package/dist/oauth/index.cjs.map +1 -0
  28. package/dist/oauth/index.d.cts +117 -0
  29. package/dist/oauth/index.d.ts +117 -0
  30. package/dist/oauth/index.js +291 -0
  31. package/dist/oauth/index.js.map +1 -0
  32. package/dist/test/index.cjs +443 -0
  33. package/dist/test/index.cjs.map +1 -0
  34. package/dist/test/index.d.cts +321 -0
  35. package/dist/test/index.d.ts +321 -0
  36. package/dist/test/index.js +437 -0
  37. package/dist/test/index.js.map +1 -0
  38. package/dist/types-Dc6tIN_V.d.cts +101 -0
  39. package/dist/types-Dc6tIN_V.d.ts +101 -0
  40. package/dist/webhooks/index.cjs +97 -0
  41. package/dist/webhooks/index.cjs.map +1 -0
  42. package/dist/webhooks/index.d.cts +35 -0
  43. package/dist/webhooks/index.d.ts +35 -0
  44. package/dist/webhooks/index.js +94 -0
  45. package/dist/webhooks/index.js.map +1 -0
  46. package/package.json +101 -0
package/CHANGELOG.md ADDED
@@ -0,0 +1,27 @@
1
+ # Changelog
2
+
3
+ All notable changes to `@atribu/node` 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.1.0] — 2026-05-15
11
+
12
+ Initial public release of `@atribu/node`. Previously developed and briefly published as `@atribu/sdk` (versions 0.1.0 and 0.2.0, both unpublished); the package was renamed to better signal scope as the Atribu ecosystem grows. Paired with `@atribu/tracker` for browser-side analytics.
13
+
14
+ ### Features
15
+
16
+ - **`AtribuClient`** — typed access to messaging, IG comment replies, webhook subscription CRUD + rotation + test fire, and webhook delivery replay.
17
+ - **`@atribu/node/webhooks`** — `verifyWebhook` via Web Crypto with rotation grace, configurable timestamp tolerance, constant-time HMAC compare. Discriminated union of every event shape (WhatsApp message-received, WhatsApp delivery, Instagram fb_login message + postback, Instagram ig_login change, Instagram delivery).
18
+ - **`@atribu/node/oauth`** — consumer-side OAuth 2.0 + RFC 7636 PKCE helpers: `buildAuthorizeUrl`, `exchangeCode`, `revokeToken`, `signIdTokenHint` (jose, optional peer), `generateCodeVerifier`, `computeCodeChallenge`.
19
+ - **`@atribu/node/next`** — `withAtribuWebhook` HOF for Next.js App Router route handlers (~50 LOC of boilerplate replaced).
20
+ - **`@atribu/node/test`** — drop-in MSW v2 handlers covering every OAuth-consumer endpoint, plus `eventFixtures` and `responseFixtures` with deep-merge override support.
21
+ - **Typed error hierarchy** — `AtribuApiError`, `AtribuOauthError`, `AtribuWebhookError`, `AtribuTransportError`, `AtribuConfigError`.
22
+ - **Opt-in retry layer** — `client.withRetry({ maxAttempts, backoff, baseDelayMs, maxDelayMs, jitter })`. Honors the typed `retry` hint exactly: retries `retry`/`retry_after` actions, never retries `do_not_retry`/`fix_and_retry`/`refresh_token`. Honors `Retry-After` with no jitter.
23
+ - **`Idempotency-Key` auto-sent** on every mutating POST.
24
+ - **`request_id` surfaced** on every error for log correlation.
25
+ - **OpenAPI-driven types** — request/response shapes generated from the live spec; zero drift from the server.
26
+ - **Edge-compatible** — Node 18+, Bun, Deno, Vercel Edge, Cloudflare Workers. Uses Web Crypto throughout, no `node:crypto` imports.
27
+ - **82 unit tests** including a server↔SDK signature-parity check that catches any HMAC contract drift.
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,423 @@
1
+ <p align="center">
2
+ <a href="https://atribu.app">
3
+ <img src="https://atribu.app/brand/atribu-wordmark-square-800.png" alt="Atribu" width="120">
4
+ </a>
5
+ </p>
6
+
7
+ <h1 align="center">Atribu Node.js SDK</h1>
8
+
9
+ <p align="center">
10
+ <a href="https://www.npmjs.com/package/@atribu/node"><img src="https://img.shields.io/npm/v/@atribu/node.svg" alt="npm version"></a>
11
+ <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="License"></a>
12
+ </p>
13
+
14
+ <p align="center">
15
+ <strong>Authorize users, send WhatsApp & Instagram messages, and verify signed webhook deliveries — through one API.</strong>
16
+ </p>
17
+
18
+ The official Node.js SDK for the [Atribu API](https://atribu.app) — typed access to messaging, IG comment replies, webhook subscriptions, OAuth 2.0 consumer helpers, and signed-webhook verification.
19
+
20
+ ## Installation
21
+
22
+ ```bash
23
+ npm install @atribu/node
24
+
25
+ # Optional peer deps:
26
+ npm install jose # only if you use the @atribu/node/oauth helpers
27
+ npm install msw # only if you use @atribu/node/test
28
+ ```
29
+
30
+ ## Quick Start
31
+
32
+ ```typescript
33
+ import { AtribuClient } from "@atribu/node";
34
+
35
+ const atribu = new AtribuClient({ apiKey: process.env.ATRIBU_API_KEY });
36
+
37
+ const result = await atribu.messages.send({
38
+ connection_id: "11111111-1111-1111-1111-111111111111",
39
+ channel: "whatsapp",
40
+ to: "+15551234567",
41
+ content: { type: "text", text: "Hello from @atribu/node!" },
42
+ });
43
+
44
+ console.log("Sent:", result.provider_message_id);
45
+ ```
46
+
47
+ ## Configuration
48
+
49
+ ```typescript
50
+ const atribu = new AtribuClient({
51
+ apiKey: "atb_live_...", // required
52
+ baseUrl: "https://www.atribu.app", // default
53
+ fetch: customFetch, // optional — bring your own (tracing, edge)
54
+ timeoutMs: 30_000, // default 30s
55
+ userAgent: "MyApp/1.0", // appended after the SDK User-Agent
56
+ });
57
+ ```
58
+
59
+ `Idempotency-Key` is auto-sent on every mutating POST. `request_id` is surfaced on every error.
60
+
61
+ ## Examples
62
+
63
+ ### Send a WhatsApp template
64
+
65
+ ```typescript
66
+ await atribu.messages.send({
67
+ connection_id: connectionId,
68
+ channel: "whatsapp",
69
+ to: "+15551234567",
70
+ content: {
71
+ type: "template",
72
+ template_name: "appointment_reminder",
73
+ language_code: "en_US",
74
+ components: [
75
+ { type: "body", parameters: [{ type: "text", text: "Tuesday at 3pm" }] },
76
+ ],
77
+ },
78
+ });
79
+ ```
80
+
81
+ ### Send a WhatsApp image
82
+
83
+ ```typescript
84
+ // Either pre-uploaded media (recommended for high fanout):
85
+ await atribu.messages.send({
86
+ connection_id: connectionId,
87
+ channel: "whatsapp",
88
+ to: "+15551234567",
89
+ content: {
90
+ type: "image",
91
+ media: { media_id: "1234567890" },
92
+ caption: "Your invoice",
93
+ },
94
+ });
95
+
96
+ // Or by public HTTPS link (Meta fetches once per send, no caching):
97
+ await atribu.messages.send({
98
+ connection_id: connectionId,
99
+ channel: "whatsapp",
100
+ to: "+15551234567",
101
+ content: {
102
+ type: "image",
103
+ media: { link: "https://cdn.example.com/invoice.png" },
104
+ },
105
+ });
106
+ ```
107
+
108
+ ### Reply to an Instagram comment
109
+
110
+ ```typescript
111
+ // Public reply on the comment thread:
112
+ await atribu.comments.reply({
113
+ comment_id: "ig_comment_id",
114
+ connection_id: connectionId,
115
+ text: "Thanks! DMing you now.",
116
+ });
117
+
118
+ // Private DM to the commenter:
119
+ await atribu.comments.privateReply({
120
+ comment_id: "ig_comment_id",
121
+ connection_id: connectionId,
122
+ text: "Here are the details you asked about ...",
123
+ });
124
+ ```
125
+
126
+ ### Manage webhook subscriptions
127
+
128
+ ```typescript
129
+ // One subscription per (app, profile, URL):
130
+ const sub = await atribu.webhooks.subscriptions.create({
131
+ url: "https://your.app/api/atribu-webhook",
132
+ events: ["message.received", "message.delivery"],
133
+ providers: ["whatsapp", "instagram"],
134
+ });
135
+ console.log("Webhook secret (shown once):", sub.secret);
136
+
137
+ // Rotate the HMAC secret with a grace window — deploy dual-verify BEFORE calling this:
138
+ const rotated = await atribu.webhooks.subscriptions.rotateSecret(sub.id, {
139
+ grace_days: 14,
140
+ });
141
+
142
+ // Fire a synthetic event to verify your handler:
143
+ await atribu.webhooks.subscriptions.test(sub.id);
144
+
145
+ // Re-deliver a dead webhook:
146
+ await atribu.webhooks.deliveries.replay(deadDeliveryId);
147
+ ```
148
+
149
+ ## Verifying Webhooks
150
+
151
+ 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.
152
+
153
+ ### Next.js App Router
154
+
155
+ ```typescript
156
+ // app/api/atribu-webhook/route.ts
157
+ import { withAtribuWebhook } from "@atribu/node/next";
158
+
159
+ export const POST = withAtribuWebhook({
160
+ secret: process.env.ATRIBU_WEBHOOK_SECRET!,
161
+ previousSecret: process.env.ATRIBU_WEBHOOK_PREVIOUS_SECRET,
162
+ onEvent: async (event) => {
163
+ if (event.type === "message.received" && event.provider === "whatsapp") {
164
+ // event.data.wa_message_id is typed string
165
+ console.log(`WA message from ${event.data.from}: ${event.data.text}`);
166
+ }
167
+ },
168
+ });
169
+ ```
170
+
171
+ ### Manual verification
172
+
173
+ ```typescript
174
+ import { verifyWebhook } from "@atribu/node/webhooks";
175
+
176
+ export async function POST(req: Request) {
177
+ try {
178
+ const event = await verifyWebhook({
179
+ rawBody: await req.text(),
180
+ signature: req.headers.get("x-atribu-signature"),
181
+ secret: process.env.ATRIBU_WEBHOOK_SECRET!,
182
+ previousSecret: process.env.ATRIBU_WEBHOOK_PREVIOUS_SECRET, // during rotation
183
+ tolerance: 300, // seconds; default 5 min
184
+ });
185
+ // ... handle the typed event
186
+ return new Response(null, { status: 200 });
187
+ } catch {
188
+ return new Response("invalid signature", { status: 401 });
189
+ }
190
+ }
191
+ ```
192
+
193
+ The unique `event.id` and the `X-Atribu-Delivery-Id` header give you idempotency keys for safe redelivery.
194
+
195
+ ## OAuth Flow
196
+
197
+ 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.
198
+
199
+ ```typescript
200
+ import {
201
+ buildAuthorizeUrl,
202
+ signIdTokenHint,
203
+ exchangeCode,
204
+ revokeToken,
205
+ generateCodeVerifier,
206
+ computeCodeChallenge,
207
+ } from "@atribu/node/oauth";
208
+
209
+ // 1. Redirect to consent
210
+ const codeVerifier = generateCodeVerifier();
211
+ const idTokenHint = await signIdTokenHint({
212
+ jwtSigningSecret: process.env.ATRIBU_APP_JWT_SECRET!,
213
+ subject: user.id,
214
+ email: user.email,
215
+ expiresIn: "5m",
216
+ });
217
+
218
+ const url = buildAuthorizeUrl({
219
+ clientId: "your-app-id",
220
+ redirectUri: "https://your.app/integrations/atribu/callback",
221
+ provider: "whatsapp",
222
+ scope: "whatsapp",
223
+ state: csrfToken,
224
+ idTokenHint,
225
+ codeChallenge: await computeCodeChallenge(codeVerifier),
226
+ codeChallengeMethod: "S256",
227
+ });
228
+ // Redirect the user to `url`.
229
+
230
+ // 2. Handle the callback
231
+ const { accessToken, connectionId, scope } = await exchangeCode({
232
+ clientId: "your-app-id",
233
+ clientSecret: process.env.ATRIBU_APP_CLIENT_SECRET!,
234
+ code: callbackQuery.code,
235
+ redirectUri: "https://your.app/integrations/atribu/callback",
236
+ codeVerifier,
237
+ });
238
+
239
+ // Persist accessToken + connectionId per user. accessToken IS the API key.
240
+
241
+ // 3. Revoke later (e.g. user disconnects)
242
+ await revokeToken({
243
+ clientId: "your-app-id",
244
+ clientSecret: process.env.ATRIBU_APP_CLIENT_SECRET!,
245
+ token: accessToken,
246
+ });
247
+ ```
248
+
249
+ ## Error Handling
250
+
251
+ ```typescript
252
+ import { AtribuClient, AtribuApiError } from "@atribu/node";
253
+
254
+ try {
255
+ await atribu.messages.send({ /* ... */ });
256
+ } catch (err) {
257
+ if (err instanceof AtribuApiError) {
258
+ switch (err.retry.action) {
259
+ case "retry": return queue.retry(job, { delay: 5_000 });
260
+ case "retry_after": return queue.retry(job, { delay: err.retry.retryAfterMs });
261
+ case "refresh_token": return refreshOAuthAndRetry();
262
+ case "fix_and_retry": logger.error("bad payload", { requestId: err.requestId }); break;
263
+ case "do_not_retry": logger.error("permanent failure", { requestId: err.requestId }); break;
264
+ }
265
+ }
266
+ }
267
+ ```
268
+
269
+ | Error | Thrown when |
270
+ | --- | --- |
271
+ | `AtribuApiError` | `/api/v1/*` returned non-2xx. Has `code`, `status`, `requestId`, `retry`, `responseBody`. |
272
+ | `AtribuOauthError` | RFC 6749/7009 error from `/oauth/*`. Has `code`, `description`, `status`. |
273
+ | `AtribuWebhookError` | Signature verification failed. Has `code: "missing_signature" \| "malformed_header" \| "expired_timestamp" \| "invalid_signature"`. |
274
+ | `AtribuTransportError` | Network glitch / timeout / abort. Has `cause`. |
275
+ | `AtribuConfigError` | Bad client configuration. |
276
+
277
+ The server's `X-Request-Id` is surfaced as `err.requestId` so you can grep server logs.
278
+
279
+ ## Retries
280
+
281
+ The SDK doesn't retry automatically — hiding retries amplifies load on a failing server and obscures backpressure. Opt in per-client:
282
+
283
+ ```typescript
284
+ const atribu = new AtribuClient({ apiKey: "..." }).withRetry({
285
+ maxAttempts: 3, // initial + 2 retries
286
+ backoff: "exponential", // or "fixed" or "none"
287
+ baseDelayMs: 500,
288
+ maxDelayMs: 30_000,
289
+ jitter: 0.3,
290
+ });
291
+ ```
292
+
293
+ The wrapper respects the typed `retry` hint exactly:
294
+
295
+ | Condition | Behavior |
296
+ |---|---|
297
+ | 5xx, 408, network glitch | Exponential / fixed backoff with jitter |
298
+ | 429 / 503 with `Retry-After` | Honored exactly, no jitter |
299
+ | 401 (`refresh_token`) | Not retried — refresh credentials, don't retry |
300
+ | 422 (`fix_and_retry`) | Not retried — your input is bad |
301
+ | 403 (`do_not_retry`) | Not retried — permission denied |
302
+
303
+ ## Testing
304
+
305
+ ```typescript
306
+ import { setupServer } from "msw/node";
307
+ import { atribuMockHandlers, eventFixtures } from "@atribu/node/test";
308
+
309
+ const server = setupServer(...atribuMockHandlers({
310
+ // Override specific endpoints; everything else gets a realistic default.
311
+ messages: {
312
+ send: { status: 422, body: { error: { code: "validation_error", message: "...", status: 422 } } },
313
+ },
314
+ }));
315
+
316
+ // Drive your webhook handler tests with realistic event shapes:
317
+ const event = eventFixtures.whatsappMessageReceived({
318
+ data: { text: "Custom test message" },
319
+ });
320
+ ```
321
+
322
+ `msw@^2.0.0` must be installed.
323
+
324
+ ## OpenTelemetry / Datadog APM / Sentry
325
+
326
+ Inject your own `fetch` to trace every SDK call. No SDK change needed:
327
+
328
+ ```typescript
329
+ import { trace, context, propagation } from "@opentelemetry/api";
330
+ import { AtribuClient } from "@atribu/node";
331
+
332
+ const tracer = trace.getTracer("my-app");
333
+
334
+ const tracedFetch: typeof fetch = (input, init) =>
335
+ tracer.startActiveSpan(`atribu.${(init?.method ?? "GET").toLowerCase()}`, async (span) => {
336
+ const headers = new Headers(init?.headers);
337
+ propagation.inject(context.active(), headers, { set: (h, k, v) => h.set(k, v) });
338
+ try {
339
+ const res = await fetch(input, { ...init, headers });
340
+ span.setAttribute("http.status_code", res.status);
341
+ const requestId = res.headers.get("x-request-id");
342
+ if (requestId) span.setAttribute("atribu.request_id", requestId);
343
+ return res;
344
+ } finally { span.end(); }
345
+ });
346
+
347
+ const atribu = new AtribuClient({ apiKey: "...", fetch: tracedFetch });
348
+ ```
349
+
350
+ The SDK's `User-Agent` and Atribu's `X-Request-Id` give you log-grep correlation out of the box.
351
+
352
+ ## SDK Reference
353
+
354
+ ### Messaging — `@atribu/node`
355
+ | Method | Description |
356
+ |---|---|
357
+ | `messages.send()` | Send a WhatsApp or Instagram message |
358
+ | `comments.reply()` | Public reply on an IG comment thread |
359
+ | `comments.privateReply()` | Send a DM to the user who left an IG comment |
360
+ | `webhooks.subscriptions.list()` | List your webhook subscriptions |
361
+ | `webhooks.subscriptions.create()` | Create a webhook subscription (secret shown once) |
362
+ | `webhooks.subscriptions.update()` | Update URL / events / providers / status |
363
+ | `webhooks.subscriptions.delete()` | Delete a webhook subscription |
364
+ | `webhooks.subscriptions.rotateSecret()` | Rotate the HMAC secret with a grace window |
365
+ | `webhooks.subscriptions.test()` | Fire a synthetic event to test your handler |
366
+ | `webhooks.deliveries.replay()` | Re-deliver a dead webhook |
367
+ | `withRetry()` | Return a new client that retries transient errors |
368
+
369
+ ### Webhook verification — `@atribu/node/webhooks`
370
+ | Symbol | Description |
371
+ |---|---|
372
+ | `verifyWebhook()` | Verify a signed payload — Web Crypto, rotation grace, constant-time |
373
+ | `AtribuWebhookEvent` | Discriminated union — every event shape, fully typed |
374
+
375
+ ### OAuth helpers — `@atribu/node/oauth`
376
+ | Symbol | Description |
377
+ |---|---|
378
+ | `buildAuthorizeUrl()` | Construct the `/oauth/authorize` redirect URL |
379
+ | `exchangeCode()` | Trade an authorization code for an access token |
380
+ | `revokeToken()` | RFC 7009 revocation |
381
+ | `signIdTokenHint()` | Sign an `id_token_hint` JWT (HS256, requires `jose`) |
382
+ | `generateCodeVerifier()` | PKCE verifier (RFC 7636) |
383
+ | `computeCodeChallenge()` | PKCE challenge (SHA-256, base64url) |
384
+
385
+ ### Next.js — `@atribu/node/next`
386
+ | Symbol | Description |
387
+ |---|---|
388
+ | `withAtribuWebhook()` | Wrap a Next.js App Router route handler in signature verification |
389
+
390
+ ### Test helpers — `@atribu/node/test`
391
+ | Symbol | Description |
392
+ |---|---|
393
+ | `atribuMockHandlers()` | MSW v2 handlers for every endpoint (with overrides) |
394
+ | `eventFixtures` | Pre-canned event shapes — deep-merge any field |
395
+ | `responseFixtures` | Pre-canned API response envelopes |
396
+
397
+ ## Runtime Support
398
+
399
+ | Runtime | Supported |
400
+ |---|---|
401
+ | Node 18+ | ✅ |
402
+ | Bun | ✅ |
403
+ | Deno | ✅ — `npm:@atribu/node` |
404
+ | Vercel Edge | ✅ |
405
+ | Cloudflare Workers | ✅ |
406
+ | Browser | ❌ by design — API keys don't belong in client JS |
407
+
408
+ Uses Web Crypto throughout — no `node:crypto` imports.
409
+
410
+ ## Requirements
411
+
412
+ - Node.js 18+ (or any WinterCG-compatible runtime)
413
+ - An [Atribu API key](https://atribu.app)
414
+
415
+ ## Links
416
+
417
+ - [Documentation](https://atribu.app/docs)
418
+ - [Dashboard](https://atribu.app)
419
+ - [Changelog](./CHANGELOG.md)
420
+
421
+ ## License
422
+
423
+ MIT