Overview
Two entry points exist, and both call the same internal service — neither implements its own sending logic. Every customer that ends up with a review request goes through the existing review-request pipeline, unchanged.
- REST API —
POST /api/customers/ingest/, API-key authenticated. Use this when your own backend can make an outbound HTTP call and wants a synchronous, per-call response. - Inbound webhook —
POST /api/webhooks/inbound/<webhook_token>/, HMAC signed. Use this for a fire-and-forget integration (Zapier/Make, or any system that only speaks “send a signed POST”).
Both require the business to be on a plan with API access (Pro or Agency — Starter is denied) and are rate-limited per business.
Authentication
API key (REST endpoint)
Generate one from the dashboard: Settings → Integrations → API access. Regenerating replaces the existing key — the old one stops working immediately, since there is one key per business. The raw key is shown once, at creation; only its hash is ever stored, so it can’t be shown again afterward.
Send it as a bearer token on every call:
Authorization: Bearer rl_<prefix>_<secret>The key resolves directly to its owning business — the request is never trusted for a client-supplied business id. A missing, malformed, or revoked key returns 401.
Webhook signature + token
The webhook URL includes your business’s own webhook token (visible on your business settings). Every request must carry a mandatory signature header:
X-Signature: sha256=<hex hmac>computed as HMAC-SHA256(webhook_secret, raw_request_body). The signing secret is the same one used for Sadakom’s outbound custom webhooks — get/set it from Settings → Integrations → Custom webhook. Without a secret configured, every inbound call is rejected — set one up first. A missing or incorrect signature also returns 401.
POST /api/customers/ingest/
Auth: API key. Content type: JSON.
| Field | Required | Notes |
|---|---|---|
| name | Yes | ≤200 chars. |
| email or phone | Validated and lowercased. | |
| phone | email or phone | Human formatting accepted (spaces, dashes, parens, leading 00), normalized toward E.164. |
| external_customer_id | No | ≤120 chars. Primary deduplication key — see Deduplication below. |
| branch_id | No | Sadakom's own branch id. Takes precedence if both branch fields are given. |
| external_branch_id | No | Matched against the branch's own external id — see Branch mapping below. |
business_id, source, and opted_out are not accepted from the body — the tenant comes from the API key, source is always set to "api", and a newly ingested customer is never opted out.
Returns 201 when a new customer was created, 200 when the call was a no-op against an existing customer (idempotent replay):
{
"id": 4821,
"name": "Fahad Al-Otaibi",
"email": null,
"phone": "+966501234567",
"source": "api",
"opted_out": false,
"created_at": "2026-08-15T10:03:00Z",
"external_customer_id": "pos-1",
"created": true,
"review_request": { "id": 9931, "status": "pending" }
}review_request is null when created is false (nothing new was queued), or when the branch’s active campaign can’t reach this customer on its configured channel (e.g. an SMS-only campaign against an email-only customer) — sending is skipped deliberately rather than consuming quota on a guaranteed failure. The request’s status is always "pending" in this response — delivery happens asynchronously afterward.
| Status | Body | Cause |
|---|---|---|
| 401 | {"error": "invalid_api_key"} | Missing, malformed, or revoked key. |
| 403 | {"error": "subscription_inactive", ...} | Business's subscription isn't active/trialing. |
| 403 | {"error": "feature_not_available", "feature": "api_webhooks"} | Plan (Starter) doesn't include API access. |
| 429 | {"error": "quota_exceeded", "feature": "review_requests", "used": N, "limit": N} | Monthly review-request quota exhausted. The customer is not created either — creation and the quota reservation happen in one transaction. |
| 429 | (standard rate-limit response) | Rate limit exceeded — see Rate limits. |
POST /api/webhooks/inbound/<webhook_token>/
Auth: signature (above), no bearer token. Only the {"type": "customer", ...} payload is documented here.
| Field | Required | Notes |
|---|---|---|
| name | Yes | Checked non-blank only. |
| email or phone | Lowercased, trimmed. No format validation. | |
| phone | email or phone | Trimmed. No format validation or normalization. |
| external_customer_id | No | Same deduplication role as the REST endpoint. |
| branch_id | No | Same resolution as the REST endpoint. |
| external_branch_id | No | Same resolution as the REST endpoint. |
Response:
{ "status": "ok", "created": true, "customer_id": 4821 }created is false on an idempotent replay — no second review request is queued.
Plan requirements
Both entry points require API access on the business's active plan:
| Field | Required | Notes |
|---|---|---|
| Starter | — | No — 403 on every call. |
| Pro | — | Yes. |
| Agency | — | Yes. |
Checked on every call, not just when the key/webhook was set up — a downgrade takes effect immediately.
Deduplication
Both entry points look up an existing customer before creating anything, in order:
external_customer_id(if given) — exact match within the business.email(if no external-id match) — exact match within the business.phone(if no external-id or email match) — exact match within the business.
A match returns the existing customer, with created: false, and queues no review request — replaying the same event, even with a different name/email on later calls, is a safe no-op.
Branch mapping
branch_id (Sadakom’s own id) is tried first; external_branch_id is tried if branch_id isn’t given. Neither given → the customer is business-wide.
Rate limits
Both entry points share one budget: 60 requests/minute per business. The bucket is keyed by the resolved business — for the API key that’s the key’s own business; for the webhook it’s the business the token in the URL resolves to.
Not implemented
- Batch ingestion — one customer per call/payload only.
- Update / delete events — no
customer.updatedorcustomer.deletedevent, no PATCH/DELETE equivalent on the REST endpoint. - A separate idempotency-key ledger — replay protection is entirely the identity matching described above.
- Dashboard visibility into ingestion activity — ingested customers show up in the regular customer list with a source of “api” or “webhook”, same as any other customer.
Questions about integrating? Contact us.