Skip to main content

ab_ecosystem_cpaas 1.0.0 copy "ab_ecosystem_cpaas: ^1.0.0" to clipboard
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 #

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 received carrying the real one — correlate on the queue id you got back, not on the final messageId.
  • chatActions carries 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 CrmApiError with 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:

  1. origin is required except on Flutter Web; omitting it throws CrmConfigError at construction.
  2. authToken is a function, not a string. ClientOptions.token() wraps a constant one.
  3. There is no channels.create. Channels arrive through a Meta-hosted dialog, via channelOnboarding, and you open the browser yourself.
  4. channels.get returns Channel? and never 404s — check for null.
  5. Two pagination shapes: contact notes, audience webhooks and campaigns are cursor (Page<T>); everything else is offset (Paginated<T>).
  6. contacts.delete is two-stage and returns 200 both times; read isDeleted.
  7. tags and customFieldHash replace, they do not merge.
  8. Nullable-and-optional fields take Patch — Patch.clear() sends null, omitting leaves alone.
  9. Pass objects, never pre-encoded JSON strings, for filter and range. The SDK encodes.
  10. Enum-likes are extension types over String; they never throw on an unknown value and compare with ==.
  11. Channel, Campaign and DataStoreRecord keep their raw payload — reach unmodelled fields with obj['key'].
  12. A hidden wallet is balance == null with isHidden == true, which is not the same as zero.
  13. Hidden rows are 404, not 403.
  14. Call crm.close() unless you supplied your own httpClient; inbox.dispose() for the socket.
  15. Creating a campaign does not send it — trigger() is the only call that arms it.
  16. campaign.receivers is 0 until the campaign is processed.
  17. The campaign audience mode is decided by the shape of filters, and modes cannot be mixed.
  18. Campaign report stages are cumulative; failed is never inside sent.
  19. A sent inbox message's messageId is a queue id until the received push replaces it.
  20. Realtime refusals arrive as CrmApiError, same as REST.
  21. dataStores.create rejects a cloud store with credentials, and a self store without them, before the request.
  22. Data store record keys are the slugged field names the server returned, not what you typed.
  23. bulkUpdate and bulkRemove refuse an empty filter locally and on the server.
  24. billing.list returns rows; billing.stats() returns aggregates. stat is not a field on the row query.
  25. Wallet amounts are ten-thousandths; ChannelWallet.balance is already divided down, WalletLog.amount is not.
  26. A webhook secret is returned once, on create.
  27. Only a pending scheduled message can be rescheduled.
  28. A canned reply row with no version is legacy: whatsappApi is null, read raw.
  29. setProp with raw bytes requires a contentType.

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

0
likes
160
points
420
downloads

Documentation

API reference

Publisher

verified publisherab-ecosystem.com

Weekly Downloads

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.

Homepage

Topics

#whatsapp #messaging #crm #chat #sdk

License

MIT (license)

Dependencies

http, meta, socket_io_client

More

Packages that depend on ab_ecosystem_cpaas