# Grouthna CRM API

> REST API for Grouthna CRM: leads, WhatsApp messages and templates, conversations, booking, verification codes and calls.

Base URL: https://staging.timdad.online/api/v1
Authorization: Bearer gr_live_… — create a key (with only the scopes it needs) in Settings → API. 120 requests a minute per key; errors are { "error": { "code", "message" } }.

## Endpoints

### POST /leads — Add or update a lead (scope: leads:write)
A website form, landing page or another system. Upserts by phone: 201 when created, 200 when the number already exists. Ad fields (platform, campaign, ad, click ids, utm_*) credit the exact ad.
Body: {"phone":"0551234567","name":"Noura","service_code":"LASER","source":"website","campaign":"Summer offer","utm_source":"snapchat","gclid":"…"}
Response: {"id":"lead-uuid","created":true}

### GET /leads — List leads (scope: leads:read)
Newest first.
Query: updated_since (ISO date), stage (stage key), limit (1–200 (50)), before (ISO date, for paging)
Response: {"data":[{"id":"lead-uuid","name":"Noura","phone":"+966551234567","stage":"new","source":"website","campaign":"Summer offer","opted_out":false,"created_at":"2026-10-09T09:00:00Z"}],"next_before":null}

### GET /leads/{id} — One lead (scope: leads:read)
The lead with its appointments.
Response: {"id":"lead-uuid","name":"Noura","phone":"+966551234567","stage":"booked","appointments":[{"id":"appt-uuid","start_at":"2026-10-12T07:00:00Z","status":"booked","doctor":"Dr. Sara","service":"Cleaning"}]}

### POST /messages — Send a WhatsApp message (scope: messages:write)
Three shapes. A template goes any time (creates the lead if new). Text and files go into the client's open conversation: on the official number only if the client wrote in the last 24 hours; QR numbers, Telegram and website chat any time — otherwise 409 window_closed (send a template). Files are fetched once from a public HTTPS link (no redirects, 20 MB).
Body: {"phone":"0551234567","template":"invoice_ready","params":["Noura","INV-1001"],"header":{"link":"https://example.com/inv-1001.pdf","filename":"invoice.pdf"},"buttons":["ORD-1001"]}
Plain text: {"phone":"0551234567","text":"Your order is on its way"}
A file: {"phone":"0551234567","media_url":"https://example.com/menu.pdf","caption":"Our menu"}
Response: {"queued":true,"type":"text","message_id":"msg-uuid","conversation_id":"conv-uuid","lead_id":"lead-uuid"}
Errors: 404 not_found — no approved template with this name; 409 window_closed / opted_out / blocked; 422 params count, media type or size

### GET /whatsapp/numbers — Your QR WhatsApp numbers (scope: messages:read)
The business's QR-linked WhatsApp numbers and whether each is connected now — the number_id to send from.
Response: {"data":[{"id":"number-uuid","name":"Sales","phone":"966551234567","connected":true}]}

### GET /whatsapp/groups — Groups of a QR number (scope: messages:read)
The WhatsApp groups the number is in (connected numbers only).
Query: number_id (required)
Response: {"data":[{"id":"120363000000000001@g.us","name":"VIP clients","size":48}]}

### POST /whatsapp/send — Send from a chosen QR number (scope: messages:write)
Send from one of your QR numbers to any number (no earlier conversation needed — it shows in the inbox) or to a group the number is in. One of text (optionally with up to 3 buttons, or a list), media_url (+caption, filename) or poll. Buttons and lists aren't officially allowed from QR numbers: they may not show and can get the number restricted — at your own risk. Paced by the number's own sending queue. 409 when the number isn't connected or the client is blocked.
Body: {"number_id":"number-uuid","to":"0551234567","text":"Your order is ready ✅"}
To a group, with a file: {"number_id":"number-uuid","to":"120363000000000001@g.us","media_url":"https://example.com/offer.jpg","caption":"This week's offer"}
A poll: {"number_id":"number-uuid","to":"0551234567","poll":{"question":"Which time suits you?","options":["Morning","Evening"],"multi":false}}
Buttons (QR — WhatsApp may block them): {"number_id":"number-uuid","to":"0551234567","text":"Book your visit?","buttons":[{"kind":"reply","text":"Book now"},{"kind":"url","text":"Our site","url":"https://example.com"}]}
Response: {"queued":true,"to":"966551234567","message_id":"msg-uuid","conversation_id":"conv-uuid"}
Errors: 404 not_found — no QR number with this id; 409 not_connected / blocked; 422 media type or size, poll options

### GET /conversations — A client's conversations (scope: messages:read)
Up to 10 conversations (newest first) with their latest messages, oldest → newest. Team notes are left out.
Query: phone (required), limit (messages per conversation, 1–200 (50)), before (ISO date, to page back)
Response: {"data":{"contact":{"id":"contact-uuid","name":"Noura","phone":"+966551234567","opted_out":false,"blocked":false},"conversations":[{"id":"conv-uuid","channel":{"type":"whatsapp","name":"Main number"},"window_open":true,"messages":[{"id":"msg-uuid","direction":"in","kind":"text","text":"Hello","status":"received","at":"2026-10-09T09:00:00Z"}]}]}}

### GET /templates — List templates (scope: templates:read)
WhatsApp templates and their status.
Query: status (APPROVED | PENDING | REJECTED | DRAFT | PAUSED)
Response: {"data":[{"name":"invoice_ready","category":"UTILITY","language":"ar","status":"APPROVED","body":"Hi {{1}}, your invoice {{2}} is ready.","variables":2,"header":{"type":"DOCUMENT"},"buttons":[{"type":"URL","text":"Pay","url":"https://pay.example.com/{{1}}","dynamic":true}]}]}

### GET /templates/{name} — Template status (scope: templates:read)
One template, with the rejection reason when WhatsApp rejected it.
Response: {"data":{"name":"invoice_ready","status":"PENDING","rejected_reason":null}}

### POST /templates — Create a template (scope: templates:write)
Created and sent to WhatsApp for approval (submit: false keeps it a draft). Variables are {{1}}, {{2}}… with one example each. A media header is fetched from header.link (JPG/PNG 5 MB, MP4 16 MB, PDF 20 MB).
Body: {"name":"invoice_ready","category":"UTILITY","language":"ar","body":"Hi {{1}}, your invoice {{2}} is ready.","examples":["Noura","INV-1001"],"footer":"Thank you","header":{"type":"DOCUMENT","link":"https://example.com/sample.pdf","filename":"invoice.pdf"},"buttons":[{"type":"URL","text":"Pay","url":"https://pay.example.com/{{1}}"}]}
Response: {"data":{"name":"invoice_ready","status":"PENDING"}}
Errors: 409 conflict — same name and language; 422 invalid_template — the builder's checks

### GET /services — Services (scope: appointments:read)
Active services with price, duration and doctors (for booking widgets).
Response: {"data":[{"id":"svc-uuid","code":"CLEAN","name":"Cleaning","price_sar":250,"duration_min":30,"doctors":[{"id":"doc-uuid","name":"Dr. Sara","accepts_new":true}]}]}

### GET /slots — Free times (scope: appointments:read)
Free times per doctor (Riyadh dates).
Query: service_id (required), from (YYYY-MM-DD), days (1–14 (7))
Response: {"data":[{"doctor_id":"doc-uuid","doctor":"Dr. Sara","accepts_new":true,"slots":[{"start_at":"2026-10-12T07:00:00Z","date":"2026-10-12","label":"10:00 ص"}]}]}

### GET /appointments — Appointments (scope: appointments:read)
Appointments between two dates (62 days max).
Query: from (ISO date), to (ISO date)
Response: {"data":[{"id":"appt-uuid","lead_id":"lead-uuid","client":"Noura","phone":"+966551234567","status":"confirmed"}]}

### POST /otp — Send a verification code (scope: otp:send)
A code on WhatsApp through the business's approved verification template (the default «verify_code», or one of its own authentication templates built in Settings → Templates, by name). Grouthna makes the code unless you send one.
Body: {"phone":"0551234567","ttl_minutes":5}
Your own authentication template: {"phone":"0551234567","template":"login_code","code":"482913"}
Response: {"sent":true,"otp_id":"otp-uuid","expires_at":"2026-10-09T09:05:00Z"}

### POST /campaigns — Launch a WhatsApp campaign (scope: campaigns:write)
An approved template to a list of numbers (up to 5000), from your official number or a QR number (channel_id, else the campaign's default). Unknown numbers become leads. Each row may carry its own values for the template's variables (params per row) — else the campaign's params go to everyone ({name} = the client's first name). Optional fallbacks: an SMS text id when WhatsApp fails for good, then an email. Runs at once, or at schedule_at. 202 with the campaign id.
Body: {"name":"October offer","template":"monthly_offer","channel_id":"channel-uuid","params":["{name}"],"phones":[{"phone":"0551234567","name":"Noura"},{"phone":"0559876543","params":["Sara"]}],"schedule_at":"2026-10-20T07:00:00Z","email_fallback":{"subject":"October offer","text":"Hi {{name}}, …"}}
Response: {"id":"campaign-uuid","status":"sending","recipients":2,"skipped":0}
Errors: 404 not_found — no approved template with this name / no such channel; 422 unprocessable — params count, empty list, plan limit

### GET /campaigns/{id} — Campaign status (scope: campaigns:write)
Where it stands: counts per state and the delivery numbers.
Response: {"id":"campaign-uuid","name":"October offer","status":"done","started_at":"2026-10-20T07:00:00Z","finished_at":"2026-10-20T07:03:00Z","recipients":2,"sent":2,"delivered":2,"read":1,"replied":0,"failed":0,"pending":0,"sms_fallback":0,"email_fallback":0}

### POST /campaigns/{id}/cancel — Cancel a campaign (scope: campaigns:write)
What wasn't sent yet is skipped; what went stays.
Response: {"cancelled":true}

### POST /otp/verify — Check a code (scope: otp:send)
Valid once, until it expires; 5 tries per code. A wrong code: { valid: false, reason, attempts_left }.
Body: {"phone":"0551234567","code":"4821"}
Response: {"valid":true}

### POST /calls — Log a call (scope: calls:write)
Any phone system reports a finished call. It lands on the client's card; a missed incoming call alerts the owner. Idempotent per (source, external_id).
Body: {"phone":"0551234567","direction":"in","status":"answered","duration_sec":134,"extension":"101","source":"grandstream","external_id":"1696512345.12"}
Response: {"id":"call-uuid","lead_id":"lead-uuid","duplicate":false}

## Webhooks
Webhooks: add an HTTPS address in Settings → API and pick events. Each POST carries X-Grouthna-Event, X-Grouthna-Timestamp and X-Grouthna-Signature = sha256=HMAC_SHA256(secret, `${timestamp}.${body}`) — check it, and reject old timestamps. lead.created, lead.stage_changed and payment.paid carry `ad`: the ad the client came from (platform, campaign, adset, ad, ad_id, campaign_id, adset_id, form, form_id, click_id, click_type = fbclid|gclid|gbraid|wbraid|ttclid|sccid|twclid|ctwa, platform_lead_id, utm_*), or null.

- lead.created: a new lead
- lead.stage_changed: a lead moved to another stage
- appointment.booked: an appointment was booked
- appointment.status_changed: confirmed / attended / no-show / cancelled
- message.received: a client wrote
- message.status: an outgoing message was delivered, read or failed
- channel.status: a number connected or disconnected
- call.logged: a call was logged
- ticket.created: a ticket or complaint was opened
- ticket.status_changed: a ticket moved on
- payment.paid: a payment link was paid

## Full reference
https://staging.timdad.online/developers
https://staging.timdad.online/api/v1/openapi.json
