Webhooks
Send signed CRM events to your systems and understand the provider callbacks SmileLine receives.
SmileLine supports both webhook directions:
- Outbound webhooks send selected CRM events from SmileLine to your HTTPS endpoint.
- Provider webhooks receive messages, payment events, PMS changes, and native lead forms into SmileLine.
Website lead capture uses a separate /capture/{token} surface described under Custom integrations.
Send CRM events to your endpoint
Outbound webhook endpoints are API-managed. Owners and managers can create and change them; use an API key with the same permissions and organization scope described in Authentication. Each organization can keep at most 20 non-archived endpoints, which also caps the delivery fan-out for every logical event.
Create, test, and enable an endpoint
Create an endpoint with a name, an HTTPS URL, and at least one event. The response returns its signingSecret exactly once, so save it in your secret manager before continuing.
Queue a test delivery with POST /settings/webhook-endpoints/{id}/test, then inspect the delivery until its status is delivered. Tests can run while an endpoint is disabled or paused, and the current test is processed before held ordinary deliveries. A successful test sets the endpoint's verifiedAt value.
Enable the verified endpoint with POST /settings/webhook-endpoints/{id}/enable. Real events are not queued while the endpoint is disabled or paused.
Create an endpoint:
curl -X POST "$SMILELINE_API_URL/settings/webhook-endpoints" \
-H "Authorization: Bearer $SMILELINE_API_KEY" \
-H "X-Organization-ID: $SMILELINE_ORGANIZATION_ID" \
-H "Content-Type: application/json" \
-d '{
"name": "CRM events",
"url": "https://hooks.example.com/smileline",
"events": ["patient.created", "journey.won"]
}'The endpoint URL must contain no more than 2,048 UTF-8 bytes. Changing it clears its verification and disables delivery. Send another successful test before enabling it again.
The generated reference under Settings → Webhook endpoints documents list, create, read, update, archive, test, enable, pause, secret rotation, delivery inspection, and replay requests.
Subscribe in one call
A machine client that cannot babysit the create → test → poll → enable handshake can use POST /settings/webhook-endpoints/subscriptions instead. It creates the endpoint, sends the verification probe synchronously, and enables the endpoint before answering. This route requires an OAuth connection; an API key gets 403 with code: "OAUTH_CONNECTION_REQUIRED".
curl -X POST "$SMILELINE_API_URL/settings/webhook-endpoints/subscriptions" \
-H "Authorization: Bearer sl_oat_EXAMPLE_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "Orders bridge",
"url": "https://hooks.example.com/smileline",
"events": ["journey.won"],
"subscriptionKey": "orders-bridge-1"
}'subscriptionKey is your own stable identity for one subscription, and it is what makes the route an idempotent upsert. Retrying after a lost response returns the same endpoint with the same signingSecret and a 200 instead of a 201, rather than spending another of the practice's 20 endpoint slots — this works even when the practice is already at the cap, and when two identical calls race, the loser resolves to the winner's subscription rather than erroring. An endpoint left disabled by an interrupted attempt is re-probed and enabled again, consuming no new capacity.
Two things a re-subscribe deliberately will not do. It will not move a subscription to a different url — the key identifies the destination, so a changed URL under an existing key is 409 rather than a silent re-point. And it will not resume a paused endpoint: pausing is either an operator's decision or the circuit breaker after twenty consecutive failures, and neither should be reversible by a client retry. Resume it from Settings → Integrations first. name and events changes are applied normally.
| Status | Meaning |
|---|---|
201 | Created, verified and enabled. |
200 | An existing subscription for this key was returned or repaired. |
403 | OAUTH_CONNECTION_REQUIRED, or OAUTH_CONNECTION_REVOKED if the connection was disconnected mid-call — the endpoint is removed again. |
409 | WEBHOOK_ENDPOINT_LIMIT_REACHED, WEBHOOK_SUBSCRIPTION_CONFLICT (the key belongs to another connection, or names a different url), WEBHOOK_ENDPOINT_PAUSED, WEBHOOK_SUBSCRIPTION_IN_PROGRESS (a concurrent create has not committed yet; retry), or WEBHOOK_VERIFICATION_FAILED. |
WEBHOOK_VERIFICATION_FAILED carries the probe's own outcome in details — responseStatus and error — so you can tell an endpoint that answered 500 from one that was never reachable. An endpoint that fails verification this way is not left behind: it is archived, and its slot is returned.
Archive a subscription with the ordinary DELETE /settings/webhook-endpoints/{id}. Disconnecting the app archives every endpoint that connection created and fails anything still queued for them, so nothing arrives after the disconnect. Should a connection's consent disappear another way — the person who authorized it has their account deleted, say — an hourly sweep archives its endpoints for the same reason.
Zaps manage their own endpoints through this route. Each switched-on Zap trigger holds one endpoint, switching a Zap off releases it, and switching it on again resumes the same subscription. You do not need to create or clean up endpoints for a Zap by hand — see Zapier.
Event envelope
Every request body is a bounded version 1 JSON envelope:
{
"version": "1",
"id": "019c1234-5678-7000-8000-000000000001",
"type": "patient.updated",
"createdAt": "2026-07-19T14:32:11.000Z",
"organizationId": "practice_123",
"subject": {
"type": "patient",
"id": "019c1234-5678-7000-8000-000000000002"
},
"actor": {
"type": "user",
"id": "user_123"
},
"data": {
"action": "update",
"changedFields": ["firstName", "email"]
}
}id is the stable logical event ID. subject identifies the changed resource, and actor.type is user, system, patient, or provider. Treat fields you don't recognise as forward-compatible additions.
The envelope reports that something changed and identifies it; it does not carry a before/after snapshot. If you read the resource back, you see its state at the moment you read it, not at the moment of the event — so two events delivered close together can both resolve to the same current state. Key your side effects on id, which identifies the event itself.
Payloads are limited to 64 KiB and expose bounded event metadata rather than whole database rows. Validation rejects notes, medical alerts, message/review/feedback bodies, attachments, raw provider payloads, credentials, OAuth data, capability-bearing URLs, tokens, secrets, and Stripe identifiers. The event catalogue contains no commercial billing events.
Event catalogue
Choose any of these 40 stable event names:
| Area | Events |
|---|---|
| Patients | patient.created, patient.updated, patient.archived |
| Journeys | journey.created, journey.updated, journey.stage_changed, journey.won, journey.lost, journey.reopened |
| Tasks | task.created, task.updated, task.completed, task.reopened, task.cancelled |
| Appointments | appointment.created, appointment.updated, appointment.status_changed, appointment.rescheduled |
| Conversations | conversation.created, conversation.updated |
| Messages | message.received, message.sent, message.status_changed |
| Deposits | deposit_request.created, deposit_request.status_changed |
| Reputation | reputation_request.status_changed, reputation_feedback.received, reputation_review.received, reputation_review.updated, reputation_reply.status_changed |
| Native lead ads | lead_ad_submission.received, lead_ad_submission.status_changed, lead_ad_connection.health_changed |
| PMS | pms_connection.health_changed, pms_patient.link_changed, pms_sync.review_required, pms_treatment_plan.updated, pms_invoice.updated, pms_payment.updated |
| Messaging connections | channel_connection.health_changed |
PMS historical imports update SmileLine without emitting one outbound event per imported record. Once the connection is active, a live provider item has one stable logical event identity across polling overlap, provider retries and Queue redelivery. An unchanged replay or revision-only timestamp bump does not create another endpoint delivery. Conflicting content at the same revision is held rather than treated as an unchanged replay.
Google review events report live changes; they are not a history export. An initial, manual or scheduled Google history refresh updates the Reputation feed without emitting one webhook per historical review. A live provider notification emits at most five deterministic review-change events per location, and replaying the same review version does not create a new event.
Native Lead Ads status events report meaningful processing outcomes.
SmileLine does not emit pending, processing, or retrying Queue states.
Replaying the same submission outcome preserves its logical event ID and does
not create another endpoint delivery.
Verify the signature
SmileLine sends these headers with each request:
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | SmileLine-Webhooks/1.0 |
X-SmileLine-Delivery-Id | The physical delivery UUID. |
X-SmileLine-Timestamp | Unix time in seconds. |
X-SmileLine-Signature | One or more comma-separated v1=<hex> HMAC-SHA256 signatures. |
Calculate the HMAC over the exact raw request body, prefixed with the timestamp and delivery ID:
timestamp.deliveryId.rawBodyVerify the signature before parsing JSON. Compare digests in constant time, reject stale timestamps according to your replay window, and record processed event IDs before applying side effects.
This Node.js example accepts either signature during a key-rotation overlap:
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifySmileLineWebhook({
rawBody,
deliveryId,
timestamp,
signatureHeader,
signingSecret,
}) {
const timestampNumber = Number(timestamp);
if (
!Number.isSafeInteger(timestampNumber) ||
Math.abs(Date.now() / 1000 - timestampNumber) > 300
) {
return false;
}
const expected = createHmac("sha256", signingSecret)
.update(`${timestamp}.${deliveryId}.`)
.update(rawBody)
.digest();
return signatureHeader.split(",").some((item) => {
const match = /^v1=([0-9a-f]{64})$/i.exec(item.trim());
if (!match) return false;
const supplied = Buffer.from(match[1], "hex");
return supplied.length === expected.length && timingSafeEqual(supplied, expected);
});
}Use X-SmileLine-Delivery-Id to recognise transport retries. Make your domain effect idempotent against the envelope id as well: a manual replay creates a new physical delivery while preserving the logical event ID and payload.
Rotate a signing secret
POST /settings/webhook-endpoints/{id}/secret/rotate returns a new signingSecret exactly once. The previous secret remains valid for 24 hours unless you revoke it sooner.
During the overlap, X-SmileLine-Signature contains one signature for each valid secret. Deploy the new secret, verify that deliveries succeed, then call POST /settings/webhook-endpoints/{id}/secret/revoke-previous.
Delivery and retry behavior
Ordinary events are durably staged and picked up by a bounded recovery sweep, normally within five minutes. Test and explicit replay requests queue their delivery immediately. This avoids scanning every endpoint after every unrelated API mutation while preserving the outbox if a queue publication is unavailable.
Any 2xx response completes a delivery. Network errors, timeouts, and HTTP
408, 425, 429, or 5xx responses become eligible for another attempt
after:
- 1 minute
- 5 minutes
- 30 minutes
- 2 hours
- 8 hours
- 24 hours
A new queue publication is paced at least five minutes after the previous one,
so the first one-minute eligibility normally resumes on the next five-minute
recovery sweep. A valid Retry-After header on a retryable response overrides
that attempt's delay, capped at 24 hours. Other HTTP statuses are terminal.
Each delivery has a 48-hour publication deadline, at most seven queue
publications, and at most seven outbound requests. Among deliveries that are
due for one endpoint, SmileLine claims the oldest first and keeps only one
active delivery lease for that endpoint; don't rely on global event order
across retries or endpoints.
Any successful delivery resets the endpoint's failure streak. After 20 consecutive terminal non-test failures, SmileLine disables and automatically pauses the endpoint. Inspect the failure, send a successful test if the URL changed, and enable it again when fixed.
You can inspect delivery status, attempt count, response status, a bounded response snippet, and the last error through the deliveries API. Completed and failed delivery records are retained for 30 days. Replaying a delivery preserves its envelope and logical ID but creates a new delivery generation.
Destination restrictions
Outbound delivery uses a separate, fail-closed egress boundary. Endpoint URLs must:
- use HTTPS on the default port
443; - use a public DNS hostname that resolves only to public IP addresses;
- contain no URL username or password; and
- accept the request without a redirect.
Private, loopback, reserved, local, and ambiguous DNS destinations are rejected. DNS aliases are checked recursively. Requests time out after 10 seconds, and both the request and captured response body are limited to 64 KiB.
Provider webhooks received by SmileLine
These endpoints receive events from providers into SmileLine. They power the Inbox and native lead capture. You don't call them yourself: SmileLine configures the callback during connection or shows the exact URL where a provider requires manual form setup.
| Endpoint | Provider | Purpose |
|---|---|---|
GET /webhooks/whatsapp | Meta | Subscription handshake (echoes hub.challenge). |
POST /webhooks/whatsapp | Meta | Inbound WhatsApp messages and delivery statuses. |
POST /webhooks/twilio/{token} | Twilio | Inbound SMS. The token routes to your channel connection. |
POST /webhooks/twilio/{token}/status | Twilio | SMS delivery status callbacks. |
POST /webhooks/resend | Resend | Email delivery statuses. |
POST /webhooks/pms/{provider}/{token} | Connected PMS | Patient and appointment change notifications. |
GET /webhooks/stripe-connect/oauth/callback | Stripe Connect | Single-use Standard-account OAuth return. |
POST /webhooks/stripe-connect | Stripe Connect | Deposit payment, refund and connected-account events. |
POST /webhooks/telnyx/{token} | Telnyx | Power-dialler call, recording and number-porting events. |
GET /webhooks/lead-ads/meta | Meta | Lead Ads subscription handshake. |
POST /webhooks/lead-ads/meta | Meta | Facebook and Instagram Instant Form lead notifications. |
POST /webhooks/lead-ads/google/{routingToken} | Google Ads | Full Google lead form submissions for the configured connection. |
POST /webhooks/lead-ads/tiktok | TikTok | Batched TikTok Instant Form submissions. |
POST /webhooks/lead-ads/meta/deauthorize | Meta | Signed app deauthorization callback. |
POST /webhooks/lead-ads/meta/data-deletion | Meta | Signed user data-deletion request. |
Inbound security
Signed webhooks verify the provider signature against the untouched request body before parsing: Meta uses the app secret, TikTok uses X-Open-Signature, Twilio signs request details, and Resend uses svix. WhatsApp and Twilio inbound messages claim a connection-scoped SHA-256 provider-message receipt before suppression, message, rate-limiter or media-queue effects. Completed receipts remain as payload-free tombstones for the life of the connection because these signatures do not carry a replay deadline. Resend timestamps must fall within five minutes of receipt and its opaque event ID is limited to 512 bytes; that ID then enters a finite five-attempt, 24-hour receipt lifecycle before status effects run. Failed Resend processing parks instead of deleting and reopening that receipt, and terminal payload-free IDs are retained because a provider can freshly sign a later retry. Google Ads lead forms instead include the one-time google_key in the body; SmileLine retains only its SHA-256 digest and compares fixed-size hashes. The URL's routing token identifies the connection but grants no access by itself. Requests that fail their provider's verification are rejected.
Authenticated Meta, Google Ads, and TikTok lead bodies claim an exact raw-body receipt before lead parsing or staging. One body can contain at most 100 deliveries and can consume at most three processing attempts in 24 hours. A completed or exhausted provider retry is acknowledged without restaging the batch. Google unknown assets reserve one durable discovery obligation and are then acknowledged, so provider retry cannot create another discovery generation. These non-PII receipt tombstones remain for the credential scope because the callback authentication has no enforceable request age.
Signed PMS bodies follow the same rule before the per-connection rate limiter or payload parser. Malformed and over-100-event bodies settle their receipt terminally; a valid body is staged once and Queue publication recovery uses the persisted receipt rather than asking the PMS to replay. The digest tombstone remains until the PMS connection is removed.
These callback bodies are stream-bounded before connection lookup, signature verification or parsing. Limits reflect each provider's callback shape: Twilio 64 KiB; Resend, Telnyx, Meta Lead Ads and Google Lead Ads 256 KiB; WhatsApp, PMS and Stripe Connect 1 MiB; TikTok Lead Ads 2 MiB. Media webhooks carry bounded attachment metadata or provider references, never uploaded media bytes. Oversized callbacks answer 413 and are not parsed or persisted.
Telnyx porting-family events (porting_order.* and portout.*) are recognised alongside call and recording events. Each signed porting event is durably captured in raw form on receipt, and one the handler cannot resolve — an unknown number, a missing reference, an unusable body — is parked for operator review before the acknowledgement rather than being silently acknowledged.
Duplicate deliveries are acknowledged with a 2xx so the provider doesn't enter a useless retry loop. Unknown Google lead assets first reserve bounded discovery work and are then acknowledged; other unknown or inactive targets are safely ignored.
Inbound WhatsApp and Twilio attachment references are persisted with the message before the callback is acknowledged. SmileLine then sends only the internal message ID to a dedicated media queue, refreshes short-lived WhatsApp download references, bounds the downloaded bytes, and copies the object to durable storage. Queue retries, a dead-letter queue and a five-minute stale-pending sweep recover interrupted downloads; the Inbox can show Loading… until the durable copy is ready. If every configured attempt is exhausted, the expiring provider reference is removed and the attachment changes to Attachment unavailable instead of remaining pending forever.
Inbound SMS and WhatsApp STOP/START keywords update the linked patient's global channel suppression and append consent evidence. STOP also sets a contact-level channel block, so it remains a safety veto when a connection is inactive or ordinary message ingest is rate-limited. A shared contact can represent at most 25 patients. One event appends evidence for at most those 25 patients; a larger legacy household keeps the contact blocked and records a manual-review marker instead of silently allowing patient 26. An inactive connection never treats START as renewed consent, and START cannot clear an overflow block pending review. Resend complaint and bounce events suppress email for that patient. These provider-level blocks are rechecked immediately before every outbound message delivery.
Native lead-form notifications are verified, durably staged and processed in the background. Provider payloads containing contact details never travel in a queue message; submission-processing jobs carry only the internal staged-submission identifier. Duplicate provider lead IDs converge on the same staged row.
OAuth callbacks for Meta, Google Ads and TikTok, plus Meta deauthorization and data-deletion callbacks, also live under /webhooks/lead-ads/*. They are browser/provider callback surfaces, not endpoints for API clients.
Website lead capture is different
POST /capture/{token} is an intake endpoint for website forms and signed server-to-server lead delivery, not an outbound CRM event subscription or provider callback. See Custom integrations and the Capture endpoint group in the sidebar.
Rate limits
Token-bucket limits protect public intake, booking, referral, chat and AI surfaces, while campaigns have configurable delivery pacing.
List patients GET
Paginated list. q is a typo-tolerant (trigram) search over name, email, phones, fiscal code and address (street, city, postal code). `filters` is URL-encoded JSON matching the PatientFilters schema; keys are AND-ed. sort=relevance ranks by similarity to q. embed=tags attaches each row's tags. Archived patients are excluded unless includeArchived=true or a status filter asks for archived.