Initial commit: SKEEN Derma Experts - Sistema Integral de Gestión Clínica
- Frontend React (SKEEN Brand) con Vite, TypeScript, Tailwind - Frontend Homenest (versión alternativa) - Módulos Odoo 17 custom (citas, pacientes, monedero, pagos, ventas, inventario, whatsapp) - WACRM fork (Next.js 16 + Supabase) - Hermes + Bridge + Skills (Qwen3.6 via Nan Builders) - Scripts de migración y operación - Documentación extensiva en docs/
This commit is contained in:
383
wacrm/docs/public-api.md
Normal file
383
wacrm/docs/public-api.md
Normal file
@@ -0,0 +1,383 @@
|
||||
# Public API (`/api/v1`)
|
||||
|
||||
The public API lets you drive your wacrm instance from your own
|
||||
scripts and automations — send messages, manage contacts, launch
|
||||
broadcasts — without going through the dashboard UI.
|
||||
|
||||
> **Status:** stable. Authentication, scopes, rate limiting, the
|
||||
> messages / contacts / conversations / broadcasts endpoints, and
|
||||
> outbound event [webhooks](#webhooks) all ship now.
|
||||
|
||||
## Authentication
|
||||
|
||||
Every request authenticates with an **API key**, sent as a bearer
|
||||
token:
|
||||
|
||||
```
|
||||
Authorization: Bearer wacrm_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
```
|
||||
|
||||
Keys are **account-scoped**: a key acts on exactly one account, the
|
||||
one it was created in. There is no cross-account access.
|
||||
|
||||
### Creating a key
|
||||
|
||||
In the dashboard: **Settings → API keys → New API key**. Only
|
||||
**admins and owners** can create keys.
|
||||
|
||||
1. Give the key a name (after the integration that will use it).
|
||||
2. Grant the **scopes** it needs — nothing more (see below).
|
||||
3. Copy the key. **The full key is shown exactly once.** wacrm
|
||||
stores only a SHA-256 hash, so it can never be shown again. If you
|
||||
lose it, revoke it and create a new one.
|
||||
|
||||
### Revoking a key
|
||||
|
||||
**Settings → API keys → Revoke.** Revocation is effective on the
|
||||
key's next request. Revoked keys stay in the list as an audit trail.
|
||||
|
||||
## Scopes
|
||||
|
||||
A key can do only what its scopes allow — independent of who created
|
||||
it. Grant the minimum.
|
||||
|
||||
| Scope | Allows |
|
||||
| -------------------- | ---------------------------------------- |
|
||||
| `messages:send` | Send WhatsApp messages |
|
||||
| `messages:read` | Read messages and delivery status |
|
||||
| `contacts:read` | List and read contacts |
|
||||
| `contacts:write` | Create and update contacts |
|
||||
| `conversations:read` | List and read conversations |
|
||||
| `broadcasts:send` | Launch broadcast campaigns |
|
||||
| `webhooks:manage` | Register and manage outbound webhooks |
|
||||
|
||||
A key with **no scopes** still authenticates and can call
|
||||
`GET /api/v1/me` — useful for verifying a key works.
|
||||
|
||||
## Response envelope
|
||||
|
||||
Every response uses one of two shapes:
|
||||
|
||||
```jsonc
|
||||
// success
|
||||
{ "data": { /* ... */ } }
|
||||
|
||||
// failure
|
||||
{ "error": { "code": "forbidden", "message": "This API key is missing the 'messages:send' scope" } }
|
||||
```
|
||||
|
||||
Branch on `error.code` (stable); `error.message` is for humans and
|
||||
may be reworded.
|
||||
|
||||
| Status | `code` | Meaning |
|
||||
| ------ | -------------- | ------------------------------------------------ |
|
||||
| 401 | `unauthorized` | Missing / malformed / unknown / revoked / expired key |
|
||||
| 403 | `forbidden` | Valid key, but missing the required scope |
|
||||
| 429 | `rate_limited` | Per-key rate limit exceeded |
|
||||
| 400 | `bad_request` | Malformed input |
|
||||
| 404 | `not_found` | No such resource |
|
||||
| 500 | `internal` | Server error |
|
||||
|
||||
## Rate limits
|
||||
|
||||
Requests are limited **per key**: **120 requests per minute**. On a
|
||||
`429`, these headers tell you when to retry:
|
||||
|
||||
- `Retry-After` — seconds until the window resets
|
||||
- `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`
|
||||
|
||||
> The limiter is in-memory and **per process**. A single-instance
|
||||
> deploy (the common case for a self-hosted fork) is fine as-is. If
|
||||
> you scale to multiple instances, swap the limiter for a shared
|
||||
> store (Redis/Upstash) — see the note at the top of
|
||||
> `src/lib/rate-limit.ts`. The limit is otherwise unenforced across
|
||||
> instances.
|
||||
|
||||
## Endpoints
|
||||
|
||||
### `GET /api/v1/me`
|
||||
|
||||
Returns the account a key is bound to and the scopes it carries.
|
||||
Requires only a valid key (no scope). Use it to verify a key works
|
||||
and to discover its scopes.
|
||||
|
||||
```bash
|
||||
curl https://your-crm.example.com/api/v1/me \
|
||||
-H "Authorization: Bearer wacrm_live_xxx"
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"account": { "id": "…", "name": "Acme Inc" },
|
||||
"key": { "id": "…", "scopes": ["messages:send"] }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `POST /api/v1/messages`
|
||||
|
||||
Send a WhatsApp message to a phone number. Scope: `messages:send`. You
|
||||
pass an **E.164 number**, not an internal id — the endpoint
|
||||
finds-or-creates the contact + conversation, then sends.
|
||||
|
||||
```bash
|
||||
curl -X POST https://your-crm.example.com/api/v1/messages \
|
||||
-H "Authorization: Bearer wacrm_live_xxx" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{ "to": "+14155550123", "type": "text", "text": "Hi 👋" }'
|
||||
```
|
||||
|
||||
`type` is `text` (default), `template`, or a media kind (`image` /
|
||||
`video` / `document` / `audio`). Media needs `media_url` (and optional
|
||||
`filename`); `text` doubles as the caption. `template` needs a
|
||||
`template` object:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"to": "+14155550123",
|
||||
"type": "template",
|
||||
"template": {
|
||||
"name": "order_update",
|
||||
"language": "en_US",
|
||||
"params": ["A123"] // positional body vars, or a structured object
|
||||
},
|
||||
"reply_to_message_id": "<uuid>" // optional; must be in the same conversation
|
||||
}
|
||||
```
|
||||
|
||||
Response (201):
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"message_id": "…",
|
||||
"whatsapp_message_id": "wamid.…",
|
||||
"conversation_id": "…",
|
||||
"contact_id": "…",
|
||||
"contact_created": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Domain error codes beyond the table above: `whatsapp_not_configured`
|
||||
(400), `meta_error` (502 — the request reached Meta and it rejected the
|
||||
send), `template_malformed` (500).
|
||||
|
||||
### `GET /api/v1/contacts`
|
||||
|
||||
List contacts, newest first. Scope: `contacts:read`. Paginated (see
|
||||
[Pagination](#pagination)). Optional filters: `?search=` (matches name
|
||||
or phone) and `?tag=<tagId>`.
|
||||
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": "…", "phone": "+14155550123", "name": "Jane Doe",
|
||||
"email": null, "company": "Acme", "avatar_url": null,
|
||||
"tags": [{ "id": "…", "name": "vip", "color": "#3b82f6" }],
|
||||
"created_at": "…", "updated_at": "…"
|
||||
}
|
||||
],
|
||||
"meta": { "next_cursor": "…" }
|
||||
}
|
||||
```
|
||||
|
||||
### `POST /api/v1/contacts`
|
||||
|
||||
Create a contact. Scope: `contacts:write`. `phone` (E.164) is required;
|
||||
`name`, `email`, `company`, and `tags` (an array of tag names, created
|
||||
if missing) are optional. **Find-or-create by phone:** an existing
|
||||
match returns `200` with the existing contact; a new contact returns
|
||||
`201`. The response body is the serialized contact (same shape as the
|
||||
list rows above).
|
||||
|
||||
### `GET` / `PATCH /api/v1/contacts/{id}`
|
||||
|
||||
Read or update one contact. Scopes: `contacts:read` / `contacts:write`.
|
||||
`PATCH` updates only the fields you send (`name`, `email`, `company`);
|
||||
pass `tags` (an array of tag names) to replace the contact's tags. A
|
||||
contact in another account returns `404`.
|
||||
|
||||
### `GET /api/v1/conversations`
|
||||
|
||||
List conversations, newest first. Scope: `conversations:read`.
|
||||
Paginated. Optional filters: `?status=` (`open` / `pending` / `closed`)
|
||||
and `?contact_id=`. Each conversation embeds its contact + tags.
|
||||
|
||||
### `GET /api/v1/conversations/{id}`
|
||||
|
||||
Read one conversation. Scope: `conversations:read`. `404` if it belongs
|
||||
to another account.
|
||||
|
||||
### `GET /api/v1/conversations/{id}/messages`
|
||||
|
||||
List a conversation's messages, newest first. Scope: `messages:read`.
|
||||
Paginated. Each message includes its `direction` (`inbound` /
|
||||
`outbound`), `status` (delivery state), `whatsapp_message_id`, and
|
||||
`content_*`. The conversation is verified to belong to your account
|
||||
first (`404` otherwise).
|
||||
|
||||
### `POST /api/v1/broadcasts`
|
||||
|
||||
Launch a template broadcast to a list of recipients. Scope:
|
||||
`broadcasts:send`. The broadcast + its recipient rows are persisted
|
||||
immediately and the sends fan out in the background, so the call
|
||||
returns fast — poll `GET /api/v1/broadcasts/{id}` for progress.
|
||||
|
||||
```bash
|
||||
curl -X POST https://your-crm.example.com/api/v1/broadcasts \
|
||||
-H "Authorization: Bearer wacrm_live_xxx" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"name": "July promo",
|
||||
"template_name": "promo_july",
|
||||
"template_language": "en_US",
|
||||
"recipients": [
|
||||
{ "to": "+14155550123", "params": ["Jane"] },
|
||||
{ "to": "+14155550124" }
|
||||
]
|
||||
}'
|
||||
```
|
||||
|
||||
Recipients are capped at **1000 per request** — split larger sends.
|
||||
Invalid phone numbers are dropped and counted as `rejected`. Response
|
||||
(202):
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"broadcast_id": "…",
|
||||
"status": "sending",
|
||||
"total_recipients": 2,
|
||||
"accepted": 2,
|
||||
"rejected": 0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `GET /api/v1/broadcasts/{id}`
|
||||
|
||||
Broadcast status + counts. Scope: `broadcasts:send`. `status` moves
|
||||
`sending` → `sent`; `delivered_count` / `read_count` keep climbing as
|
||||
Meta delivery webhooks arrive. `404` for another account's broadcast.
|
||||
|
||||
## Pagination
|
||||
|
||||
Every list endpoint pages the same way. Request a page size with
|
||||
`?limit=` (default 50, max 100) and read the next page with the opaque
|
||||
`meta.next_cursor` from the previous response:
|
||||
|
||||
```
|
||||
GET /api/v1/contacts?limit=50
|
||||
→ { "data": [ … ], "meta": { "next_cursor": "eyJ…" } }
|
||||
|
||||
GET /api/v1/contacts?limit=50&cursor=eyJ…
|
||||
→ { "data": [ … ], "meta": { "next_cursor": null } } // last page
|
||||
```
|
||||
|
||||
Cursors are keyset-based (stable under concurrent inserts). Pass the
|
||||
cursor back verbatim — don't parse it. `next_cursor: null` means the
|
||||
last page.
|
||||
|
||||
## Webhooks
|
||||
|
||||
Rather than polling, register an endpoint and wacrm will POST to it when
|
||||
things happen in your account. **Migration required:** apply
|
||||
`supabase/migrations/028_webhook_endpoints.sql`.
|
||||
|
||||
### Events
|
||||
|
||||
| Event | Fires when |
|
||||
| ------------------------ | ------------------------------------------------- |
|
||||
| `message.received` | An inbound message arrives from a contact |
|
||||
| `message.status_updated` | A message you sent changed delivery status |
|
||||
| `conversation.created` | A new conversation is opened for a contact |
|
||||
|
||||
### Managing endpoints
|
||||
|
||||
All under scope `webhooks:manage`.
|
||||
|
||||
- `POST /api/v1/webhooks` — register `{ "url": "https://…", "events": ["message.received"] }`. `url` must be `https://`. **The response includes `secret` exactly once** — store it to verify signatures; wacrm keeps only an encrypted copy.
|
||||
- `GET /api/v1/webhooks` — list your endpoints (never returns the secret).
|
||||
- `GET /api/v1/webhooks/{id}` — read one.
|
||||
- `PATCH /api/v1/webhooks/{id}` — update `url`, `events`, or `is_active` (re-enabling clears the failure counter).
|
||||
- `DELETE /api/v1/webhooks/{id}` — remove one.
|
||||
|
||||
```bash
|
||||
curl -X POST https://your-crm.example.com/api/v1/webhooks \
|
||||
-H "Authorization: Bearer wacrm_live_xxx" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{ "url": "https://example.com/hooks/wacrm", "events": ["message.received"] }'
|
||||
# → 201 { "data": { "id": "…", "url": "…", "events": [...], "secret": "whsec_…" } }
|
||||
```
|
||||
|
||||
### Delivery payload
|
||||
|
||||
Every delivery is a POST with this envelope; `id` is a unique per-
|
||||
delivery uuid you can dedupe on, and `data` varies by `event`:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "8f3c…",
|
||||
"event": "message.received",
|
||||
"occurred_at": "2026-07-01T12:00:00.000Z",
|
||||
"account_id": "…",
|
||||
"data": { /* per-event, see below */ }
|
||||
}
|
||||
```
|
||||
|
||||
`data` by event:
|
||||
|
||||
```jsonc
|
||||
// message.received
|
||||
{ "conversation_id": "…", "contact_id": "…", "whatsapp_message_id": "wamid.…", "content_type": "text", "text": "Hi 👋" }
|
||||
// conversation.created
|
||||
{ "conversation_id": "…", "contact_id": "…" }
|
||||
// message.status_updated
|
||||
{ "whatsapp_message_id": "wamid.…", "conversation_id": "…", "status": "delivered" }
|
||||
```
|
||||
|
||||
Headers: `X-Wacrm-Event`, `X-Wacrm-Webhook-Id`, and `X-Wacrm-Signature`.
|
||||
|
||||
### Verifying the signature
|
||||
|
||||
`X-Wacrm-Signature: t=<unix_seconds>,v1=<hex>` where `v1 =
|
||||
HMAC-SHA256(secret, "${t}.${rawBody}")`. Recompute it over the **raw
|
||||
request body** and compare in constant time; reject if `t` is more than
|
||||
a few minutes old (replay protection).
|
||||
|
||||
```js
|
||||
const [, t, v1] = header.match(/t=(\d+),v1=([0-9a-f]+)/);
|
||||
const expected = crypto.createHmac('sha256', secret)
|
||||
.update(`${t}.${rawBody}`).digest('hex');
|
||||
const ok = crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1));
|
||||
```
|
||||
|
||||
### Delivery semantics
|
||||
|
||||
Delivery is **best-effort**: a single attempt per event with a short
|
||||
timeout, and **redirects are not followed**. `message.status_updated`
|
||||
covers messages wacrm stores (inbox + API sends), not broadcast-only
|
||||
sends, and — because providers re-send and re-order status callbacks —
|
||||
the same status may arrive more than once or out of order; **dedupe on
|
||||
`id` and don't assume ordering**. Each consecutive failure increments
|
||||
`failure_count`; after enough consecutive failures the endpoint is
|
||||
auto-disabled (`is_active: false`) — re-enable it with `PATCH` (which
|
||||
resets the counter). Durable retry-with-backoff (a delivery queue) is a
|
||||
future enhancement; today, treat missed deliveries as possible and
|
||||
reconcile with the read endpoints when it matters.
|
||||
|
||||
**Target restrictions (SSRF).** The `url` must be `https://` and must
|
||||
resolve to a public address — requests to `localhost`, private/RFC1918
|
||||
ranges, link-local (incl. cloud metadata `169.254.169.254`), and similar
|
||||
internal targets are refused at delivery time.
|
||||
|
||||
## Roadmap
|
||||
|
||||
The public API now covers messaging, contacts, conversations,
|
||||
broadcasts, and outbound webhooks — the full scope of
|
||||
[#245](https://github.com/ArnasDon/wacrm/issues/245). Future ideas
|
||||
(deals/pipelines, templates, flows, a delivery queue for webhooks) are
|
||||
not yet scheduled.
|
||||
Reference in New Issue
Block a user