@axis-social/sdk
v0.1.2
Published
Axis Social SDK — typed client with Bearer auth, automatic idempotency keys, retries, and a webhook signature verifier
Maintainers
Readme
@axis-social/sdk
Typed TypeScript client for the Axis Social API — one class that wraps every endpoint (connections, publishing, inbox, outbound webhooks, realtime) with Bearer auth, automatic idempotency keys, retry-on-429/5xx, and a webhook signature verifier.
1. Install
yarn add @axis-social/sdk
# or
npm install @axis-social/sdkRequires Node 18+ (or any runtime with a global fetch and crypto.randomUUID).
2. The mental model
Everything in Axis Social hangs off three nouns. Knowing how they relate keeps you from guessing at IDs later.
App key (axs_…) ──auth──► Tenant ──owns──► Connection (a connected IG/FB/… account)
│
┌─────────────────┼──────────────────┐
▼ ▼ ▼
Post (publish) InboxThread InboxAccount
(DMs / comments) (per-connection settings)- App key —
axs_{env}_{keyId}.{secret}. Identifies which integration app is calling. Sent as aBearertoken. The SDK validates the shape locally and refuses to construct if it's malformed. - Tenant — the identity everything is scoped to, sent on every request as
x-axis-tenant. It MUST be the Axis Accounts (SSO) user id, namespaced asws_<axisAuthId>— derived from the logged-in session the same way in every Axis app. The SSO id is the only identifier shared across Axis apps, so using it as the tenant is what lets a connection made in one app (e.g. Engage) appear in another (Social) with no reconnect. Do NOT pass a local per-app workspace/DB id — those differ per app and fragment the same user across apps. The server auto-provisions the internal tenant on first use, so a newws_<axisAuthId>value works immediately. One app key serves many tenants; you fix the tenant when you build the client. - Connection — one connected social account (an Instagram account, a Facebook Page, …), identified by a
ConnectionDto.id. You get one by running the connect flow (§4). Almost everything downstream — posting, inbox threads, account settings — is keyed byconnectionId.
Auth is Bearer-only. Despite the historical package description mentioning HMAC request signing, the client authenticates requests purely with the
Bearerapp key plus thex-axis-tenantheader. HMAC only appears in one place: verifying inbound outbound-webhook deliveries (§7). Don't sign your own requests.
3. Construct the client
import { AxisSocial, AxisSocialError } from '@axis-social/sdk';
const axis = new AxisSocial({
baseUrl: 'https://api.myaxis.ai', // no trailing slash needed — it's trimmed for you
appKey: process.env.AXIS_APP_KEY!, // axs_{env}_{keyId}.{secret}
tenant: `ws_${axisAuthId}`, // Axis Accounts (SSO) user id, namespaced ws_<axisAuthId> — same in every Axis app
// optional:
// fetch: customFetch, // defaults to global fetch (auto-bound)
// maxRetries: 3, // 429/5xx retries before giving up (default 3)
});
// Smoke-test credentials:
await axis.ping(); // -> { ok: true }
await axis.whoami(); // -> identity the key/tenant resolve toEvery method returns a Promise of the unwrapped payload — the SDK strips the { data } envelope for you. List endpoints that paginate return { data, meta } instead (see §6).
How errors surface
Any non-2xx response throws an AxisSocialError (never a bare fetch rejection for HTTP errors):
try {
await axis.posts.create({
targets: [
/* … */
],
});
} catch (e) {
if (e instanceof AxisSocialError) {
e.status; // HTTP status, e.g. 409
e.code; // machine code, e.g. "missing_capability"
e.message; // human-readable
e.details; // structured hints, e.g. { connectIntegrationKey: "meta" }
e.body; // full parsed error envelope
}
}e.details.connectIntegrationKey is the actionable one: when a write fails because the connection lacks a capability, it tells you which integration to connect to gain it.
4. Connect a social account (the OAuth-style flow)
You can't post or read an inbox until a tenant has a connection. Connecting is a two-step redirect dance — start hands you a URL to send the user to, the provider redirects back, and complete finalizes it.
// Step 1 — begin. `network` is the platform: "instagram" | "facebook" | …
const startRes = await axis.connections.start('instagram', {
redirectUrl: 'https://app.example.com/connections/callback', // where the provider returns the user
// integration: "aggregator", // default; pick a specific integration if you support more than one
});
// startRes.kind tells you how to proceed:
// "redirect" -> send the browser to startRes.redirectUrl
// "qr" -> render startRes.qr for the user to scan
// "embeddedSignup" -> launch the provider's embedded signup widget
window.location.href = startRes.redirectUrl!;
// Step 2 — after the provider redirects back to your redirectUrl, finalize:
const connection = await axis.connections.complete('instagram', {
// accountId / network / provider params arrive on your callback query string — forward them here
});
connection.id; // <- the connectionId you'll use everywhere downstream
connection.connected; // true
connection.bindings; // the surfaces (feed, story, dm, comments…) this account can act onInspecting, switching, and the non-destructive lifecycle
await axis.connections.list(); // every connection for this tenant
await axis.connections.capabilities('instagram'); // what a network *could* do once connected
// Disconnect WITHOUT losing history. Tears down the upstream account (stops vendor billing,
// fully disconnects on Meta) but KEEPS the connection record and all saved DMs/comments.
await axis.connections.disconnect(connection.id);
// Reconnect re-authorizes the retained account and reuses its stored history.
// Returns a redirect to follow, exactly like start(). Pass redirectUrl for a one-call connect.
const re = await axis.connections.reconnect(connection.id, {
redirectUrl: 'https://app.example.com/connections/callback',
});
// Move an existing connection to a different integration without re-running the full connect:
await axis.connections.switchIntegration(connection.id, {
integration: 'meta',
});Disconnect is non-destructive by design. There is no "delete connection" in this SDK. Disconnect → reconnect preserves the thread/entry history end-to-end.
5. Publish a post
A post fans out to one or more targets. Each target names a connectionId and a surface (e.g. "feed", "story") — the surfaces available come from the connection's bindings.
const post = await axis.posts.create({
content: 'Launch day. 🚀',
media: [{ url: 'https://cdn.example.com/hero.jpg', type: 'image' }],
targets: [
{ connectionId: connection.id, surface: 'feed' },
{ connectionId: connection.id, surface: 'story' },
],
// scheduledFor: "2026-07-01T09:00:00Z", // omit to publish now
});
post.id;
post.status; // "scheduled" | "publishing" | "published" | "failed" | …
post.results; // per-surface outcome
post.externalIds; // provider-side ids once published, keyed by surface
// Lifecycle:
await axis.posts.get(post.id);
await axis.posts.update(post.id, { content: 'Edited copy' }); // before it publishes
await axis.posts.retry(post.id); // re-attempt a failed post
await axis.posts.delete(post.id);
// Poll several posts at once (e.g. a feed of cards):
await axis.posts.liveStatus([post.id, 'post_2', 'post_3']);Writes are idempotent automatically — see §8. To dedupe a user double-clicking "Publish", pass your own key:
await axis.posts.create(input, { idempotencyKey: `publish:${draftId}` });6. Read and work the inbox
The inbox unifies DMs and comments into threads. A thread holds entries (individual messages or comments). Comment threads nest one level of replies.
List threads (cursor-paginated)
This is the one family that returns the { data, meta } envelope, because you page through it:
let cursor: string | undefined;
do {
const page = await axis.inbox.threads.list({
type: 'dm', // "dm" | "comment"
status: 'open',
unreadOnly: true,
connectionId: connection.id,
limit: 50, // default 25, max 100
cursor, // omit on the first call
});
for (const thread of page.data) {
thread.unreadCount;
thread.previewText;
thread.lastInboundAt; // provider event time, not ingest time
}
cursor = page.meta.nextCursor; // undefined when you've reached the end
} while (cursor);Times are provider times. Entries expose
platformCreatedAt(when the user actually sent it) alongsidecreatedAt(when Axis ingested it). Ordering andlastInboundAtuse the provider time, so backfilled history shows real send times. Sort/display onplatformCreatedAt ?? createdAt.
Read one thread and reply
const thread = await axis.inbox.threads.get(threadId); // includes participants + entries
const entry = await axis.inbox.threads.reply(threadId, {
message: 'Thanks for reaching out!',
// attachmentUrl / attachmentType — to send media
// parentEntryId — to reply under a specific comment
});
await axis.inbox.threads.setStatus(threadId, { status: 'closed' });Moderate comments
action() is the general verb; the named helpers are thin wrappers over it. entryId is the comment entry's id.
await axis.inbox.threads.hide(threadId, entryId);
await axis.inbox.threads.unhide(threadId, entryId);
await axis.inbox.threads.like(threadId, entryId);
await axis.inbox.threads.unlike(threadId, entryId);
await axis.inbox.threads.deleteComment(threadId, entryId);
// Reply to a public comment privately, as a DM to its author:
await axis.inbox.threads.privateReply(
threadId,
entryId,
'DMing you the details!',
);
// Equivalent low-level form:
await axis.inbox.threads.action(threadId, { action: 'hide', entryId });Per-account inbox settings
await axis.inbox.accounts.list();
await axis.inbox.accounts.updateSettings(connection.id, {
isEnabled: true,
config: { aiMode: 'suggest', syncEnabled: true }, // stored verbatim — the service never acts on these
});config is a free-form bag the service persists but never interprets. Use it to stash your own per-connection flags.
7. Receive events via outbound webhooks
Instead of polling, register an HTTPS endpoint and Axis will POST events to it (new DM, new comment, post status change, …).
Register an endpoint
const ep = await axis.webhooks.register(
'https://app.example.com/hooks/axis',
['message.received', 'comment.received'], // omit the array to subscribe to ALL events
);
ep.secret; // ⚠️ returned EXACTLY ONCE — store it now; list() never returns it again
await axis.webhooks.list(); // endpoints (without secrets)
await axis.webhooks.delete(ep.id);Verify and handle a delivery
Every delivery carries a signature header. Verify it before trusting the body. The SDK ships the exact verifier:
import { verifyWebhookSignature } from '@axis-social/sdk';
// Express-style handler. You MUST have the raw, unparsed request body.
app.post('/hooks/axis', express.raw({ type: '*/*' }), (req, res) => {
const rawBody = req.body.toString('utf8');
const timestamp = req.header('x-axis-timestamp')!;
const signature = req.header('x-axis-signature')!; // "sha256=<hex>"
const ok = verifyWebhookSignature({
secret: process.env.AXIS_WEBHOOK_SECRET!, // the secret from register()
rawBody,
timestamp,
signature,
});
if (!ok) return res.status(401).end();
const event = JSON.parse(rawBody); // { type, …payload }
switch (event.type) {
case 'message.received':
/* … */ break;
case 'comment.received':
/* … */ break;
}
res.status(200).end();
});The signature is sha256= + HMAC-SHA256 of `${timestamp}.${rawBody}` using your endpoint secret. Other headers on each delivery: x-axis-event (the event type) and x-axis-delivery (a unique delivery id, useful for your own dedupe/logging).
Browser realtime (alternative to webhooks)
For live UI, mint a short-lived token instead of exposing the app key to the browser:
const { token, wsUrl, expiresIn } = await axis.realtime.token();
// open a WebSocket to wsUrl with `token` — the tenant is embedded in it.8. Cross-cutting behavior (how the client treats every call)
- Idempotency. Every write (
POST/PATCH/DELETE) sends anIdempotency-Key. If you don't pass one, the SDK generates a UUID per logical write and keeps it stable across retries, so a retried request never double-applies. Pass your own ({ idempotencyKey }) to dedupe across separate calls — e.g. a user clicking a button twice. - Retries.
429and5xxresponses retry up tomaxRetries(default 3).429honors theRetry-Afterheader; otherwise backoff ismin(2^attempt, 8)seconds. The idempotency key is reused on every retry, so retries are safe. - Envelope unwrapping.
request()-based methods returndatadirectly; paginatedlist()methods return{ data, meta }. - Abort. Pass
{ signal }(anAbortSignal) to any call to cancel it. - Headers, automatically:
Authorization: Bearer <appKey>,x-axis-tenant: <tenant>,Idempotency-Key(writes),content-type: application/json(when there's a body).
9. Invariants & gotchas
The rules that bite if you get them wrong:
- Never construct or sign requests by hand. Use the
AxisSocialinstance. Auth is Bearer-only; the SDK adds every required header. tenantMUST bews_<axisAuthId>— the Axis Accounts (SSO) user id, never a local per-app workspace/DB id. Using anything else fragments the user's data across Axis apps.connectionIdis the spine. To post, read inbox, or change settings, you first need a connection fromconnections.list()or the connect flow. Don't fabricate one.- Don't delete connections. Use
disconnect→reconnect; history is preserved deliberately. - Read provider time, not ingest time when ordering or displaying messages:
platformCreatedAt ?? createdAt. - The webhook secret is shown once. When you write
register()code, also write the code that persistsep.secret. - Catch
AxisSocialErrorand branch on.code/.details.connectIntegrationKey, not on string-matching.message. - Pagination is cursor-based. Loop on
meta.nextCursoruntil it'sundefined; never assume offset/limit.
10. Reference
Client surface
| Group | Method | HTTP | Returns |
| ---------------- | ------------------------------------------------------------------------ | -------------------------------------------------- | -------------------------------------- |
| connections | list() | GET /v1/connections | ConnectionDto[] |
| | capabilities(network?) | GET /v1/integrations/capabilities | CapabilitiesResponse |
| | start(network, body?, opts?) | POST /v1/connections/{network}/start | StartConnectResponse |
| | complete(network, body?, opts?) | POST /v1/connections/{network}/complete | ConnectionDto |
| | reconnect(id, body?, opts?) | POST /v1/connections/{id}/reconnect | StartConnectResponse |
| | disconnect(id, opts?) | POST /v1/connections/{id}/disconnect | ConnectionDto |
| | switchIntegration(id, body, opts?) | POST /v1/connections/{id}/switch-integration | ConnectionDto |
| posts | create(input, opts?) | POST /v1/posts | PostDto |
| | list(query?) | GET /v1/posts | PagedResponse<PostDto> |
| | get(id) | GET /v1/posts/{id} | PostDto |
| | update(id, patch, opts?) | PATCH /v1/posts/{id} | PostDto |
| | retry(id, opts?) | POST /v1/posts/{id}/retry | PostDto |
| | delete(id, opts?) | DELETE /v1/posts/{id} | — |
| | liveStatus(ids) | GET /v1/posts/live-status | LiveStatusResponse |
| inbox.threads | list(query?) | GET /v1/inbox/threads | PagedResponse<InboxThreadDto> |
| | get(id) | GET /v1/inbox/threads/{id} | InboxThreadDto |
| | reply(id, body, opts?) | POST /v1/inbox/threads/{id}/reply | InboxEntryDto |
| | action(id, body, opts?) | POST /v1/inbox/threads/{id}/actions | — |
| | setStatus(id, body, opts?) | POST /v1/inbox/threads/{id}/status | — |
| | hide / unhide / like / unlike / deleteComment / privateReply | POST …/actions | — |
| inbox.accounts | list() | GET /v1/inbox/accounts | InboxAccountDto[] |
| | updateSettings(connectionId, settings, opts?) | PATCH /v1/inbox/accounts/{connectionId}/settings | InboxAccountDto |
| webhooks | register(url, events?, opts?) | POST /v1/webhooks | { id, url, events, enabled, secret } |
| | list() | GET /v1/webhooks | endpoints (no secret) |
| | delete(id, opts?) | DELETE /v1/webhooks/{id} | — |
| realtime | token() | POST /v1/realtime/tokens | { token, expiresIn, wsUrl } |
| admin | releaseNativeClaim(network, externalAccountId, opts?) | POST /v1/admin/native-claims/release | { released, previousOwnerTenantId? } |
| | webhookInbox.list(status?) | GET /v1/admin/webhook-inbox | inbound deliveries |
| | webhookInbox.requeue(id, opts?) | POST /v1/admin/webhook-inbox/{id}/requeue | — |
| — | ping() | GET /v1/ping | { ok: true } |
| — | whoami() | GET /v1/whoami | identity |
admin.*requires theadminscope on the app key.
Webhook event types
message.received · comment.received · thread.reply · post.status · account.updated (plus any future string types — handle the default case).
Headers the SDK sends / expects
| Header | Direction | Purpose |
| -------------------------------- | ---------------- | -------------------------------------------- |
| Authorization: Bearer <appKey> | request | Auth |
| x-axis-tenant | request | Tenant scope |
| Idempotency-Key | request (writes) | Safe retries / dedupe |
| Retry-After | response | Backoff hint on 429 |
| x-axis-signature | inbound webhook | sha256=<hmac> over {timestamp}.{rawBody} |
| x-axis-timestamp | inbound webhook | Signed timestamp |
| x-axis-event | inbound webhook | Event type |
| x-axis-delivery | inbound webhook | Unique delivery id |
Exports
import {
AxisSocial, // the client
AxisSocialError, // thrown on non-2xx
verifyWebhookSignature, // inbound-webhook verifier
} from '@axis-social/sdk';
import type {
AxisSocialOptions,
RequestOptions,
AxisErrorBody,
AxisErrorCode,
Scope,
QueryParams,
} from '@axis-social/sdk';All request/response DTOs (ConnectionDto, PostDto, InboxThreadDto, …) are re-exported from @axis-social/types.
