{"openapi":"3.1.0","info":{"title":"WA Gateway API","version":"0.1.0","description":"WhatsApp gateway built on Baileys. Multi-session, multi-tenant, with webhook\ndelivery, anti-ban patterns, and rate limiting.\n\n## Getting your API key\n\nSign up + login through the **web dashboard** (not via this API), then copy\nyour API key from **Settings**. Account/session management is dashboard-only —\nthis spec only documents the programmatic surface that consumers actually call.\n\n## Quickstart (after you have an API key)\n\n1. `POST /devices` — create a device record (one per WhatsApp account).\n2. `POST /devices/{id}/connect` — start a WA session. Subscribe to\n   `/devices/{id}/events` (SSE) to receive the QR code. Scan with WhatsApp\n   on your phone (Settings → Linked Devices).\n3. Once `status: CONNECTED`, call `POST /messages/send-text`.\n\n## Authentication\n\nAll endpoints below require a header:\n\n```\nX-API-Key: <your-64-char-hex-key>\n```\n\nVerify your key with `GET /auth/me`. Rotate it (invalidating the old key)\nwith `POST /auth/rotate-key`.\n\n## Webhooks\n\nRegister webhook URLs via `POST /webhooks`. Each delivery includes:\n\n| Header | Description |\n| --- | --- |\n| `X-Webhook-Event` | One of `message.received`, `message.status`, `device.status`. |\n| `X-Webhook-Signature` | HMAC-SHA256(body, secret), hex. |\n| `X-Webhook-Timestamp` | Server timestamp (ms) when the event was emitted. |\n\nVerify signatures using your stored `secret` (constant-time compare).\n\n## Embed origin allowlist (project tokens only)\n\nProject tokens (`wagw_...`) authorise the customer-facing\n`/devices/{id}/embed-token` endpoint, which mints a short-lived JWT for the\nQR-code embed page. The mint call accepts a `returnOrigin` parameter — the\nembed page will only `postMessage` connect events back to that exact origin.\n\nFor project tokens, `returnOrigin` MUST appear in the project's\n`allowedOrigins` (set via `POST /admin/projects` or\n`PATCH /admin/projects/{id}`). Matching rules:\n\n* **Normalised compare** — trailing slash, host case, and default ports are\n  ignored. `https://Foo.Com/` matches `https://foo.com`.\n* **Strict scheme + port** — `http` ≠ `https`, `:3000` ≠ `:3001`.\n* **www vs apex** — separate entries. List both if you need both.\n* **Wildcard subdomain** — `https://*.foo.com` matches `api.foo.com`,\n  `edge.api.foo.com`, etc. Does NOT match the apex `foo.com` — register\n  it separately if you want both.\n* **`*`** — matches every origin. Dev-only escape hatch; never set this\n  in prod.\n\nWhen matching fails the API returns `400` with the received value, the\nconfigured list, and the project slug — copy it into your bug tracker\nto spot mismatches without tailing logs.\n\n## Rate limits\n\nOutbound messages: **30 per minute per device**. Responses include\n`X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset`.\nExceeding the limit returns `429`.\n\n## Broadcasting (fan-out send)\n\nWhen you need to send the same message — or different personalised messages\n— to many recipients, **don't loop `/messages/send-text` yourself**. Use\n`POST /messages/broadcast` instead. Why:\n\n* Single HTTP call for N recipients (no client-side loop / timer to babysit).\n* Server-side throttling via Redis-backed delayed jobs — survives client\n  restarts / crashes.\n* Per-plan **delay floor** (`minBroadcastDelayMs`) guards the underlying\n  WhatsApp account against rate-based bans.\n* Quota for all N items is reserved up-front; cancelling a broadcast\n  refunds unsent items back to your monthly quota.\n\n### Delay control\n\nTwo body params control timing between recipients:\n\n| Field | Default | What it does |\n| --- | --- | --- |\n| `baseDelayMs` | `3000` | Minimum gap between consecutive sends (ms). |\n| `jitterMs` | `2000` | Random tail added per send: actual gap = `baseDelayMs + random(0, jitterMs)`. |\n\nExample — to space sends by **5–7 seconds** randomly:\n\n```json\n{ \"baseDelayMs\": 5000, \"jitterMs\": 2000 }\n```\n\nExample — to look human-like (bigger jitter masks the timing pattern):\n\n```json\n{ \"baseDelayMs\": 5000, \"jitterMs\": 10000 }\n```\n\n### Plan floors\n\nYou can always go **slower** than the floor (set a larger `baseDelayMs`).\nYou **cannot** go faster — the server silently clamps your value up.\n\n| Plan | `minBroadcastDelayMs` | `maxBroadcastSize` | `bulkSend` |\n| --- | --- | --- | --- |\n| FREE | 10 000 ms | 50 | no |\n| MICRO | 8 000 ms | 100 | no |\n| STARTER | 5 000 ms | 250 | no |\n| GROWTH | 3 000 ms | 1 000 | yes |\n| BUSINESS | 1 500 ms | 5 000 | yes |\n| ENTERPRISE | 1 000 ms | unlimited | yes |\n\nPlans without `bulkSend` get a `402 feature_locked` response from\n`/broadcast` — they can still call `/send-text` and `/send-media`\none-by-one. Calls beyond `maxBroadcastSize` get `402 broadcast_size_exceeded`.\n\n### Anti-ban (separate from `baseDelayMs`)\n\nOn top of the inter-message delay you control, **every send** also runs a\ntyping-presence simulation inside the worker before `sendMessage` fires:\n\n1. `presenceSubscribe` (random 150–450 ms)\n2. `composing` indicator — random 700–1700 ms (recipient sees \"typing…\")\n3. Actual `sendMessage`\n4. `paused` indicator\n\nThis is **always on** and not user-configurable. `baseDelayMs` /\n`jitterMs` control the gap *between* recipients; the typing simulation\ncontrols how each individual send looks to the recipient.\n\n### Cancelling\n\n`POST /messages/broadcast/{id}/cancel` removes pending BullMQ jobs and\nmarks remaining `PENDING` items as `CANCELLED`. Already-SENT items\n**cannot** be recalled — WhatsApp doesn't support that.\n\n### Polling vs webhooks\n\n`GET /messages/broadcast/{id}` returns aggregate counts (`pending`,\n`sent`, `failed`, `cancelled`) plus paginated per-recipient items.\nFor high-volume use, prefer subscribing to `message.status` webhooks —\neach item that fires sends a webhook with `status: SENT` or `FAILED`."},"servers":[{"url":"/","description":"Same-origin (when behind reverse proxy)"},{"url":"http://localhost:3001","description":"Local dev"}],"tags":[{"name":"Account","description":"Verify or rotate the API key"},{"name":"Devices","description":"WhatsApp device lifecycle"},{"name":"Messages","description":"Send + list messages"},{"name":"Webhooks","description":"Webhook subscriptions"},{"name":"Events","description":"Real-time SSE stream"},{"name":"Billing","description":"Plan info, broadcast limits, monthly quota"}],"components":{"securitySchemes":{"apiKey":{"type":"apiKey","in":"header","name":"X-API-Key","description":"Programmatic API key from /auth/me"}},"schemas":{"User":{"type":"object","properties":{"id":{"type":"string","example":"cmosh123abc"},"email":{"type":"string","format":"email"},"name":{"type":"string","nullable":true},"apiKey":{"type":"string","description":"64-char hex API key","example":"ffec40...90ef2fe2"}},"required":["id","email","apiKey"]},"Device":{"type":"object","properties":{"id":{"type":"string"},"userId":{"type":"string"},"name":{"type":"string"},"phoneNumber":{"type":"string","nullable":true},"status":{"type":"string","enum":["DISCONNECTED","CONNECTING","QR_READY","CONNECTED","LOGGED_OUT","BANNED"]},"workerId":{"type":"string","nullable":true},"lastSeen":{"type":"string","format":"date-time","nullable":true},"createdAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"}},"required":["id","userId","name","status","createdAt"]},"Message":{"type":"object","properties":{"id":{"type":"string"},"deviceId":{"type":"string"},"externalId":{"type":"string","nullable":true,"description":"WhatsApp message ID"},"direction":{"type":"string","enum":["INBOUND","OUTBOUND"]},"remoteJid":{"type":"string","example":"628111111111@s.whatsapp.net"},"type":{"type":"string","description":"Message type (text, image, video, audio, document)"},"content":{"type":"object","additionalProperties":true},"mediaUrl":{"type":"string","nullable":true},"mediaMime":{"type":"string","nullable":true},"caption":{"type":"string","nullable":true},"status":{"type":"string","enum":["PENDING","SENT","DELIVERED","READ","FAILED"]},"error":{"type":"string","nullable":true},"createdAt":{"type":"string","format":"date-time"}},"required":["id","deviceId","direction","remoteJid","status"]},"Webhook":{"type":"object","properties":{"id":{"type":"string"},"userId":{"type":"string"},"url":{"type":"string","format":"uri"},"secret":{"type":"string","description":"HMAC secret for signature verification"},"events":{"type":"array","items":{"type":"string","enum":["message.received","message.status","device.status"]}},"enabled":{"type":"boolean"},"createdAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"}},"required":["id","url","secret","events","enabled"]},"WebhookEnvelope":{"type":"object","description":"Outer envelope used for every webhook delivery body. The shape of `data` depends on `event`.","properties":{"event":{"type":"string","enum":["message.received","message.status","device.status"]},"timestamp":{"type":"integer","description":"Unix milliseconds when the event was emitted"},"data":{"type":"object","additionalProperties":true}},"required":["event","timestamp","data"]},"MessageReceivedData":{"type":"object","properties":{"deviceId":{"type":"string"},"messageId":{"type":"string","description":"Internal message id (matches /messages list)"},"externalId":{"type":"string","description":"WhatsApp's message id"},"remoteJid":{"type":"string","example":"628111222333@s.whatsapp.net"},"fromMe":{"type":"boolean"},"type":{"type":"string","description":"Message type. Common values: conversation (text), imageMessage, videoMessage, audioMessage, documentMessage, extendedTextMessage, stickerMessage. Format follows Baileys protobuf."},"content":{"type":"object","additionalProperties":true,"description":"Raw Baileys message protobuf as JSON. For conversation: { conversation: '...' }. For imageMessage: { imageMessage: { url, mimetype, caption, ... } }"},"timestamp":{"type":"integer","description":"Unix seconds (WhatsApp message timestamp)"}},"required":["deviceId","remoteJid","type"]},"MessageStatusData":{"type":"object","properties":{"deviceId":{"type":"string"},"externalId":{"type":"string"},"status":{"type":"string","enum":["PENDING","SENT","DELIVERED","READ","FAILED"]}},"required":["deviceId","externalId","status"]},"DeviceStatusData":{"type":"object","properties":{"deviceId":{"type":"string"},"status":{"type":"string","enum":["DISCONNECTED","CONNECTING","QR_READY","CONNECTED","LOGGED_OUT","BANNED"]},"phoneNumber":{"type":"string","description":"Only present when status=CONNECTED"},"workerId":{"type":"string"}},"required":["deviceId","status"]},"Error":{"type":"object","properties":{"error":{"type":"string","example":"unauthorized"}},"required":["error"]}}},"security":[{"apiKey":[]}],"paths":{"/auth/me":{"get":{"tags":["Account"],"summary":"Verify your API key + return your account info","description":"Useful as a programmatic ping to confirm an API key is valid.","responses":{"200":{"description":"Account info","content":{"application/json":{"schema":{"$ref":"#/components/schemas/User"}}}},"401":{"description":"Invalid or missing API key"}}}},"/auth/rotate-key":{"post":{"tags":["Account"],"summary":"Rotate API key (invalidates the old one)","description":"Returns a new API key. The old key stops working immediately — update your stored credentials before the next call.","responses":{"200":{"description":"New API key","content":{"application/json":{"schema":{"type":"object","properties":{"apiKey":{"type":"string"}}}}}}}}},"/devices":{"get":{"tags":["Devices"],"summary":"List devices for the authenticated user","responses":{"200":{"description":"Array of devices","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/Device"}}}}}}},"post":{"tags":["Devices"],"summary":"Create a new device","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["name"],"properties":{"name":{"type":"string","example":"WA Marketing"}}}}}},"responses":{"200":{"description":"Created device","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Device"}}}}}}},"/devices/{id}":{"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"get":{"tags":["Devices"],"summary":"Get a single device","responses":{"200":{"description":"Device","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Device"}}}},"404":{"description":"Not found"}}},"delete":{"tags":["Devices"],"summary":"Delete device + WA session","responses":{"200":{"description":"Deleted"},"404":{"description":"Not found"}}}},"/devices/{id}/connect":{"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"post":{"tags":["Devices"],"summary":"Start a WhatsApp session — emits QR via SSE","description":"Picks the worker with fewest sessions. After this, watch /devices/{id}/events for `qr` and `status` events.","responses":{"200":{"description":"Session init queued","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean"},"workerId":{"type":"string"}}}}}},"503":{"description":"No workers available"}}}},"/devices/{id}/logout":{"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"post":{"tags":["Devices"],"summary":"Disconnect WA session and clear stored credentials","responses":{"200":{"description":"Logged out"}}}},"/devices/{id}/qr":{"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"get":{"tags":["Devices"],"summary":"Latest QR code (raw string) — falsy if not in QR_READY state","responses":{"200":{"description":"QR data","content":{"application/json":{"schema":{"type":"object","properties":{"qr":{"type":"string","nullable":true}}}}}}}}},"/devices/{id}/events":{"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"get":{"tags":["Events"],"summary":"SSE stream of events for a device","description":"Server-Sent Events stream. Event types: `qr`, `status`, `message`. Use EventSource in browser or any SSE client.","responses":{"200":{"description":"text/event-stream","content":{"text/event-stream":{"schema":{"type":"string"},"example":"event: qr\ndata: {\"deviceId\":\"cmosh123\",\"qr\":\"2@xxx\"}\n\nevent: status\ndata: {\"deviceId\":\"cmosh123\",\"status\":\"CONNECTED\",\"phoneNumber\":\"628...\"}"}}}}}},"/messages/send-text":{"post":{"tags":["Messages"],"summary":"Send a plain text message","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["deviceId","to","text"],"properties":{"deviceId":{"type":"string"},"to":{"type":"string","description":"Phone number (628xxx) or full JID (628xxx@s.whatsapp.net)","example":"628111222333"},"text":{"type":"string","maxLength":4096}}},"example":{"deviceId":"cmosh123abc","to":"628111222333","text":"Halo dari WA Gateway!"}}}},"responses":{"200":{"description":"Queued for delivery","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string","description":"Internal message id"},"status":{"type":"string","example":"PENDING"}}}}}},"400":{"description":"Device not connected"},"429":{"description":"Rate limit exceeded (30/min/device)"}}}},"/messages/send-media":{"post":{"tags":["Messages"],"summary":"Send image / video / audio / document via URL","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["deviceId","to","kind","url"],"properties":{"deviceId":{"type":"string"},"to":{"type":"string"},"kind":{"type":"string","enum":["image","video","audio","document"]},"url":{"type":"string","format":"uri","description":"Public URL — worker fetches and uploads to WA"},"mime":{"type":"string","description":"Optional MIME hint"},"caption":{"type":"string","maxLength":1024,"description":"Ignored for audio"},"fileName":{"type":"string","description":"Required for document kind"}}},"example":{"deviceId":"cmosh123abc","to":"628111222333","kind":"image","url":"https://picsum.photos/600","caption":"Halo dengan gambar!"}}}},"responses":{"200":{"description":"Queued for delivery"},"400":{"description":"Device not connected"},"429":{"description":"Rate limit exceeded"}}}},"/messages/broadcast":{"post":{"tags":["Messages"],"summary":"Broadcast: send N personalised messages with a per-message delay","description":"Single call that fans out to many recipients. Use this instead of looping\n`/send-text` yourself — the gateway throttles each item via Redis-backed\ndelayed jobs, so:\n\n* You don't need to keep an open Node.js timer per recipient.\n* If your client crashes mid-broadcast, the queue keeps draining.\n* Per-plan delay floors protect the underlying WhatsApp account.\n\nThe actual delay between items is `baseDelayMs + random(0, jitterMs)`. The\nserver clamps `baseDelayMs` to your plan's minimum — you can always go\nslower than the floor, never faster.\n\nReturns immediately. Poll `GET /messages/broadcast/{id}` or subscribe to\n`message.status` webhooks for per-recipient outcomes.\n\n**Plan gating:** requires the `bulkSend` feature. Free plans get\n`/send-text` only.\n\n**Quota:** all N items are reserved up-front against your monthly quota.\nIf you cancel, unsent items are refunded.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["deviceId","items"],"properties":{"deviceId":{"type":"string"},"items":{"type":"array","minItems":1,"maxItems":10000,"items":{"oneOf":[{"type":"object","required":["type","to","text"],"properties":{"type":{"type":"string","enum":["text"]},"to":{"type":"string"},"text":{"type":"string","maxLength":4096}}},{"type":"object","required":["type","to","kind","url"],"properties":{"type":{"type":"string","enum":["media"]},"to":{"type":"string"},"kind":{"type":"string","enum":["image","video","audio","document"]},"url":{"type":"string","format":"uri"},"mime":{"type":"string"},"caption":{"type":"string","maxLength":1024},"fileName":{"type":"string","maxLength":255}}}]}},"baseDelayMs":{"type":"integer","minimum":0,"maximum":300000,"default":3000,"description":"Minimum gap between consecutive sends (ms). Server clamps to your plan floor — see the **Broadcasting → Plan floors** section in the API description for the full table. You can always go slower (larger value), never faster."},"jitterMs":{"type":"integer","minimum":0,"maximum":60000,"default":2000,"description":"Random tail added per send: actual gap = `baseDelayMs + random(0, jitterMs)`. Larger jitter mimics human timing better and is harder for WhatsApp's anti-spam to fingerprint as bot traffic."},"source":{"type":"string","maxLength":120,"description":"Free-form caller tag for ops debugging."}}},"examples":{"Iuran reminder fanout":{"value":{"deviceId":"dev_abc123","baseDelayMs":5000,"jitterMs":2000,"source":"aplikasi-rt:rt-001","items":[{"type":"text","to":"628111111111","text":"Halo Budi, ..."},{"type":"text","to":"628222222222","text":"Halo Siti, ..."}]}}}}}},"responses":{"201":{"description":"Broadcast accepted and scheduled","content":{"application/json":{"schema":{"type":"object","properties":{"broadcastId":{"type":"string"},"total":{"type":"integer"},"baseDelayMs":{"type":"integer"},"jitterMs":{"type":"integer"},"enqueueFailed":{"type":"integer"},"scheduledFinishAt":{"type":"string","format":"date-time","nullable":true},"messageIds":{"type":"array","items":{"type":"string"}}}}}}},"400":{"description":"Device not connected"},"402":{"description":"Plan does not allow bulkSend / media, or quota / size limit hit"},"404":{"description":"Device not found"},"429":{"description":"Rate limit exceeded"}}}},"/messages/broadcast/{id}":{"get":{"tags":["Messages"],"summary":"Broadcast status (aggregate + paginated per-item)","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"name":"limit","in":"query","schema":{"type":"integer","minimum":1,"maximum":500},"description":"Per-item page size (default 100, max 500)."},{"name":"cursor","in":"query","schema":{"type":"string"},"description":"Pass `nextCursor` from previous response."}],"responses":{"200":{"description":"Broadcast detail","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string"},"deviceId":{"type":"string"},"status":{"type":"string","enum":["PENDING","RUNNING","DONE","CANCELLED"]},"total":{"type":"integer"},"baseDelayMs":{"type":"integer"},"jitterMs":{"type":"integer"},"source":{"type":"string","nullable":true},"createdAt":{"type":"string","format":"date-time"},"finishedAt":{"type":"string","format":"date-time","nullable":true},"counts":{"type":"object","properties":{"pending":{"type":"integer"},"sent":{"type":"integer"},"failed":{"type":"integer"},"cancelled":{"type":"integer"}}},"items":{"type":"array","items":{"type":"object"}},"nextCursor":{"type":"string","nullable":true}}}}}},"404":{"description":"Broadcast not found"}}}},"/messages/broadcast/{id}/cancel":{"post":{"tags":["Messages"],"summary":"Cancel pending items in a running broadcast","description":"Removes still-pending BullMQ jobs and marks remaining `PENDING` Messages as\n`CANCELLED`. Already-SENT items can NOT be recalled — WhatsApp doesn't\nsupport that. Cancelled items are refunded back to your monthly quota.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Cancellation result","content":{"application/json":{"schema":{"type":"object","properties":{"broadcastId":{"type":"string"},"cancelled":{"type":"integer"},"removedJobs":{"type":"integer"}}}}}},"404":{"description":"Broadcast not found"},"409":{"description":"Broadcast already DONE or CANCELLED"}}}},"/messages":{"get":{"tags":["Messages"],"summary":"List recent messages (newest first)","parameters":[{"name":"deviceId","in":"query","schema":{"type":"string"},"description":"Filter by device"},{"name":"limit","in":"query","schema":{"type":"integer","minimum":1,"maximum":200},"description":"Default 50, max 200"}],"responses":{"200":{"description":"Array of messages","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/Message"}}}}}}}},"/webhooks":{"get":{"tags":["Webhooks"],"summary":"List webhooks for the authenticated user","responses":{"200":{"description":"Array of webhooks","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/Webhook"}}}}}}},"post":{"tags":["Webhooks"],"summary":"Register a new webhook","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["url","events"],"properties":{"url":{"type":"string","format":"uri"},"events":{"type":"array","items":{"type":"string","enum":["message.received","message.status","device.status"]},"minItems":1},"enabled":{"type":"boolean","default":true}}}}}},"responses":{"200":{"description":"Created webhook (includes generated secret)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Webhook"}}}}}}},"/webhooks/{id}":{"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"patch":{"tags":["Webhooks"],"summary":"Update a webhook (url / events / enabled)","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"url":{"type":"string","format":"uri"},"events":{"type":"array","items":{"type":"string","enum":["message.received","message.status","device.status"]},"minItems":1},"enabled":{"type":"boolean"}}}}}},"responses":{"200":{"description":"Updated"}}},"delete":{"tags":["Webhooks"],"summary":"Delete a webhook","responses":{"200":{"description":"Deleted"}}}},"/webhooks/{id}/rotate-secret":{"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"post":{"tags":["Webhooks"],"summary":"Rotate the HMAC secret for a webhook","responses":{"200":{"description":"Webhook with new secret","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Webhook"}}}}}}},"/health":{"get":{"tags":["Account"],"summary":"Health check (no auth required)","security":[],"responses":{"200":{"description":"Service alive","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean"},"ts":{"type":"integer"}}}}}}}}},"/billing/limits":{"get":{"tags":["Billing"],"summary":"Broadcast-focused limits + recommended delay preset","description":"Returns just the fields a client needs to render a broadcast UI:\n\n* `broadcast.minBroadcastDelayMs` — your plan's delay floor; use this as\n  the minimum on a delay slider.\n* `broadcast.maxBroadcastSize` — max items in one `/messages/broadcast`\n  call (`-1` = unlimited).\n* `broadcast.bulkSend` — whether `/broadcast` is unlocked at all on this\n  plan.\n* `broadcast.recommended` — opinionated `{ baseDelayMs, jitterMs }` you\n  can pass through unchanged if you don't want to ask the user.\n* `quota` — message budget left this month (`-1` = unlimited).\n\nFor the full plan + subscription detail, use `GET /billing/me`.","responses":{"200":{"description":"Limits view","content":{"application/json":{"schema":{"type":"object","properties":{"billingEnabled":{"type":"boolean"},"plan":{"type":"object","nullable":true,"properties":{"code":{"type":"string"},"name":{"type":"string"}}},"broadcast":{"type":"object","nullable":true,"properties":{"minBroadcastDelayMs":{"type":"integer"},"maxBroadcastSize":{"type":"integer"},"bulkSend":{"type":"boolean"},"media":{"type":"boolean"},"recommended":{"type":"object","properties":{"baseDelayMs":{"type":"integer"},"jitterMs":{"type":"integer"}}}}},"quota":{"type":"object","nullable":true,"properties":{"used":{"type":"integer"},"max":{"type":"integer"},"remaining":{"type":"integer"},"unlimited":{"type":"boolean"},"wibMonth":{"type":"string"}}}}},"examples":{"GROWTH plan, 245 of 10k used":{"value":{"billingEnabled":true,"plan":{"code":"GROWTH","name":"Growth"},"broadcast":{"minBroadcastDelayMs":3000,"maxBroadcastSize":1000,"bulkSend":true,"media":true,"recommended":{"baseDelayMs":3500,"jitterMs":3000}},"quota":{"used":245,"max":10000,"remaining":9755,"unlimited":false,"wibMonth":"2026-05"}}}}}}}}}}},"webhooks":{"message.received":{"post":{"summary":"Inbound WhatsApp message","description":"Sent when a WhatsApp message is received on a connected device. Delivered to every webhook URL whose `events` array contains `message.received`.","parameters":[{"name":"X-Webhook-Event","in":"header","required":true,"schema":{"type":"string","const":"message.received"}},{"name":"X-Webhook-Signature","in":"header","required":true,"description":"HMAC-SHA256(body, webhook.secret) as hex","schema":{"type":"string","example":"8a7d3f9b2e6c1..."}},{"name":"X-Webhook-Timestamp","in":"header","required":true,"schema":{"type":"integer","example":1746480000000}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/WebhookEnvelope"},{"type":"object","properties":{"event":{"const":"message.received"},"data":{"$ref":"#/components/schemas/MessageReceivedData"}}}]},"example":{"event":"message.received","timestamp":1746480000000,"data":{"deviceId":"cmosjwfds0003t6tqvr2k8viz","messageId":"cmosk1b2c0001abc","externalId":"3EB0AC7BFE5D4567890","remoteJid":"628111222333@s.whatsapp.net","fromMe":false,"type":"conversation","content":{"conversation":"Halo bro!"},"timestamp":1746479998}}}}},"responses":{"2XX":{"description":"Acknowledged. Any 2xx is treated as success — gateway will not retry."},"default":{"description":"Non-2xx triggers retry with exponential backoff (up to 5 attempts)."}}}},"message.status":{"post":{"summary":"Outbound message status update","description":"Status transition for a message you sent (PENDING → SENT → DELIVERED → READ, or → FAILED).","parameters":[{"name":"X-Webhook-Event","in":"header","required":true,"schema":{"type":"string","const":"message.status"}},{"name":"X-Webhook-Signature","in":"header","required":true,"schema":{"type":"string"}},{"name":"X-Webhook-Timestamp","in":"header","required":true,"schema":{"type":"integer"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/WebhookEnvelope"},{"type":"object","properties":{"event":{"const":"message.status"},"data":{"$ref":"#/components/schemas/MessageStatusData"}}}]},"example":{"event":"message.status","timestamp":1746480001000,"data":{"deviceId":"cmosjwfds0003t6tqvr2k8viz","externalId":"3EB0AC7BFE5D4567890","status":"DELIVERED"}}}}},"responses":{"2XX":{"description":"Acknowledged"},"default":{"description":"Triggers retry"}}}},"device.status":{"post":{"summary":"Device connection state change","description":"Sent when a device transitions between states (connecting, scanning QR, connected, banned, etc).","parameters":[{"name":"X-Webhook-Event","in":"header","required":true,"schema":{"type":"string","const":"device.status"}},{"name":"X-Webhook-Signature","in":"header","required":true,"schema":{"type":"string"}},{"name":"X-Webhook-Timestamp","in":"header","required":true,"schema":{"type":"integer"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/WebhookEnvelope"},{"type":"object","properties":{"event":{"const":"device.status"},"data":{"$ref":"#/components/schemas/DeviceStatusData"}}}]},"example":{"event":"device.status","timestamp":1746479900000,"data":{"deviceId":"cmosjwfds0003t6tqvr2k8viz","status":"CONNECTED","phoneNumber":"628111222333","workerId":"worker-1"}}}}},"responses":{"2XX":{"description":"Acknowledged"},"default":{"description":"Triggers retry"}}}}}}