ab_ecosystem_cpaas 1.0.0
ab_ecosystem_cpaas: ^1.0.0 copied to clipboard
Dart and Flutter SDK for the AB Ecosystem CPaaS platform: WhatsApp Business API channels, CRM contacts, bulk campaigns, message templates and a realtime live chat inbox.
ab_ecosystem_cpaas #
The Dart and Flutter SDK for the AB Ecosystem CPaaS platform — send WhatsApp Business API messages, run bulk campaigns, manage message templates, build a realtime live chat inbox, and work with the CRM audience behind it all.
flutter pub add ab_ecosystem_cpaas # Flutter
dart pub add ab_ecosystem_cpaas # Dart
A pure Dart package: one import works in Flutter on Android, iOS, web, macOS, Windows and Linux, and in Dart on the server. No platform channels, no code generation, no build step.
This release mirrors @ab-ecosystem/cpaas 1.1.0 on npm — the same resources, the same method names, the same errors. The two packages version independently; see Compatibility and versioning.
What you can build with it #
- WhatsApp Business API messaging — send session messages and approved templates over the WhatsApp Cloud API, through your own connected numbers.
- Bulk WhatsApp campaigns — build an audience, schedule a send, and read a delivery funnel down to the individual recipient.
- A realtime live chat inbox — Socket.IO powered, with chats, threads, delivery receipts and agent assignment, exposed as Dart streams.
- A WhatsApp CRM — contacts, tags, custom fields, saved segments, notes, and one filter language shared across every surface.
- Message template management — create, submit for Meta approval, and sync approval status back.
- Drip sequences and scheduled messages — with a condition re-checked at send time, so a sequence stops itself when a lead converts.
- Your own MongoDB as a data store — register it, declare schemas over it, and query it with the full Mongo language.
Table of contents #
- Getting started
- Client options
- Core concepts
- Guides
- Manage contacts
- Filter and segment an audience
- Connect a WhatsApp channel
- Create and submit message templates
- Send a bulk WhatsApp campaign
- Read a campaign report
- Build a live chat inbox
- Schedule messages and drip sequences
- Upload and send media
- Query your own database
- Receive webhooks and push notifications
- Error handling
- Flutter notes
- API reference
- For AI coding agents
- FAQ
- Compatibility and versioning
Getting started #
import 'package:ab_ecosystem_cpaas/ab_ecosystem_cpaas.dart';
final crm = CrmClient(ClientOptions(
gatewayUrl: 'https://api.example.com',
clientId: myClientId,
origin: 'https://console.example.com',
authToken: () async => tokenStore.fresh(),
));
final page = await crm.contacts.list(
const ListContactsQuery(search: 'acme', rows: 50),
);
for (final contact in page.data) {
print('${contact.id} ${contact.name.firstName}');
}
crm.close();
Build the client once and share it. It holds no per-request state, so the token resolver and the timeout apply to every resource at once. Call close() when you are finished — unless you supplied your own httpClient, which stays yours to close.
Client options #
| Option | Type | Required | Notes |
|---|---|---|---|
gatewayUrl |
String |
yes | Base URL of the gateway. A trailing slash is stripped. |
clientId |
String |
yes | Sent as the client-id header. Public — an identifier, not a secret. |
authToken |
TokenResolver |
yes | FutureOr<String> Function(), resolved on every request. |
origin |
String? |
off the web | The gateway rejects a call without it, and only a browser sets it for you. |
httpClient |
http.Client? |
no | Inject your own for retries, logging or a proxy. |
timeout |
Duration |
no | Per request. Defaults to 30 seconds. |
ClientOptions.token('…') is shorthand for a token that does not change over the client's life — convenient for a server script. On a device, prefer the resolver so a token can expire without the app restarting.
Why origin is required #
On the web the browser sets origin itself and ignores any override, so leave it unset there. Everywhere else — Flutter on a phone or desktop, Dart on a server — there is no browser, and the gateway refuses a call that does not carry it. The SDK throws CrmConfigError at construction rather than letting the first request fail somewhere less obvious.
Core concepts #
Two pagination shapes #
Most lists are offset paginated and return Paginated<T>:
final page = await crm.contacts.list(const ListContactsQuery(page: 2, rows: 50));
page.data; // List<Contact>
page.pagination.total; // rows matching the filter, across all pages
page.pagination.hasMore; // whether another page follows
Contact notes, audience webhooks and campaigns are cursor paginated and return Page<T>, because rows are appended and an offset would shift under you mid-walk:
var page = await crm.contactNotes.list(contactId);
while (page.hasMore) {
page = await crm.contactNotes.list(contactId, PageQuery(cursor: page.nextCursor));
}
iterate() instead of paging by hand #
Every list has a streaming twin that manages the page or cursor for you, and is lazy — break out of the loop and the requests stop:
await for (final contact in crm.contacts.iterate(const ListContactsQuery(rows: 100))) {
print(contact.id);
}
Omitted, set, and cleared #
JSON distinguishes "field absent" from "field set to null", and the API reads them differently. Dart has only the one null, so the few fields that are both optional and nullable take a Patch:
UpdateContactInput(agent: const Patch('65f1…')); // assign
UpdateContactInput(agent: const Patch.clear()); // unassign
UpdateContactInput(status: ContactStatus.live); // agent untouched
Replace, not merge #
tags and customFieldHash replace their stored value. Read the contact first and send the whole set, or you will drop what you left out.
Unknown values never throw #
Every enum-like type is an extension type over String, not a Dart enum. A value the server adds after your app shipped passes through instead of crashing a build that is still on someone's phone two years later:
if (contact.status == ContactStatus.live) { }
contact.status.value; // the raw string, whatever it is
The same applies to whole objects: Channel, Campaign and DataStoreRecord keep their full payload, so an unmodelled field is still reachable as channel['some_new_flag'].
Guides #
Manage contacts #
final contact = await crm.contacts.create(const CreateContactInput(
whatsapp: '+91 98765 43210',
name: ContactName(firstName: 'Asha', lastName: 'Rao'),
));
whatsapp accepts any shape and is stored as digits only, which is the form an inbound message carries. It is create-only — a channel identity lives in its own collection and a contact patch cannot express a change to it. A number already on the account is a 409: nothing is created, and the existing contact is left alone.
Deleting is two-stage, and answers 200 both times — the body says which stage ran:
final first = await crm.contacts.delete(id); // isDeleted == false (trashed)
final second = await crm.contacts.delete(id); // isDeleted == true (destroyed)
Filter and segment an audience #
One filter language drives the contact list, saved segments, CRM triggers, exports, campaign audiences and scheduled-message conditions. Conditions are a flat list: and binds tighter than or, so the list splits on every or, each run is anded, and the runs are ored.
final filter = ContactFilter(conditions: [
ContactFilterEntry(
fieldId: 'city',
fieldType: ContactFilterFieldType.text,
condition: ContactFilterCondition.equalTo,
value: 'Pune',
),
ContactFilterEntry(
operator: ContactFilterOperator.and,
fieldId: 'SYSTEM_CREATED_AT',
fieldType: ContactFilterFieldType.date,
condition: ContactFilterCondition.within,
direction: ContactFilterDirection.last,
timeUnit: ContactFilterTimeUnit.days,
value: 30,
),
]);
final page = await crm.contacts.list(ListContactsQuery(filter: filter));
Pass the object — the SDK serialises it to JSON on the query string. Never encode it yourself.
Save it and replay it by name:
await crm.segments.create(CreateSegmentInput(name: 'Pune, new', filter: filter));
await crm.contacts.list(const ListContactsQuery(segment: 'Pune, new'));
Sending both segment and filter narrows the segment rather than replacing it.
Connect a WhatsApp channel #
There is no channels.create. A channel arrives through an embedded signup dialog on Meta's own site, and the SDK covers every step except opening the browser:
final ticket = await crm.channelOnboarding.url(const OnboardingUrlInput(
redirectUri: 'https://console.example.com/channels/connected',
));
// Open ticket.url yourself — url_launcher, flutter_web_auth_2, or a browser
// view. Meta refuses to be framed, so a plain WebView widget is not enough.
// ticket.url is a launcher on your own domain that forwards to Meta's dialog.
// ...the browser returns to your redirectUri...
final outcome = OnboardingReturn.fromUri(returnedUri);
if (outcome.status != OnboardingReturnStatus.ready) return;
final record = await crm.channelOnboarding.pending(outcome.token!);
final channel = await crm.channelOnboarding.complete(CompleteOnboardingInput(
token: record.id,
name: 'Support',
phoneNumberId: record.needsSelection ? record.numbers.first.id : null,
));
redirectUri is required: the callback has no session, so nothing else says where to hand the browser back. Meta never sees it.
Once connected:
final channels = await crm.channels.list(
const ListChannelsQuery(type: ChannelType.whatsapp, active: true),
);
channels.get returns Channel? — the endpoint answers an empty envelope rather than a 404, so this returns null instead of throwing. active is the owner's on/off switch, not the provider's connection health. disable() is permanent.
A wallet the agency has hidden reads as hidden, never as zero:
channel.wallet.isHidden ? 'hidden' : '${channel.wallet.balance}';
Anything that lives with the provider rather than on the channel document moves through two generic endpoints:
final profile = await crm.channels.getProp(id, ChannelGetProp.businessProfile);
await crm.channels.setProp(id, ChannelSetProp.businessProfile,
body: {'about': 'We reply in minutes'});
await crm.channels.setProp(id, ChannelSetProp.profilePic,
raw: bytes, contentType: 'image/jpeg');
The three properties in kBinaryChannelProps take file bytes and require a contentType; every other one takes JSON.
Create and submit message templates #
A template is local until it is submitted to Meta:
await crm.templates.create(channelId, CreateTemplateInput(
name: 'order_shipped',
category: TemplateCategory.utility,
language: 'en_US',
components: [
{'type': 'BODY', 'text': 'Hi {{1}}, your order is on its way.'},
],
applyForApproval: true, // omit to keep a local draft
));
sync() pulls Meta's current view back over ours, which is how approvals and rejections arrive without waiting on a webhook. Note the path: sync is a sibling of :id, not a child of it.
Send a bulk WhatsApp campaign #
Creating does not send. A campaign comes back pending with no scheduledAt — a draft you can keep editing, invisible to the sender until you arm it:
final campaign = await crm.campaigns.create(CreateCampaignInput(
name: 'Diwali offer',
filters: const [], // empty = every contact
messages: [
CampaignMessage(
channelId: channelId,
message: {'type': 'template', 'template': {'name': 'diwali_offer'}},
),
],
));
await crm.campaigns.trigger(campaign.id); // send now
await crm.campaigns.trigger(campaign.id,
const TriggerCampaignInput(scheduledAt: '2026-11-01T04:30:00.000Z'));
"Now" means about a minute out, which is the window cancel() works in. The server stamps triggerInfo — the caller, their address, the moment — as part of arming, so every send has one authorising act behind it.
campaign.receivers is 0 until the campaign is processed. The audience is a stored query, so a scheduled campaign's final count can differ from what was shown when it was built.
The four audience modes
The audience is one field whose mode is decided by the shape of the list, not a selector. Modes cannot be mixed, and the three non-segment modes take exactly one entry:
const <CampaignFilter>[] // every contact
[CampaignSegmentFilter(condition), ...] // contact conditions
[CampaignNumbersFilter(numbers: url, hash: key)] // an uploaded list
[CampaignRetargetSelectionFilter(recordIds)] // re-send to these
[CampaignRetargetStatusFilter(cid: cid, cmid: 0, status: 'delivered')]
CampaignNumbersFilter.hash is named for history — it is a key, not a digest. Anyone holding it and the URL can read the recipient list, so treat it as a credential.
Read a campaign report #
Every stage is cumulative, so read is a subset of delivered. failed sits off the funnel and is never inside sent.
final stats = await crm.campaignMessages.stats(campaign.id);
stats.total; // every attempted send, failures included
stats.deliveredUnread; // delivered - read
Drill into either number with the matching list. exact: true returns only rows sitting at that stage:
final failures = await crm.campaignMessages.list(campaign.id,
const ListCampaignMessagesQuery(status: CampaignMessageStatus.failed, rows: 100));
final unread = await crm.campaignMessages.list(campaign.id,
const ListCampaignMessagesQuery(
status: CampaignMessageStatus.delivered, exact: true));
replyType and repliedWithinHours are refused with 422 validation_failed unless the status is replied, rather than quietly matching nothing.
A CampaignMessageRecord.id is the handle a selection re-target takes — which is how "re-send to everyone who did not read it" is built.
Build a live chat inbox #
The realtime inbox is a separate library, so an app that never opens a socket does not reach for it:
import 'package:ab_ecosystem_cpaas/cpaas_realtime.dart';
final inbox = Inbox(InboxClientOptions(
url: 'https://inbox.example.com', // the socket's own origin
clientId: myClientId,
token: () async => tokenStore.fresh(),
));
await inbox.ready();
The socket URL is not the REST gateway's — the gateway proxies HTTP only and forwards no upgrade. Credentials travel in the handshake auth, not a header, because a browser cannot set headers on a WebSocket upgrade. The token resolver runs on every connect, so a reconnect after an expiry picks up a fresh token.
Requests are futures; pushes are broadcast streams:
inbox.received.listen(draw); // Stream<Message>
inbox.messageStatus.listen(tick); // Stream<MessageStatusUpdate>
inbox.chatActions.listen(refreshRow); // Stream<ChatActionResult>
final chats = await inbox.chats.list(const ListChatsPayload(
filter: ChatListFilter(assignment: 'none', unread: 'unread'),
));
await inbox.messages.send(
channel: channelId,
contactId: chat.id,
identifier: chat.profile['whatsapp']!.identifier,
content: MessageContent.text('On its way.'),
);
await inbox.chats.markAsRead(chat.id);
inbox.dispose();
Three things worth knowing:
- A sent message comes back with a queue id, not a provider id. The stored row arrives moments later on
receivedcarrying the real one — correlate on the queue id you got back, not on the finalmessageId. chatActionscarries other agents' actions too. The server broadcasts the same shape it answers with, so one handler per kind of change is enough.- A refusal is a
CrmApiErrorwith the same status and code the REST call would have thrown. One error path covers both transports.
ChatList.filter is the filter after permission narrowing: a member without the unassigned permission who asks for all gets me back, so the UI can show what it actually got.
Before a free-form reply, check the 24-hour window:
final sessions = await inbox.sessions(chat.id, channel: 'whatsapp');
final open = sessions.any((s) => s.isOpen); // outside it, send a template
Schedule messages and drip sequences #
A send with a time, and a condition checked just before it goes out — which is what lets a drip stop itself when the lead converts, instead of finishing the sequence at someone who already bought.
await crm.scheduledMessages.createSeries(CreateScheduledSeriesInput(
channel: channelId,
contactId: contact.id,
timezone: 'Asia/Kolkata',
startsAt: '2026-11-01T09:00:00+05:30',
conditions: notYetConverted,
onConditionFail: OnConditionFail.cancelSeries,
steps: [
ScheduledMessageStep(
after: const ScheduleOffset(value: 0, unit: ScheduleUnit.days),
content: MessageContent.text('Thanks for the enquiry.'),
),
ScheduledMessageStep(
after: const ScheduleOffset(value: 3, unit: ScheduleUnit.days),
content: MessageContent.text('Still interested?'),
),
],
));
Each step's offset is measured from the series start, not from the previous step, so editing one delay does not shift the ones behind it. cancelSeries drops the contact out for good; skip lets a later occurrence pick them back up.
Only a pending occurrence can be rescheduled — check isReschedulable, or take the 409.
Upload and send media #
Uploads do not pass through the API. The SDK asks for a presigned ticket, PUTs the bytes straight to storage, and hands back a permanent link:
final link = await crm.inboxMedia.upload(
bytes,
kind: MediaKind.document,
filename: 'invoice.pdf',
contentType: 'application/pdf',
);
await inbox.messages.sendMedia(
channel: channelId,
contactId: contactId,
identifier: identifier,
kind: MediaKind.document,
link: link,
filename: 'invoice.pdf',
);
| Kind | Limit | Accepts |
|---|---|---|
image |
5 MB | jpeg, png, webp |
video |
16 MB | mp4, 3gpp, quicktime |
audio / voice |
16 MB | aac, mp4, mpeg, amr, ogg |
sticker |
100 KB | webp |
document |
100 MB | anything a browser will not execute |
Size and content type are signed into the ticket, so the bytes have to match what you declared — a 403 from storage is almost always that mismatch. The returned link does not expire, so it still renders in the thread months later.
Query your own database #
A data store is 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.
final store = await crm.dataStores.create(const CreateDataStoreInput(
title: 'Orders',
credentials: DataStoreUriCredentials('mongodb+srv://user:pass@host/orders'),
));
await crm.dataStoreCollections.create(store.id,
const CreateDataStoreCollectionInput(
title: 'Orders',
fields: [
DataStoreField(name: 'order_no', type: DataStoreFieldType.string),
DataStoreField(name: 'total', type: DataStoreFieldType.number),
],
));
final matches = await crm.dataStoreRecords.search(store.id, 'orders', {
'total': {r'$gte': 5000},
});
Field names come back slugged — First Name is stored as first_name — and the slug is the document key. Use the names the response gives you.
The full Mongo query language works, minus $where, $function and $accumulator, which would run JavaScript on your cluster. Operand values are coerced against the declared field type before the query is sent.
bulkUpdate and bulkRemove refuse an empty filter, here and on the server: it matches the whole collection, and a one-character slip that rewrites or deletes every record is not something to make convenient.
Receive webhooks and push notifications #
Subscribe to contact events:
final hook = await crm.webhooks.create(const CreateWebhookInput(
url: 'https://example.com/hooks/contacts',
events: [WebhookEventType.contactCreated, WebhookEventType.contactUpdated],
));
hook.secret; // returned ONCE, on create — store it now
Channel events (message delivery, template approvals, number changes) are a separate resource, crm.channelWebhooks, scoped to one channel.
WebhookEnvelope.id is stable across retries of the same event, which is what makes a receiver idempotent.
For push, get a Firebase registration token yourself and hand it over:
await crm.notifications.subscribe(SubscribeNotificationsInput(
token: fcmToken,
platform: DevicePlatform.android,
deviceLabel: 'Pixel 8',
));
Call it on every app load, not once: FCM does not revoke a token when an app is uninstalled, it simply stops delivering, so a device unseen for 60 days is pruned.
Error handling #
Two exception types, and neither is thrown for a request that never left:
try {
await crm.contacts.get(id);
} on CrmApiError catch (error) {
if (error.status == 404) return null;
print('${error.code}: ${error.message}');
} on CrmConfigError catch (error) {
print(error.message); // thrown before any request is sent
}
Branch on error.code, not error.message — the message is written for a person and may change. error.details carries the offending fields on a validation_failed.
| Situation | What you get |
|---|---|
| Non-2xx response | CrmApiError with the server's status, code, details |
| 2xx with no body where one was expected | CrmApiError, code empty_response |
| 2xx with a body that is not JSON, or the wrong shape | CrmApiError, code invalid_response |
| Invalid configuration, caught locally | CrmConfigError, before any request |
| Dropped connection | ClientException / SocketException from package:http |
Expired timeout |
TimeoutException |
A row hidden by permissions answers 404, not 403 — the API does not confirm the existence of something it will not show.
Flutter notes #
Platforms. Android, iOS, web, macOS, Windows, Linux. Nothing platform-specific is compiled in.
On Flutter Web, leave origin unset — the browser sets it and ignores any override.
Security. This package runs wherever you put it, including inside an app you ship to users. Anything compiled into an app binary is extractable with strings on the APK or IPA. Mint short-lived, narrowly scoped tokens from your own backend and hand them to authToken; never embed a long-lived key. clientId is a public identifier and is fine to ship.
Large pages. Decoding a hundred-row page happens on the calling isolate. If you page aggressively in a UI, do the work in compute().
Opening a browser. Channel onboarding needs a real browser view — url_launcher or flutter_web_auth_2. The page cannot be framed, so a plain WebView widget will not do.
API reference #
The full generated reference — every class, method and field — is hosted at pub.dev/documentation/ab_ecosystem_cpaas/latest.
Audience #
| Resource | Methods |
|---|---|
crm.contacts |
list get create update delete iterate |
crm.contactTags |
list get create update delete |
crm.contactNotes |
list get create update delete iterate |
crm.customFields |
list get create update delete |
crm.segments |
list get create update delete |
crm.cannedReplies |
list get create update delete |
crm.crmTriggers |
list get create update delete logs |
crm.scheduledMessages |
list get create createSeries createRepeating reschedule cancel cancelSeries sendNow iterate |
crm.imports |
list get create delete iterate |
crm.exports |
list get create delete iterate |
crm.webhooks |
list get create update delete iterate |
crm.notifications |
subscribe unsubscribe devices |
crm.inboxMedia |
createUpload upload |
Channels #
| Resource | Methods |
|---|---|
crm.channels |
list get updateDetails disable rotateToken getProp setProp iterate |
crm.templates |
list get create update delete sync iterate |
crm.channelOnboarding |
types url pending exchange complete |
crm.channelWebhooks |
list get create update delete |
crm.billing |
list stats iterate |
crm.wallet |
list iterate |
crm.activity |
list iterate |
Campaigns #
| Resource | Methods |
|---|---|
crm.campaigns |
list get create update trigger schedule cancel delete iterate |
crm.campaignMessages |
stats list replies iterate |
Data stores #
| Resource | Methods |
|---|---|
crm.dataStores |
regions list get create update remove test |
crm.dataStoreCollections |
list get create update remove |
crm.dataStoreRecords |
insert insertMany list search get update bulkUpdate remove bulkRemove iterate |
Realtime (cpaas_realtime.dart) #
| Member | Methods |
|---|---|
inbox |
ready connect disconnect dispose ping referrals sessions |
inbox.chats |
list get update done assignBot act delete transfer archive unarchive block markAsRead markAsUnread pauseAutomation resumeAutomation close pauseTimer updateTags bulkTransfer |
inbox.messages |
list get send sendMedia delete retryDownload |
| streams | received messageStatus chatActions chatDone messageDeleted downloadRetried contactReceived errors |
For AI coding agents #
Entry points: CrmClient for everything HTTP, from package:ab_ecosystem_cpaas/ab_ecosystem_cpaas.dart; Inbox for realtime, from package:ab_ecosystem_cpaas/cpaas_realtime.dart.
Invariants worth knowing before writing code against this package:
originis required except on Flutter Web; omitting it throwsCrmConfigErrorat construction.authTokenis a function, not a string.ClientOptions.token()wraps a constant one.- There is no
channels.create. Channels arrive through a Meta-hosted dialog, viachannelOnboarding, and you open the browser yourself. channels.getreturnsChannel?and never 404s — check for null.- Two pagination shapes: contact notes, audience webhooks and campaigns are cursor (
Page<T>); everything else is offset (Paginated<T>). contacts.deleteis two-stage and returns 200 both times; readisDeleted.tagsandcustomFieldHashreplace, they do not merge.- Nullable-and-optional fields take
Patch—Patch.clear()sends null, omitting leaves alone. - Pass objects, never pre-encoded JSON strings, for
filterandrange. The SDK encodes. - Enum-likes are extension types over
String; they never throw on an unknown value and compare with==. Channel,CampaignandDataStoreRecordkeep their raw payload — reach unmodelled fields withobj['key'].- A hidden wallet is
balance == nullwithisHidden == true, which is not the same as zero. - Hidden rows are 404, not 403.
- Call
crm.close()unless you supplied your ownhttpClient;inbox.dispose()for the socket. - Creating a campaign does not send it —
trigger()is the only call that arms it. campaign.receiversis 0 until the campaign is processed.- The campaign audience mode is decided by the shape of
filters, and modes cannot be mixed. - Campaign report stages are cumulative;
failedis never insidesent. - A sent inbox message's
messageIdis a queue id until thereceivedpush replaces it. - Realtime refusals arrive as
CrmApiError, same as REST. dataStores.createrejects a cloud store with credentials, and a self store without them, before the request.- Data store record keys are the slugged field names the server returned, not what you typed.
bulkUpdateandbulkRemoverefuse an empty filter locally and on the server.billing.listreturns rows;billing.stats()returns aggregates.statis not a field on the row query.- Wallet amounts are ten-thousandths;
ChannelWallet.balanceis already divided down,WalletLog.amountis not. - A webhook
secretis returned once, on create. - Only a
pendingscheduled message can be rescheduled. - A canned reply row with no
versionis legacy:whatsappApiis null, readraw. setPropwith raw bytes requires acontentType.
FAQ #
Is this a Flutter plugin? No. It is a pure Dart package with no platform channels, so it works in Flutter on every target and in Dart on the server from the same source.
Can I use it in a Flutter app I ship to users? Yes, with the care any client-side SDK needs: mint short-lived, narrowly scoped tokens from your own backend. Never compile a long-lived key into an app binary.
Does it work on Flutter Web?
Yes. Leave origin unset there — the browser sets it.
How do I send a WhatsApp message to one person?
Through the realtime inbox: inbox.messages.send(...). Campaigns are for bulk sends.
How do I send a WhatsApp template message?
Create and approve it with crm.templates, then reference it in a campaign message or in MessageContent.
Why do I get a 404 when the row clearly exists?
Permission-hidden rows answer 404 rather than 403, on purpose. Check the token's scopes and the contact's privacyStatus.
Can I plug in Dio, retries, or a logging interceptor?
Pass any http.Client as httpClient. The SDK will not close one you supplied.
Why extension types instead of enums? So a value the server adds later does not crash an app already on someone's phone.
Does it support RCS, SMS or Instagram? The channel types exist in the API and the SDK carries them. Feature coverage per channel follows the platform; WhatsApp is the most complete.
Compatibility and versioning #
- Dart SDK
^3.5.0 - Flutter Android, iOS, web, macOS, Windows, Linux
- Runtime dependencies
package:http,package:socket_io_client
All 25 resources of the JavaScript SDK are covered, plus the realtime inbox.
Two campaign helpers are not: createBulk() and createGroup(), which encrypt and upload a recipient file on your behalf. The endpoint they call is reachable — build the pointer yourself with CampaignNumbersFilter — but the client-side encryption is not ported yet. Adding them is additive, so it will arrive in a minor release.
Versioning #
Semantic versioning. A patch release fixes behaviour behind an unchanged API, a minor release adds to it, and only a major release can break your build. Pin with ^1.0.0 and you will pick up both of the first two automatically:
dependencies:
ab_ecosystem_cpaas: ^1.0.0
The version number is this package's own — it does not track the npm package. A JavaScript-only fix does not produce a Dart release. Which npm version a given release mirrors is stated in the changelog entry for it.
License #
MIT