Growth & Operations Integration Recipes
Wave-4 surfaces: CRM leads, outbound/inbound messaging, loyalty, feedback, tasks, inventory, bookkeeping export, analytics — plus the cross-cutting /exports and /events endpoints. Scope table in 02, routes in 03, events in 04.
Ad-platform lead ingestion (Meta/TikTok/Google lead forms)
Scopes: leads:write (implies leads:read). Events: lead.*.
try {
await client.leads.create(
{ name, phone, sourceId, subSourceId, dealSize, notes },
{ idempotencyKey: `meta-${adLeadId}` } // your platform's lead id
);
} catch (error) {
if (error instanceof DentolizeApiError && error.code === 'lead_exists') return; // dedupe: phone already a lead
throw error;
}
POST /leadsmirrors the public lead-capture path: contact record, stage placement (defaults to the first pipeline stage), round-robin auto-assignment.- Duplicate phone → 409
lead_exists— that response is your dedupe; don't pre-query. - Attribute campaigns via
sourceId/subSourceId; measure conversion with thelead.convertedwebhook (data.patientId) and stage velocity withlead.stage_changed(previousAttributes.stageId). leads.updatemoves stages with full pipeline semantics (counters, due dates, timeline history) — safe for external kanban syncs.
Reminder / marketing messaging
Scopes: communications:send (+ communications:read for the delivery log). Events: message.sent / message.failed.
const queued = await client.messages.send({
patientId, channel: 'whatsapp', templateId, variables: ['Sara', 'Sunday 9am']
});
// 202 — correlate later outcomes via queued.communicationId
- The platform resolves the patient's phone internally; partners never see it — so no
patients:read.piineeded for messaging. - WhatsApp is restricted to APPROVED templates (platform-enforced — protects the clinic's WABA); SMS accepts free text.
- Track outcomes by webhook (
message.sent/message.failedcarrycommunicationId) or pollcommunications.list({ patientId })(status + 100-char preview only).
Patient-facing agentic bot (WhatsApp booking + billing)
The full bot loop — event-driven, no polling. Scopes: conversations:write, patients:read (+ patients:write to register new patients, .pii to greet by name), availability:read, appointments:write, invoices:write, paymentlinks:write. Events: conversation.message_received, appointment.*, invoice.paid, onlinepayment.*.
// 1. Inbound message webhook (thin: ids only — pull the text)
// event.type === 'conversation.message_received'
const { conversationId, messageId, patientId, leadId } = event.data;
const history = await client.conversations.messages(conversationId!, { limit: 20 });
// 2. Who is texting? Conversation linkage first, phone lookup as fallback
const conv = (await client.conversations.list({ patientId })).data[0]
?? (await client.conversations.list({ phone: userPhone })).data[0];
const who = patientId
? { registered: true, patientId }
: await client.patients.lookup(userPhone); // { registered, patientId, leadId }
const patient = who.registered
? await client.patients.get(who.patientId!)
: await client.patients.create({ branchId, firstName, lastName, phoneNumber: userPhone });
// 3. Offer slots and book
const slots = await client.availability.list({ branchId, doctorId, date: '2026-08-30' });
const appointment = await client.appointments.create({
patientId: patient.id, branchId, doctorId, start, end
});
// 4. After the visit: issue the invoice and send a payment link
const invoice = await client.invoices.create(
{ patientId: patient.id, branchId, appointmentId: appointment.id,
items: [{ procedureId: cleaningId }] },
{ idempotencyKey: `visit-${appointment.id}` }
);
const link = await client.paymentLinks.create({ invoiceId: invoice.id });
await client.conversations.reply(conversationId!, { text: `Pay here: ${link.paymentLink}` });
// 'invoice.paid' / 'onlinepayment.succeeded' close the loop; thank the patient there.
conversation.message_receivedfires for every inbound message with ids only (conversationId,messageId,patientId?,leadId?) — content is pulled viaconversations.messages, keeping PII flows scoped and audited.patients.lookup(phone)is the deterministic registered / known-lead / unknown check (app-identical phone normalization, ids only — no.piineeded).invoices.createis cash-only in v1 and resume-safe on retries (ids derive from yourIdempotency-Key); see 03 for the 400/404/409 error table.- Replies are free-text but only within WhatsApp's 24-hour customer-service window: expect
409 conversation_expiredoutside it (fall back to a template viamessages.send) and409 whatsapp_not_configuredwhen the clinic lacks official WhatsApp. - Escalate to humans with
update({ assignedToId }); mark solved sessions withupdate({ resolved: true }). - Distribute the bot to a single clinic without hub review via an integration manifest.
Loyalty / engagement platforms
Scopes: loyalty:read / loyalty:write. Events: points.earned / points.redeemed.
const balance = await client.loyalty.patientPoints(patientId); // total/used/remaining/expired
await client.loyalty.createTransaction({ patientId, points: 250, reason: 'Referral campaign' });
await client.loyalty.createTransaction({ patientId, points: -500, reason: 'Reward redemption' });
- Sign decides: positive earns (expiry per clinic loyalty settings), negative redeems (earliest-expiring first). Overdraw →
409 insufficient_points— checkremainingfirst or handle the 409. - Platform-driven accruals (payments, referrals, feedback) also fire
points.earned— mirror balances from events instead of polling. - Pair with
feedback:read(appointment-feedback,feedback.submitted) for review-gating and NPS flows.
Supplier purchase-order flow
Scopes: inventory:read / inventory:write. Events: inventory.low_stock, inventoryorder.created / inventoryorder.received.
const low = await client.inventoryItems.list({ belowMin: true }); // restock candidates
const po = await client.inventoryOrders.create({
supplierId, branchId,
items: low.data.map(i => ({ inventoryItemId: i.id, quantity: i.preferredAmount ?? 10 }))
});
await client.inventoryOrders.updateStatus(po.id, { status: 'CONFIRMED' });
await client.inventoryOrders.updateStatus(po.id, { status: 'IN_TRANSIT' });
- Integrations may only set the supplier-side transitions
CONFIRMEDandIN_TRANSIT;COMPLETED(receiving affects stock) andCANCELEDstay clinic-side (403). Watchinventoryorder.receivedfor goods-in confirmation. - Line unit prices default to the item's catalog price.
inventory.low_stockis level-triggered: it re-fires on every mutation that leaves the item below minimum (data.amount/data.minAmountfrom the committed row,data.subItemIdfor batch/variant items) — debounce perinventoryItemIdbefore auto-raising a PO, and usebelowMin=trueas the reconciliation sweep.
BI / bookkeeping bridges (QuickBooks, Xero, dashboards)
Scopes: bookkeeping:read (financial), analytics:read, plus read scopes of anything you export.
// Backfill once via bulk export (NDJSON, ≤50k rows — window with updatedAfter):
const job = await client.exports.create({ resource: 'invoices' });
const { url, rows } = await client.exports.waitFor(job.exportId, { intervalMs: 2000 });
// Then delta-sync the ledgers with updatedAfter + from/to windows (≤366 days):
for await (const expense of client.expenses.iterate({ from, to, updatedAfter: cursor })) { /* map to your CoA */ }
const kpis = await client.analytics.revenue({ from, to, branchId }); // aggregates only, never row-level PII
- Ledger surfaces:
expenses(withmainType),incomes,transactions(runningbalanceAfterper treasury),treasuries. Note: this is the operational ledger — the GL/journal export supersedes it after the accounting module ships (see roadmap). - After webhook downtime, reconcile with
client.events.iterate({ type, from: lastSeenAt })instead of re-pulling lists — same thin payloads the deliveries would have carried.
Checklist before listing review
- Required vs optional scopes split defensibly (02); degrade gracefully when optional scopes are off.
- Idempotency keys are your own stable ids (ad-lead id, PO number) — not random per attempt.
- Handle the domain 409s (
lead_exists,conversation_expired,insufficient_points) as flow control, not errors. - Subscribe with family wildcards (
lead.*) only when the whole family is granted; dedupe deliveries byevent.id.