JavaScript and TypeScript SDK
Official client for the DialerBee public API, version 1 (https://app.dialerbee.com/api/v1).
Typed models, RFC 9457 errors with the request id, safe retries, an idempotency helper,
cursor auto-pagination, streamed recordings and webhook signature verification.
Status: pre-release. This package is not yet published to npm. It is generated from the API
contract version named in contract-lock.json (1.0.0-rc6); until version 1.0.0 is final, shapes
may still change. Answers may gain fields within v1 (ignore fields you do not know); requests
with a field the API does not know are refused.
Server-side only. An API key is a secret. The client refuses to start in a browser.
Requires Node.js 18.17 or later (uses the built-in fetch and node:crypto). No runtime dependencies.
Quick start
import { DialerBee } from 'dialerbee';
const db = new DialerBee({ apiKey: process.env.DIALERBEE_API_KEY });
const me = await db.me.get(); // works with any active key; shows your scopes
console.log(me.client.name, me.scopes);
const result = await db.leads.import(
{ leads: [{ phone: '+12125550101', external_id: 'CRM-1', full_name: 'Ann Lee' }] },
{ campaign_id: 'cmp_0f8fad5b-d9cb-469f-a165-70867728950e' },
);
for (const row of result.data) console.log(row.index, row.status, row.reason);
If apiKey is left out, the client reads DIALERBEE_API_KEY.
Calling convention
Every operation of the contract is a method named after its route id:
leads.import, campaigns.pause, webhooks.rotateSecret, webhookEvents.replay, sms.deliveryReports ...
(the full map is exported as RESOURCE_METHODS, operationId -> resource.method).
method(...pathIds, body?, query?, options?)
- path ids come first, in path order (
calls.get(callId)); a malformed id, or an id of the wrong type, is refused locally (InvalidArgumentError) and nothing is sent; bodyonly when the operation takes one (wire shape, snake_case, exactly the contract's fields);queryonly when the operation has query parameters; unknown ones are refused locally, as the API does;options:idempotencyKey,correlationId,ifMatch,range,timeoutMs,maxRetries,signal. A header option the operation does not take is refused, never silently dropped.
Each method returns the data. <method>WithResponse(...) returns
{ data, status, requestId, etag, idempotencyKey, idempotencyReplayed, headers }.
db.request(operationId, { path, query, body }, options) is the low-level call behind all of them.
Resources: me, leads, campaigns, calls, webhooks, webhookEvents, dnc, callbacks,
appointments, sms, whatsapp, consents, reports.
Idempotency
Every POST and DELETE that changes data needs an Idempotency-Key. When you do not pass
idempotencyKey, the SDK generates a UUID, reuses it on every retry, and returns it
(idempotencyKey on the response and on every error). Pass your own key when a logical operation
may be retried across process restarts. PATCH accepts a key; without one a PATCH is never retried.
const key = newIdempotencyKey(); // store it with your job
await db.campaigns.pause(campaignId, { idempotencyKey: key });
Same key, same request: the stored answer (idempotencyReplayed: true). Same key, different
request: IdempotencyConflictError.
Retries
Automatic, bounded and only when safe:
- on 429, 502, 503, 504, on 409
conflict(the first request with the key is still running), and on network errors or timeouts; - only for GET, for a read-only POST (
dnc.check), or for a request that carries anIdempotency-Key(the same key is reused); - never on
outcome_unknown,idempotency_conflict,daily_cap_reached,replay_limit_reached,tier_not_enabled; - at most 3 attempts in total;
Retry-Afteris honoured (a value above 60 s is returned to you instead of waited on); otherwise exponential backoff from 0.5 s with full jitter, capped at 8 s.
dnc.check is a POST that only reads: it changes nothing, so it is retried like a GET, without an
Idempotency-Key. Any other POST, PATCH or DELETE sent without a key is never retried.
maxRetries: 0 (client or call option) turns retries off.
Errors
Branch on status and code, never on message or detail. Every API error carries requestId
(quote it to support), type, title, detail, errors (field problems of a 400/422),
retryAfter, operationId, routeId and the idempotencyKey that was sent.
| Class | When |
|---|---|
AuthenticationError |
401 invalid_api_key (missing, unknown, expired or revoked key: one answer for all) |
PermissionDeniedError |
403 (scope missing, Tier-2 switch off, IP not allowed, access disabled) |
NotFoundError |
404 (includes another account's objects) |
ConflictError |
409 state conflicts |
IdempotencyConflictError |
409 idempotency_conflict |
OutcomeUnknownError |
409 outcome_unknown: see below |
PreconditionFailedError |
412 / 428 (If-Match) |
ValidationError |
400, 413, 415, 422 |
RateLimitError |
429 |
ServerError |
5xx |
ApiError |
any other API answer (base class of all the above) |
ConnectionError, TimeoutError |
no HTTP answer |
InvalidArgumentError |
refused locally; nothing was sent |
OperationBlockedError |
the operation is not available yet for any account; nothing was sent (today: whatsapp.send) |
outcome_unknown: DialerBee could not confirm whether the write happened. Do not repeat the
operation with a new key. Check the resource, or contact support with the requestId; the same key
keeps answering this until DialerBee has reconciled it.
try {
await db.leads.import(body, { campaign_id }, { idempotencyKey: key });
} catch (e) {
if (e instanceof OutcomeUnknownError) { /* keep `key`; check later; never re-send with a new key */ }
else if (e instanceof ApiError) console.error(e.status, e.code, e.requestId);
else throw e;
}
Lists
const page = await db.leads.list({ campaign_id, limit: 100 }); // one page: { data, page }
for await (const lead of db.leads.iterate({ campaign_id })) { // every item, page after page
console.log(lead.id, lead.status);
}
iterate follows page.next_cursor with the same filters and stops when has_more is false.
Cursors are opaque and bound to your key, client and filters.
Updating with If-Match
const r = await db.leads.getWithResponse(leadId);
await db.leads.update(leadId, { email: '[email protected]' }, { ifMatch: r.etag ?? undefined });
Recordings
const rec = await db.calls.recording(callId); // streamed, never a storage URL
await pipeline(Readable.fromWeb(rec.body), createWriteStream('call.mp3'));
const part = await db.calls.recording(callId, { range: 'bytes=0-65535' }); // 206, part.contentRange
Call summaries
Every call of your account answers a summary with a status (needs the summaries:read scope):
const s = await db.calls.summary(callId);
if (s.status === 'ready') console.log(s.summary, s.next_action, s.language);
else if (s.status === 'not_generated') console.log('no summary:', s.not_generated_reason);
// 'processing': ask again later. Treat a not_generated_reason you do not know as "not generated".
Webhooks
Verify the raw body (the exact bytes received), not a re-parsed JSON. During the 24 h after a secret rotation DialerBee sends two signatures; either one is accepted. Pass both secrets while you roll over.
import express from 'express';
import { verifyWebhook, WebhookVerificationError } from 'dialerbee';
app.post('/dialerbee/webhooks', express.raw({ type: 'application/json' }), (req, res) => {
try {
const event = verifyWebhook({ rawBody: req.body, headers: req.headers, secrets: [process.env.DIALERBEE_WEBHOOK_SECRET] });
if (event.type === 'call.completed') { /* event.data.duration_seconds ... */ }
res.sendStatus(204); // de-duplicate on event.id (retries and replays reuse it)
} catch (e) {
if (e instanceof WebhookVerificationError) return res.sendStatus(400);
throw e;
}
});
The signing secret is returned once: by webhooks.create and by webhooks.rotateSecret. Store it
then. DialerBee does not keep it, so a replay of the same request (same Idempotency-Key,
idempotencyReplayed: true) answers secret: null; the type is string | null. If you lost the
secret, rotate it again with a new key.
isValidWebhookSignature(...) returns a boolean instead; buildSignatureHeader /
computeWebhookSignature reproduce DialerBee's signature (for your own tests). Timestamps more
than 300 s from your clock are refused.
Tier 2 and blocked operations
Tier-2 operations (campaign resume, callback and appointment writes, consent, SMS send) can reach
or unlock real people. They need their own scope and an account switch that DialerBee turns on
only on request; otherwise they answer 403 tier_not_enabled (never retried).
The Do-Not-Call list can be checked (dnc.check) and added to (dnc.add) through the API.
Removing a number from it is not offered in API v1: it is done in the DialerBee console, by an
account administrator.
sms.send is Tier 2: it needs the scope messages:send (a sending key from the account's guided
sending setup) and the account's switch. Every message needs your own client_reference; a second
message with the same reference answers 409 duplicate_client_reference and nothing is sent again.
Click-to-call, speed-to-lead and WhatsApp
calls.create({ to, agent_extension })(scopecalls:originate, Tier 2): DialerBee checks that the agent is signed in and ready (409agent_unavailableotherwise), dials the customer from one of your verified caller IDs and joins the agent on answer. Every compliance check runs first (422dial_refused,on_dncorblocked_country, and nothing is dialled).leads.create({ campaign_id, phone, dial_now: true })(scopeleads:write): adds one lead, screened like an import; on a running campaigndial_nowputs it first in the dialling order fordial_within_seconds(default 300).whatsapp.send({ to, text | template, client_reference })(scopewhatsapp:send): a text or an approved template from your own WhatsApp number; a repeatedclient_referenceis refused.
Logging
new DialerBee({ logger: { debug: console.debug, warn: console.warn } });
The logger gets the operation, route id, attempt, status, code, request id and retry delay. It never
gets the key, headers or bodies (bodies hold personal data). util.inspect, JSON.stringify and
String of the client show [REDACTED] for the key.
Options
| Option | Default | |
|---|---|---|
apiKey |
DIALERBEE_API_KEY |
dbk_live_... / dbk_test_... |
baseUrl |
https://app.dialerbee.com/api |
https only (plain http for localhost) |
timeoutMs |
30000 | per attempt |
maxRetries |
2 | 3 attempts in total |
fetch |
global fetch |
|
logger |
none | |
appInfo |
none | appended to User-Agent |
dangerouslyAllowBrowser |
false | never ship a key to a browser |