Skip to content

@ab-ecosystem/cpaas

1.1.0PublicMessaging & CRM

One CrmClient covers the whole gateway — the audience module (contacts, tags, custom fields, segments, imports and exports), the channels module (WhatsApp onboarding, templates, webhooks, billing and wallet), campaigns (bulk sends to a CSV of numbers, scheduling, per-recipient reporting) and data stores you own. The live chat inbox is a Socket.IO connection, shipped as the /realtime subpath.

$ npm install @ab-ecosystem/cpaas
View on npm

@ab-ecosystem/cpaas

The official TypeScript / JavaScript SDK for the AB Ecosystem CPaaS platform — WhatsApp Business API channels, CRM contacts, bulk campaigns, message templates and a realtime live-chat inbox, behind one typed client.

npm install @ab-ecosystem/cpaas

One CrmClient covers the whole gateway: the audience module (contacts, tags, custom fields, segments, imports and exports), the channels module (WhatsApp onboarding, templates, webhooks, billing and wallet), campaigns (bulk sends to a CSV of numbers, scheduling, per-recipient reporting), and data stores (a MongoDB you own, registered with the platform). The live chat inbox is a Socket.IO connection, shipped as the @ab-ecosystem/cpaas/realtime subpath.

Dependency-free, written in TypeScript, types included — no @types/ package to install.


Table of contents


Installation

npm install @ab-ecosystem/cpaas
# or
pnpm add @ab-ecosystem/cpaas
# or
yarn add @ab-ecosystem/cpaas

The live chat inbox needs one optional peer dependency. Skip it if you only use the REST client:

npm install socket.io-client

Requirements: Node.js 18 or newer (the SDK uses the global fetch), or any modern browser. The package ships CommonJS plus .d.ts declarations, so it imports cleanly from CommonJS, from ESM through Node's interop, and from every bundler.


Quick start

import { CrmClient } from '@ab-ecosystem/cpaas';

const crm = new CrmClient({
  gatewayUrl: 'https://api.example.com',
  clientId: 'tpc_xxxxxxxxxxxxxxxx',
  authToken: process.env.CRM_TOKEN!,
  origin: 'https://app.example.com', // required on Node, ignored in browsers
});

// Audience
const contacts = await crm.contacts.list({ search: 'acme', rows: 50 });
const tag = await crm.contactTags.create({ name: 'VIP', color: '#f59e0b' });
await crm.contacts.update(contacts.data[0].id, { tags: [tag.id] });

// Channels
const channels = await crm.channels.list({ type: 'whatsapp', active: true });
const templates = await crm.templates.list(channels.data[0].id, { status: 'approved' });

Construct the client once and share it. It holds no per-request state, and every resource runs over the same HTTP client, so the token resolver and the timeout apply everywhere.


Client options

new CrmClient(options: ClientOptions)
OptionTypeRequiredDefaultDescription
gatewayUrlstringyes—Base URL of the gateway, no trailing slash.
clientIdstringyes—Sent as the client-id header on every call.
authTokenstring | (() => string | Promise<string>)yes—Bearer token, or a resolver called per request.
originstringon Nodepage originSent as the origin header and matched against the token's referer claim. Browsers set this themselves and ignore any override; on Node the gateway refuses the call without it.
fetchtypeof fetchnoglobal fetchCustom fetch implementation.
timeoutMsnumberno30000Per-request timeout in milliseconds.

Every request carries authorization: Bearer <authToken>, client-id and origin.

Rotating tokens

Pass a function instead of a string and it is called before every request, so a short-lived token never needs the client rebuilt:

const crm = new CrmClient({
  gatewayUrl: 'https://api.example.com',
  clientId: 'tpc_xxxxxxxxxxxxxxxx',
  authToken: () => auth.getAccessToken(), // sync or async
});

Core concepts

Two pagination shapes

Most lists are offset paginated and answer Paginated<T>:

{ data: T[], pagination: { page: number, rows: number, total: number, pages: number } }

Send { page, rows, search }. page is 1-based and defaults to 1; rows defaults to 25 and caps at 100; search is trimmed and must be 1–120 characters. Which fields search matches is per resource and is stated on each reference page.

Two endpoints — contact notes and audience webhooks — are cursor paginated and answer Page<T>:

{ data: T[], nextCursor: string | null }

Send { limit, cursor }. limit defaults to 50 and caps at 100; pass the previous response's nextCursor to continue, and stop when it is null. These two page through rows that are appended constantly, where an offset would shift under a caller mid-walk.

Two async iterators walk the pages for you:

for await (const contact of crm.contacts.iterate({ tag: vipTagId })) { /* … */ }
for await (const row of crm.campaignMessages.iterate(campaignId)) { /* … */ }

Object-valued query parameters

filter, range, and the billing list's template / country / campaign arrays travel as JSON on the query string. Pass real values — the SDK serialises them:

await crm.billing.list('all', {
  range: { start: 1767225600, end: 1769817600 },
  country: ['IN', 'US'],
});

The contact filter DSL

One condition language is shared by five places: contacts.list({ filter }), a saved Segment, a CRM trigger's filters, an export's config.filter, and a scheduled message's conditions.

await crm.contacts.list({
  filter: {
    conditions: [
      { operator: null, field_id: 'city', field_type: 'text', condition: 'equal to', value: 'Pune' },
    ],
  },
});

Every field, condition and operator is in docs/contact-filter.md.

Scoping

Audience rows belong to an account, and a request carries two identities: the caller, and the account they act for. Reads match either; writes land on the caller's own. Audience webhooks are the exception — they are scoped by tenant.

A team member's visibility is their role's data_scope: own sees the contacts assigned to them as agent, all sees the account's. Reading a row outside that scope answers 404, never 403 — a 403 would confirm the row exists, which is what the scope is hiding.

A contact a team member creates is assigned to that member as its agent, whatever their data_scope.

Permission scopes

Every method documents the scope its route requires — contacts:read, channels:write and so on. They are listed for planning; the gateway does not enforce them yet. Per-key permissions (CRM.CONTACT.CREATE, …) are enforced, and are listed on each reference page.


Guides

Contacts and the audience

const contact = await crm.contacts.create({
  name: { first_name: 'Ada', last_name: 'Lovelace' },
  whatsapp: '+91 98765 43210',
  tags: [vipTagId],
  custom_field_hash: { city: 'Pune', lead_score: 80 },
});

contact.profile.whatsapp.identifier; // '919876543210'

A WhatsApp number is accepted in any shape and stored as digits only, because that is the form an inbound message carries. One number identifies one contact per account; a repeat is 409 conflict. A contact holds one identity per channel.

delete is two-stage and answers 200 both times: the first call trashes ({ status: 'trashed' }, recoverable with update(id, { status: 'live' })), the second deletes for good ({ status: 'deleted' }).

Reference: docs/audience/contacts.md.

Connect a WhatsApp channel

Channels are provisioned by onboarding, not created through the API. There is no channels.create. connect() runs the whole Meta Embedded Signup flow from a browser — it opens the dialog, waits for the return, and completes the channel:

const channel = await crm.channelOnboarding.connect({
  type: 'whatsapp',
  redirectUri: 'https://app.example.com/channels/connected',
});

A console that drives its own popup can call the steps directly — types(), url(), pending(), exchange(), complete() — and the popup drivers openSignup() and awaitSignupReturn() are exported for that.

Reference: docs/channels/onboarding.md.

WhatsApp message templates

await crm.templates.create(channelId, template);
const approved = await crm.templates.list(channelId, { status: 'approved' });
await crm.templates.sync(channelId); // pull Meta's current status

Reference: docs/channels/templates.md.

Send a WhatsApp campaign

A campaign is one message sent to many people. The audience is a single filters field with five modes, chosen by the shape of the array: [] for every contact, a list of filter conditions for a segment, [{ numbers, hash }] for a bulk list, [{ type: 'channel' | 'template', … }] for a group campaign split into buckets, or [{ type: 'status' | 'selection', … }] to re-target an earlier campaign.

createBulk is the one to reach for when you hold the recipients yourself. The list does not travel inside the campaign body — a million numbers is not a request body — so the helper parses the input, encrypts it under a per-file secret, uploads it through a presigned URL, and creates the campaign pointing at the file:

const { campaign, receivers, skipped, duplicates } = await crm.campaigns.createBulk({
  name: 'Diwali offer',
  audience: { csv: await file.text(), recipientColumn: 'phone' },
  messages: [{ channel: { id: channelId }, message: template }],
});

Every CSV column becomes a per-recipient template variable. Rows with no usable number, and repeated numbers, are dropped and counted — skipped and duplicates — rather than passed over silently.

createGroup is the bucketed form, splitting recipients across channels or templates and uploading one file per bucket:

await crm.campaigns.createGroup({
  name: 'Diwali offer',
  distributeBy: 'channel',
  buckets: [
    { channel: channelA, audience: { rows: north, recipientColumn: 'phone' } },
    { channel: channelB, audience: { rows: south, recipientColumn: 'phone' } },
  ],
  messages: [{ channel: { id: channelA }, message: template }],
});

Creating a campaign does not send it. Every create returns a draft — pending, with no scheduledAt:

await crm.campaigns.trigger(id);                  // send now; cancel works for ~1 minute
await crm.campaigns.trigger(id, { scheduledAt }); // send then

A send time is refused on create and update, because arming a campaign is also what records who authorised it.

campaign.receivers is 0 on a campaign you just created: the audience is resolved when the campaign sends, not when it is built. The receivers on the create result is the count written into the file, and it is the one to show a user.

The audience helpers are exported on their own, so a UI can show "12,480 recipients, 3 duplicates dropped" before anything is uploaded:

import { parseDelimited, resolveAudience } from '@ab-ecosystem/cpaas';

const { headers } = parseDelimited(text);
const { receivers, skipped, duplicates } = resolveAudience({ csv: text, recipientColumn });

Reference: docs/campaign/campaigns.md.

Campaign reporting

const stats = await crm.campaignMessages.stats(campaignId);
const replies = await crm.campaignMessages.replies(campaignId);

The funnel, the per-recipient rows behind it and the replies are all counted off the message rows when asked for — there is no roll-up to wait for and no export job to poll.

Reference: docs/campaign/campaign-messages.md.

Live chat inbox (realtime)

A separate transport, not the REST gateway: the inbox is a Socket.IO connection with its own origin. Install socket.io-client and import the /realtime subpath — nothing resolves it if you only use the REST client.

import { Inbox } from '@ab-ecosystem/cpaas/realtime';

const inbox = new Inbox({
  url: 'https://inbox.example.com',
  clientId,
  token: () => accessToken,
});
await inbox.ready();

const { contacts } = await inbox.chats.list({ filter: { assignment: 'me', unread: 'unread' } });
inbox.on('message.received', (message) => append(message));

Media uploads are the one part of the inbox that is HTTP, because an upload is a request with an answer. Upload through the REST client, then send the link:

const link = await crm.inboxMedia.upload(file, { kind: 'image' });
await inbox.messages.sendMedia({ channel, contact, kind: 'image', link, caption: 'Your order' });

Requests are promises; pushes are what other agents and contacts did. The same event name can be both — an archive you performed and one another agent performed arrive identically — so one handler per kind of change is enough.

Reference: docs/inbox/connection.md.

Scheduled messages

A send with a time, plus a condition checked just before it goes out — which is what lets a drip stop itself when a lead converts.

await crm.scheduledMessages.createSeries(input);
await crm.scheduledMessages.sendNow(id);

Reference: docs/audience/scheduled-messages.md.

Data stores

A MongoDB you own, registered with the platform. The platform holds the credential — encrypted — and the schemas you declare over it. It holds none of your data: every record call is a live round trip to your own cluster, and deleting a store forgets our side of it while leaving your database untouched.

const store = await crm.dataStores.create({ /* connection */ });
await crm.dataStoreCollections.create(store.id, { /* schema */ });
await crm.dataStoreRecords.insert(store.id, collectionId, { /* document */ });

Reference: docs/data-store.md.


API reference map

Every resource is a property on CrmClient. Each row names its reference page, which ships inside the package under docs/.

Audience — /v1/audience

PropertyMethodsReference
crm.contactslist get create update delete iteratedocs/audience/contacts.md
crm.contactTagslist get create update deletedocs/audience/contact-tags.md
crm.contactNoteslist get create update deletedocs/audience/contact-notes.md
crm.customFieldslist get create update deletedocs/audience/custom-fields.md
crm.cannedReplieslist get create update delete — addressed by id or namedocs/audience/canned-replies.md
crm.segmentslist get create update deletedocs/audience/segments.md
crm.crmTriggerslist get create update delete logsdocs/audience/crm-triggers.md
crm.importslist get create deletedocs/audience/imports.md
crm.exportslist get create deletedocs/audience/exports.md
crm.webhookslist get create update deletedocs/audience/webhooks.md
crm.scheduledMessageslist get create createSeries createRepeating reschedule cancel cancelSeries sendNowdocs/audience/scheduled-messages.md
crm.notificationssubscribe unsubscribe devicesdocs/audience/notifications.md
crm.inboxMediaupload createUploaddocs/inbox/media.md

Channels — /v1/channels

PropertyMethodsReference
crm.channelslist get updateDetails disable rotateToken getProp setPropdocs/channels/channels.md
—every prop that getProp / setProp acceptsdocs/channels/channel-props.md
crm.channelOnboardingtypes url pending exchange complete connectdocs/channels/onboarding.md
crm.templateslist get create update delete syncdocs/channels/templates.md
crm.channelWebhookslist get create update deletedocs/channels/webhooks.md
crm.billinglist statsdocs/channels/billing.md
crm.walletlistdocs/channels/wallet.md
crm.activitylistdocs/channels/activity.md

Campaigns — /v1/campaigns

PropertyMethodsReference
crm.campaignslist get create createBulk createGroup uploadReceivers update trigger schedule cancel deletedocs/campaign/campaigns.md
crm.campaignMessagesstats list iterate repliesdocs/campaign/campaign-messages.md

Data stores

PropertyMethodsReference
crm.dataStoresregions list get create update remove testdocs/data-store.md
crm.dataStoreCollectionslist get create update removedocs/data-store.md
crm.dataStoreRecordsinsert list search get update bulkUpdate remove bulkRemovedocs/data-store.md

Realtime — @ab-ecosystem/cpaas/realtime

MemberMethodsReference
new Inbox(options)ready on pingdocs/inbox/connection.md
inbox.chatslist get delete transfer bulkTransfer archive unarchive block markAsRead markAsUnread pauseAutomation resumeAutomation close done pauseTimer updateTags update assignBotdocs/inbox/chats.md
inbox.messageslist get send sendMedia delete retryDownloaddocs/inbox/messages.md
inbox.referrals(contactId)click-to-WhatsApp ad payloadsdocs/inbox/referrals.md
inbox.sessions(contactId)per-channel reply windowsdocs/inbox/sessions.md

Error handling

ClassWhen
CrmConfigErrorThrown client-side, before any request, when gatewayUrl, clientId or authToken is missing or empty.
CrmApiErrorThe gateway answered a non-2xx status. Carries status, code, message and details.
import { CrmApiError, CrmConfigError } from '@ab-ecosystem/cpaas';

try {
  await crm.contacts.get(id);
} catch (err) {
  if (err instanceof CrmConfigError) {
    console.error('bad SDK setup:', err.message);
  } else if (err instanceof CrmApiError) {
    console.error(err.status, err.code, err.message, err.details);
  }
}
StatusMeaning
402Subscription or quota — see below.
403The role does not hold the permission key (A403), or the plan does not cover the feature (E403).
404 not_foundNo such row for this caller — including a row hidden by data_scope.
409 conflictA unique value is taken: a contact's number, a name, a slug.
422Body or query failed validation; details lists the failing paths.

Plan limits and quotas

Every call needs an active subscription, and metered resources are capped at what the plan includes. Both refusals are 402, told apart by code:

CodeWhendetails
subscription_requiredThe account has no subscription, or it lapsed. Any call.—
not_includedThe plan includes none of that resource.{ resource }
quota_exceededThe plan's allowance is used up.{ resource, limit, used }
resourceCounted asInclusion
contactone contactcontact_18472 — 1 unit = 1000 contacts
canned_replyone replycanned_reply_14172
scheduled_seriesone series, whatever its length — a one-off schedule is freeseries_91922
data_storeone registered storedata_store_52360
campaignone campaigncampaign_73018
super_fieldone custom fieldsuper_field_29689
whatsapp_channelone live WhatsApp channelwhatsapp_channels_28335

Counts are of what exists now, so deleting a row gives the allowance back. limit and used come back on quota_exceeded, so a panel can say how far over the account is without a second call.

try {
  await crm.campaigns.create(input);
} catch (err) {
  if (err instanceof CrmApiError && err.status === 402) {
    // err.code: 'subscription_required' | 'not_included' | 'quota_exceeded'
    console.error(err.message, err.details);
  }
}

TypeScript

Declarations ship with the package — there is no @types/ to install. Every input and output is an exported type, so prefer importing one over hand-writing an object literal:

import type {
  Contact,
  CreateContactInput,
  ListContactsQuery,
  Campaign,
  Channel,
  Paginated,
  Page,
} from '@ab-ecosystem/cpaas';

const query: Partial<ListContactsQuery> = { search: 'acme', rows: 50 };
const page: Paginated<Contact> = await crm.contacts.list(query);

strict mode is supported and expected.


Offline documentation

The full reference ships inside the package, so it is readable without a network and greppable by an editor or an agent:

node_modules/@ab-ecosystem/cpaas/docs/
├── audience/     contacts, tags, notes, custom fields, canned replies,
│                 segments, triggers, imports, exports, webhooks,
│                 scheduled messages, notifications
├── campaign/     campaigns, campaign-messages
├── channels/     channels, channel-props, onboarding, templates,
│                 webhooks, billing, wallet, activity
├── inbox/        connection, chats, messages, media, referrals, sessions
├── contact-filter.md
└── data-store.md

Each page lists every action with its route, permission key, request body, query parameters and response shape.


For AI coding agents

Read this section before generating code against this SDK.

Entry points. There are exactly two:

import { CrmClient } from '@ab-ecosystem/cpaas';        // REST, everything
import { Inbox } from '@ab-ecosystem/cpaas/realtime';   // Socket.IO live chat

Ground truth. The reference pages under node_modules/@ab-ecosystem/cpaas/docs/ are authoritative and complete — read the page for the resource you are touching before writing the call. The map above says which file. The type declarations under dist/*.d.ts are the second source. Do not invent a method that appears in neither.

Invariants that are easy to get wrong:

  1. Creating a campaign does not send it. create / createBulk / createGroup return a draft. campaigns.trigger(id) is what sends.
  2. Recipients never go in the campaign body. Use createBulk or createGroup, which upload the list and point the campaign at the file.
  3. campaign.receivers is 0 on a fresh campaign. Show the receivers from the create result instead.
  4. There is no channels.create. A channel is provisioned through crm.channelOnboarding and retired with channels.disable — there is no delete either.
  5. channels.get never 404s. It answers { data }, and { data: {} } when nothing matched. Check with if (!('id' in data)).
  6. Two pagination shapes. Paginated<T> ({ data, pagination }) for most lists; Page<T> ({ data, nextCursor }) for contact notes and audience webhooks only.
  7. origin is required on Node and must match the token's referer claim. Omitting it is a rejected request, not a warning.
  8. Pass objects, not JSON strings, for filter, range and the billing array filters. The SDK serialises them.
  9. A hidden row is 404, not 403. Do not branch on 403 to detect "exists but forbidden".
  10. tags and custom_field_hash replace on update, they do not merge. Read the row first if you mean to add one tag to the existing set.
  11. contacts.delete is two-stage and returns 200 both times. Read result.status — 'trashed', then 'deleted'.
  12. The realtime subpath needs socket.io-client installed by the app. It is an optional peer dependency, and nothing resolves it otherwise.

Error handling to generate: catch CrmApiError and branch on err.status and err.code; catch CrmConfigError only around construction. Never parse err.message.

Do not hand-write request URLs or call fetch directly — every route is a method, and the client adds the three required headers.


FAQ

Does this SDK send WhatsApp messages? Yes — one-to-many through crm.campaigns, one-to-one through the realtime inbox (inbox.messages.send / sendMedia), and on a schedule through crm.scheduledMessages.

How do I connect a WhatsApp Business number? crm.channelOnboarding.connect({ type: 'whatsapp', redirectUri }) runs Meta's Embedded Signup flow end to end from the browser. See docs/channels/onboarding.md.

Does it work in the browser? Yes. Browsers set the origin header themselves, so that option is only needed on Node. Treat any token you ship to a browser as public.

Does it work with ESM, Next.js, Vite or a bundler? Yes. The package is CommonJS with type declarations, which Node's ESM interop and every major bundler consume without configuration.

Do I need socket.io-client? Only for the live chat inbox. It is an optional peer dependency.

Can I bring my own fetch, or set a timeout? Yes — fetch and timeoutMs on ClientOptions.

How do I handle an expiring token? Pass a function as authToken. It is called before every request, and on every socket connect for the Inbox.

What happens when a plan limit is hit? A 402 CrmApiError with code: 'quota_exceeded' and details: { resource, limit, used }.


Compatibility

Node.js18 or newer
Browsersany with fetch
Module formatCommonJS + .d.ts — works from ESM and all bundlers
Runtime dependenciesnone
Optional peersocket.io-client ^4.7.0, for /realtime only

License

MIT

What you can build with cpaas

  • Build your own WhatsApp CRM dashboard on the audience and channels modules
  • Drive bulk WhatsApp campaigns from a CSV, with every column a per-recipient template variable
  • Embed a live-chat agent console with assignment, transfer, tags and canned replies
  • Run drip sequences that stop themselves when a lead converts
  • Register your own MongoDB as a data store and keep every record on your cluster
  • Preview "12,480 recipients, 3 duplicates dropped" before anything is uploaded

Other packages in @ab-ecosystem