Grouthna CRM API

Leads, WhatsApp messages and templates, conversations, booking, verification codes and calls — over HTTPS with JSON.

OpenAPI 3.1 (JSON)llms.txt

Authentication

Base URL https://staging.timdad.online/api/v1. Send Authorization: Bearer gr_live_…; create a key in Settings → API with only the scopes it needs (a key can expire). 120 requests a minute per key. Errors: { "error": { "code": "…", "message": "…" } } with 400 / 401 / 403 / 404 / 409 / 422 / 429.

curl https://staging.timdad.online/api/v1/templates?status=APPROVED \n  -H "Authorization: Bearer gr_live_…"

Endpoints

POST /api/v1/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.

Request
{
  "phone": "0551234567",
  "name": "Noura",
  "service_code": "LASER",
  "source": "website",
  "campaign": "Summer offer",
  "utm_source": "snapchat",
  "gclid": "…"
}
Response
{
  "id": "lead-uuid",
  "created": true
}

GET /api/v1/leads

List leads · scope leads:read

Newest first.

  • 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 /api/v1/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 /api/v1/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).

Request
{
  "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"
}
  • 404 not_found — no approved template with this name
  • 409 window_closed / opted_out / blocked
  • 422 params count, media type or size

GET /api/v1/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 /api/v1/whatsapp/groups

Groups of a QR number · scope messages:read

The WhatsApp groups the number is in (connected numbers only).

  • number_id — required
Response
{
  "data": [
    {
      "id": "120363000000000001@g.us",
      "name": "VIP clients",
      "size": 48
    }
  ]
}

POST /api/v1/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.

Request
{
  "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"
}
  • 404 not_found — no QR number with this id
  • 409 not_connected / blocked
  • 422 media type or size, poll options

GET /api/v1/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.

  • 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 /api/v1/templates

List templates · scope templates:read

WhatsApp templates and their status.

  • 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 /api/v1/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 /api/v1/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).

Request
{
  "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"
  }
}
  • 409 conflict — same name and language
  • 422 invalid_template — the builder's checks

GET /api/v1/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 /api/v1/slots

Free times · scope appointments:read

Free times per doctor (Riyadh dates).

  • 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 /api/v1/appointments

Appointments · scope appointments:read

Appointments between two dates (62 days max).

  • from — ISO date
  • to — ISO date
Response
{
  "data": [
    {
      "id": "appt-uuid",
      "lead_id": "lead-uuid",
      "client": "Noura",
      "phone": "+966551234567",
      "status": "confirmed"
    }
  ]
}

POST /api/v1/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.

Request
{
  "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 /api/v1/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.

Request
{
  "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
}
  • 404 not_found — no approved template with this name / no such channel
  • 422 unprocessable — params count, empty list, plan limit

GET /api/v1/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 /api/v1/campaigns/{id}/cancel

Cancel a campaign · scope campaigns:write

What wasn't sent yet is skipped; what went stays.

Response
{
  "cancelled": true
}

POST /api/v1/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 }.

Request
{
  "phone": "0551234567",
  "code": "4821"
}
Response
{
  "valid": true
}

POST /api/v1/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).

Request
{
  "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

Add an HTTPS address in Settings → API and pick events. Each POST has a JSON body { id, event, created_at, data } and the headers X-Grouthna-Event, X-Grouthna-Timestamp and X-Grouthna-Signature: sha256=HMAC_SHA256(secret, `timestamp.body`). Check the signature and reject old timestamps. Failed deliveries are retried.

MCP

AI assistants (Claude and others) can work with the account through the MCP server — see Settings → API → MCP.