@funnelchat-hq/sandbox 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Funnelchat
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,550 @@
1
- # Temporary Holding Version
1
+ # @funnelchat-hq/sandbox
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A local server that imitates the Funnelchat public API and sends signed webhooks, with no real effects. Use it to build and test your integration without a real account, without WhatsApp and without spending anything: nothing goes out to WhatsApp, everything stays in memory.
4
+
5
+ - The same routes, bodies and errors as the API in production (accounts v1.4.24, conversations v1.18.10, communities v2.34.52 and the API edge v0.3.0, since 21-10).
6
+ - In-memory state for a real flow: contacts, conversations and messages, tags, custom fields, templates, WhatsApp forms and webhook subscriptions.
7
+ - A send answers at once with the queued message, and then announces `sent`, `delivered` and `read` through `message.status_changed`.
8
+ - Webhooks signed with the portal's exact algorithm, with its retries, and the event catalog of production.
9
+ - The WhatsApp rules the API applies: the 24-hour window on official numbers, the per-number pace, the spam brakes and the MAC lockout.
10
+ - `X-Request-Id` on every response, like the API.
11
+
12
+ > **Status: v0.** The public API is in v0 and may change. What the sandbox does not imitate is in [What it does not imitate](#what-it-does-not-imitate).
13
+
14
+ **About the language.** This documentation, the command line, the panel and the sandbox's own routes (`/_sandbox`) are in English. The answers under `/api/v1` imitate the API, which answers in Spanish, so their messages are in Spanish, word for word where they come from the API's code. Decide by the HTTP status and by `error`, never by `message`.
15
+
16
+ ## Starting it
17
+
18
+ You need Node 20.11 or later.
19
+
20
+ ```bash
21
+ npx @funnelchat-hq/sandbox
22
+ ```
23
+
24
+ Or, in your project, as a development dependency (the command it installs is `funnelchat-sandbox`):
25
+
26
+ ```bash
27
+ npm install --save-dev @funnelchat-hq/sandbox
28
+ npx funnelchat-sandbox
29
+ ```
30
+
31
+ ```
32
+ funnelchat-sandbox 0.1.0 at http://127.0.0.1:4010
33
+ API: http://127.0.0.1:4010/api/v1/... (Authorization: Bearer 1|fc_live_sandbox, or any well-formed key)
34
+ panel: http://127.0.0.1:4010/_sandbox
35
+ example numbers: 3 (official, with the 24-hour window) and 4 (QR, no window); contact 2001; template 45
36
+ ```
37
+
38
+ **From the source:**
39
+
40
+ ```bash
41
+ git clone https://github.com/Funnelchat20/funnelchat-sandbox.git
42
+ cd funnelchat-sandbox
43
+ npm install
44
+ npm run build
45
+ npx funnelchat-sandbox
46
+ ```
47
+
48
+ **With Docker** (from the source):
49
+
50
+ ```bash
51
+ docker build -t funnelchat-sandbox .
52
+ docker run --rm -p 4010:4010 funnelchat-sandbox
53
+ # with your configuration:
54
+ docker run --rm -p 4010:4010 -v "$PWD/funnelchat-sandbox.config.json:/app/funnelchat-sandbox.config.json:ro" funnelchat-sandbox
55
+ ```
56
+
57
+ Inside the container it listens on `0.0.0.0`. If your webhook receiver runs on your machine, from the container it is `http://host.docker.internal:<port>` (on Linux, add `--add-host=host.docker.internal:host-gateway`).
58
+
59
+ **From your tests**, with no other process:
60
+
61
+ ```ts
62
+ import { createSandbox } from '@funnelchat-hq/sandbox';
63
+
64
+ const sandbox = createSandbox({ config: { port: 0, log: false, statusDelays: { sent: 0.05, delivered: 0.05, read: 0.05 } } });
65
+ const { url } = await sandbox.listen(); // port 0: a free port
66
+ // … point your SDK at `url` …
67
+ await sandbox.close();
68
+ ```
69
+
70
+ ### Options
71
+
72
+ | Option | What it does | Default |
73
+ |---|---|---|
74
+ | `-p`, `--port <n>` | The port. `0` takes a free one. | `4010` |
75
+ | `--host <host>` | Where it listens. | `127.0.0.1` (in Docker, `0.0.0.0`) |
76
+ | `-c`, `--config <file>` | A JSON configuration file. | `./funnelchat-sandbox.config.json`, if it exists |
77
+ | `--status-delays <s,d,r>` | Seconds until `sent`, from there to `delivered` and from there to `read`. A `-` stops there (for example `1,2,-`: never read). | `1,2,3` |
78
+ | `--retry-delays <a,b,c>` | Seconds between the attempts of a webhook delivery. | `60,300,1800`, like the portal |
79
+ | `--webhook-timeout <s>` | How long it waits for your receiver's answer. | `10` |
80
+ | `--require-https` | Only accept `https` subscriptions, like the real API. | off |
81
+ | `--no-window` | Do not apply the 24-hour window. | — |
82
+ | `--no-antiblock` | Do not apply the spam brakes. | — |
83
+ | `--no-rate-limit` | Do not apply the per-number pace. | — |
84
+ | `--template-approval <s>` | Seconds until a template created through the API is approved. A `-` leaves it pending. | `5` |
85
+ | `-q`, `--quiet` | No line per request and per delivery on the console. | — |
86
+
87
+ Options win over the file. The file has the same options and, besides, subscriptions created at start (and after every reset) with a fixed secret, so your receiver keeps working when you restart the sandbox:
88
+
89
+ ```json
90
+ {
91
+ "statusDelays": { "sent": 1, "delivered": 2, "read": 3 },
92
+ "webhooks": { "retryDelays": [5, 15, 30], "timeoutSeconds": 10 },
93
+ "rules": { "window": true, "antiblock": true, "rateLimit": true },
94
+ "subscriptions": [
95
+ {
96
+ "url": "http://localhost:3000/hooks/funnelchat",
97
+ "events": ["message.received", "message.status_changed", "contact.created"],
98
+ "secret": "whsec_desarrolloLocalNoUsarEnProduccion"
99
+ }
100
+ ]
101
+ }
102
+ ```
103
+
104
+ There is a complete one in [funnelchat-sandbox.config.example.json](funnelchat-sandbox.config.example.json). The file `funnelchat-sandbox.config.json` is in `.gitignore`: do not commit a secret, even a test one.
105
+
106
+ ## The key
107
+
108
+ Any key with the shape of a real one works: `Authorization: Bearer <id>|fc_live_<letters and digits>`, for example `1|fc_live_sandbox`. Without a key, or with a malformed one, it answers `401` like the API:
109
+
110
+ ```json
111
+ { "error": "unauthenticated", "message": "Falta la API key o no es válida." }
112
+ ```
113
+
114
+ Every key sees the same account and has every permission.
115
+
116
+ **Every response carries `X-Request-Id`**, like the API: a different UUID v4 per request, also on errors (`401`, `404 route_not_found`, `422`…) and on the `/_sandbox` routes. It is only in the header, never in the body, and one you send in the request is ignored. The panel shows it next to each response, so you can quote it as you would quote the API's.
117
+
118
+ ## Pointing the SDKs at the sandbox
119
+
120
+ Change only the base URL. Everything else is the same as against the real API.
121
+
122
+ **Node** ([funnelchat-node](https://github.com/Funnelchat20/funnelchat-node)):
123
+
124
+ ```ts
125
+ import { Funnelchat } from '@funnelchat-hq/sdk';
126
+
127
+ const funnelchat = new Funnelchat({ apiKey: '1|fc_live_sandbox', baseUrl: 'http://localhost:4010' });
128
+ ```
129
+
130
+ **PHP** ([funnelchat-php](https://github.com/Funnelchat20/funnelchat-php)):
131
+
132
+ ```php
133
+ $funnelchat = new \Funnelchat\Funnelchat(apiKey: '1|fc_live_sandbox', baseUrl: 'http://localhost:4010');
134
+ ```
135
+
136
+ **The n8n node** ([n8n-nodes-funnelchat](https://github.com/Funnelchat20/n8n-nodes-funnelchat), `@funnelchat-hq/n8n-nodes-funnelchat` on npm): in the credential, the custom environment with the base URL `http://localhost:4010` (or `http://host.docker.internal:4010` if n8n runs in Docker and the sandbox on your machine). The trigger creates its subscription with `POST /api/v1/webhooks`, which the sandbox accepts over `http`. Since n8n sends no headers of its own, arm the failures beforehand with [`POST /_sandbox/failures`](#forcing-failures).
137
+
138
+ ## What is inside
139
+
140
+ Every session starts with this:
141
+
142
+ | What | Id | What for |
143
+ |---|---|---|
144
+ | Official number (WhatsApp Cloud API) | `3` | It has the 24-hour window and the pace, and it is the only one that takes forms. `provider: 2`. |
145
+ | QR number | `4` | No window and no pace. `provider: 1`. |
146
+ | Contact «Contacto de ejemplo» | `2001` | Phone `5491100000000`. |
147
+ | Tag «Cliente» | `4001` | |
148
+ | Template `bienvenida`, approved, of number 3 | `45` | One variable: «Hola {{1}}, gracias por escribirnos…». |
149
+
150
+ `POST /_sandbox/reset` goes back to this and cancels everything that was scheduled.
151
+
152
+ **With state** (what you create, change or delete stays): contacts (`/api/v1/contacts…`, with their tags, fields and the `meta-window`), conversations (`/api/v1/conversations…`), messages (send, history, reactions, forward, cards and the lookup by id), tags, custom fields, templates, [WhatsApp forms](#whatsapp-forms), numbers (`GET /api/v1/devices`) and webhook subscriptions with their history.
153
+
154
+ **The numbers** are those of accounts: `GET /api/v1/devices` always paginates (`{data, links, meta}`, `perPage` from 1 to 100), and each number carries `capabilities`, with what the API can do through it:
155
+
156
+ ```json
157
+ {
158
+ "messages": { "text": true, "media": true, "buttons": true, "lists": true, "templates": true, "location": true, "voice_note": true, "polls": false, "events": false, "contact_card": true, "reactions": false },
159
+ "groups": { "create": false, "add_participants": false, "remove_participants": false, "admins": false, "photo": false, "settings": false, "invite_link": false, "send": false, "join_requests": false },
160
+ "statuses": ["sent", "delivered", "read", "failed"]
161
+ }
162
+ ```
163
+
164
+ The official number (`3`) has no groups (the sandbox account is not in the official groups beta); the QR one (`4`) does, and has no templates. `health` is `null` on the QR number.
165
+
166
+ **The group actions by API** (create a group, participants, admins, name, description, photo, settings, invite link, sending to the group and the join requests) exist in communities but are off in production: with any key they answer `403 route_not_available`, as they do here. So do polls and events (`/message/poll`, `/message/event`).
167
+
168
+ ```json
169
+ { "error": "route_not_available", "message": "Esta ruta no está disponible para API keys en el piloto. No es un permiso que falte: ninguna key la habilita." }
170
+ ```
171
+
172
+ **Everything else** in the spec (broadcasts, flows, kanban, groups, campaigns, alerts…) answers an example built from the response schema: the spec's operations carry no examples of their own. The key, the route, the query and the body are still validated.
173
+
174
+ **Validation** follows the spec and reads values like Laravel (`"5"` works as an integer, `"1"` as a boolean). A validation error is a `422` with Laravel's messages per field; a route that does not exist, `404 route_not_found`:
175
+
176
+ ```json
177
+ {
178
+ "error": "validation_failed",
179
+ "message": "El campo device_id es obligatorio. (y 1 error más)",
180
+ "errors": {
181
+ "device_id": ["El campo device_id es obligatorio."],
182
+ "contact_id": ["El campo contact_id es obligatorio."]
183
+ }
184
+ }
185
+ ```
186
+
187
+ **What does not exist in the account** answers like each domain: in conversations (contacts, messages, tags, fields, forms) `404 {"message": "Objeto no encontrado."}`, without `error`; in accounts (numbers) `404 {"message": "Object not found"}`.
188
+
189
+ The complete spec the sandbox uses is at `GET /_sandbox/openapi.json`.
190
+
191
+ ## Sending a message
192
+
193
+ ```bash
194
+ curl -s http://localhost:4010/api/v1/contacts/2001/message \
195
+ -H 'Authorization: Bearer 1|fc_live_sandbox' -H 'Content-Type: application/json' \
196
+ -d '{"device_id": 4, "contact_id": 2001, "phone": "5491100000000", "text": "¡Hola!", "client_reference": "pedido-8841"}'
197
+ ```
198
+
199
+ ```json
200
+ {
201
+ "message": {
202
+ "id": 9001,
203
+ "contact_id": 2001,
204
+ "device_id": 4,
205
+ "direction": "outbound",
206
+ "type": "text",
207
+ "status": "queued",
208
+ "error": null,
209
+ "client_reference": "pedido-8841",
210
+ "created_at": "2026-10-22T12:00:00Z",
211
+ "updated_at": "2026-10-22T12:00:00Z"
212
+ }
213
+ }
214
+ ```
215
+
216
+ - **Queued is not sent.** Then, at 1, 3 and 6 seconds (configurable), the message moves to `sent`, `delivered` and `read`, and every change arrives through `message.status_changed` with the same `message_id`, `previous_status` (`null` on the first one) and your `client_reference`. `queued` never appears in the event.
217
+ - The current status is read with `GET /api/v1/contacts/{contact}/messages/{message}` (the same object) and in the history `GET /api/v1/contacts/{contactId}/messages`.
218
+ - The same goes for `POST /api/v1/message/send-message` (`{message}`), templates (`POST /api/v1/devices/{device}/templates/{template}/send-template`, `{message}`), forms (`POST /api/v1/contacts/{contact}/message/form`, `{message}`), cards (`{messages: [...]}`, one per card) and the forward (one item per destination, with its `message`, or `status: "blocked"` and `message: null`).
219
+ - `client_reference` is optional: 1 to 64 visible ASCII characters, no spaces. If it does not fit, `422 errors.client_reference`.
220
+ - A note (`"note": true`) is stored with `status: null` and is not sent.
221
+ - The `id` of each event is the same for the same message and the same status: deduplicate by `id`.
222
+
223
+ ### A file by URL
224
+
225
+ On `POST /contacts/{contact}/message` and `POST /message/send-message`, `file` is an `http(s)` URL (also signed, or without an extension) or a file name. The type comes from the extension, as in conversations: `jpg`, `jpeg` and `png` are `image`; `webp`, `sticker`; `gif` and `mp4`, `video`; `ogg`, `oga`, `mp3`, `aac`, `m4a`, `opus` and `wav`, `audio`; `pdf`, `doc` and `docx`, `document`. It looks at the extension of the URL's path and, if that one does not work, at the one of the whole URL (a signed URL that ends in `.pdf`).
226
+
227
+ | Case | Answer |
228
+ |---|---|
229
+ | The URL does not say its format and you sent no `file_type` | `422 errors.file_type`: «Tu archivo viene de una URL que no indica su formato: envía file_type con uno de estos valores: image, video, audio, document o sticker.» |
230
+ | `file_type: "document"`, the URL does not end in an accepted extension and `file_name` with an extension is missing | `422 errors.file_name`: «Para enviar un documento desde una URL que no termina en una de las extensiones admitidas, envía file_name con su extensión, por ejemplo factura.pdf.» |
231
+ | `file` malformed or longer than 4096 characters | `422 errors.file` |
232
+ | A known but not accepted extension (`.zip`) without `file_type` | `415`: `{"error": "Unsupported file type: zip", "supported_file_types": [...]}`; on `send-message`, `{"status": "error", "error": "Unsupported file type: zip"}` |
233
+
234
+ `file_type` (`image`, `video`, `audio`, `document` or `sticker`) always wins over the extension. The sandbox does not download the file.
235
+
236
+ ### The rules
237
+
238
+ They apply to every send with a key, in this order, as in the API. The first one that refuses wins; nothing is stored and nothing is sent.
239
+
240
+ | Rule | Answer | When |
241
+ |---|---|---|
242
+ | Number without connection | `422 device_unavailable` | After `POST /_sandbox/devices/{id}/disconnect`. |
243
+ | Recipient | `422 recipient_mismatch` | On `contacts/{contact}/message` and on templates: the body's `phone` or `whatsapp_id` is not the one of the route's contact. |
244
+ | Recipient | `422 recipient_unsupported` | The contact is a group, a list or a channel (`whatsapp_id` with `@g.us`, `@broadcast`…). |
245
+ | Pace | `429 rate_limited` with `Retry-After` | Official number: more than 60 messages per second (`Retry-After: 1`), or more than 10 to the same contact in the same clock minute (`Retry-After`: what is left of the minute). A QR number has no limit. |
246
+ | 24-hour window | `422 window_closed` | Official number, free text (not a template) and the contact did not write in the last 24 hours. |
247
+ | Spam brakes | `422 antiblock_duplicate_content` | The third send of the same content to a contact who did not answer. |
248
+ | Spam brakes | `422 antiblock_unanswered_limit` | The sixth send in a row to a contact who did not answer. |
249
+ | MAC | `429 mac_lockout_24h`, without `Retry-After` | The account is in the 24-hour lockout and the contact is not active in the cycle, or the contact is inactive by MAC (`POST /_sandbox/mac`). Templates do not have this brake; in a forward, that destination comes back `blocked` with `reason: "mac_lockout_24h"`. |
250
+
251
+ An incoming message from the contact opens the window of that number and resets the brakes. The error messages are the API's. To turn a rule off: `--no-window`, `--no-antiblock`, `--no-rate-limit`.
252
+
253
+ Some routes have bodies of their own, as in conversations: `send-message` answers `404 {"status": "error", "error": "Contacto no encontrado."}` and `403 {"status": "error", "error": "No autorizado"}`; a template that does not exist is `404 template_not_found`, one of another number `422 {"error": "La plantilla no pertenece al número actual", "error_code": "template_waba_mismatch"}`, and one with unfilled variables `422 template_unresolved_placeholder` with `placeholders` and `count`.
254
+
255
+ ### Forcing failures
256
+
257
+ **Make the send answer an error** (`reject`), with any code of the table above, `not_sent`, `recipient_unreachable` or `mac_limit_reached`:
258
+
259
+ ```bash
260
+ curl … -H 'X-Sandbox-Reject: rate_limited' # 429 with Retry-After: 6
261
+ ```
262
+
263
+ **Make the send be accepted and end `failed`** (`fail`):
264
+
265
+ | Value | What arrives |
266
+ |---|---|
267
+ | A reason of Funnelchat: `window_closed`, `insufficient_balance`, `device_unavailable`, `recipient_unreachable`, `template_not_found`, `template_unresolved_placeholder`, `not_queued`, `not_sent` | A single `failed` with `previous_status: null` and `error: {code: null, title: <the API's text>, reason: <reason>}`. |
268
+ | A code of Meta, for example `131047`, `131026`, `131049` | `sent` and then `failed` with `error: {code: 131047, title: "Re-engagement message", reason: null}`. |
269
+
270
+ ```bash
271
+ curl … -H 'X-Sandbox-Fail: window_closed'
272
+ ```
273
+
274
+ **Make the API edge fail** (`edge`), on any `/api/v1` route:
275
+
276
+ | `X-Sandbox-Edge` | What happens |
277
+ |---|---|
278
+ | `origin_unreachable` | `502 {"error": "origin_unreachable", "message": "La API no está disponible en este momento."}`. The request never arrived: nothing was applied. |
279
+ | `origin_unreachable_applied` | The same `502`, but the write **was** applied and only the answer was lost. This is how you test the portal's rule: a `502` on a write is treated as a timeout, and the state is read before repeating it. |
280
+ | `edge_misconfigured` | `500 {"error": "edge_misconfigured", "message": "La API no está disponible en este momento."}` |
281
+
282
+ None of them carries `Retry-After`.
283
+
284
+ If you cannot send headers (from n8n, for example), arm the next failures beforehand:
285
+
286
+ ```bash
287
+ curl -s http://localhost:4010/_sandbox/failures -H 'Content-Type: application/json' \
288
+ -d '{"mode": "fail", "code": "131047", "count": 1, "contact_id": 2001}'
289
+ curl -s http://localhost:4010/_sandbox/failures -H 'Content-Type: application/json' \
290
+ -d '{"mode": "edge", "code": "origin_unreachable", "applied": true, "count": 1}'
291
+ ```
292
+
293
+ `GET /_sandbox/failures` lists the armed ones, `GET /_sandbox/failures/codes` the codes each mode takes and `DELETE /_sandbox/failures` deletes them. You can also move a message by hand: `POST /_sandbox/messages/{id}/status` with `{"status": "delivered"}` (or `"failed"` with `"error": "131026"`); it never goes back.
294
+
295
+ ## WhatsApp forms
296
+
297
+ The routes of conversations, with state. A form is a family (`family_id`, the one you send) with versions (`id`, the one you edit). Only official numbers take them: with the QR one, `422 NUMBER_NOT_ELIGIBLE`.
298
+
299
+ | Route | What it does |
300
+ |---|---|
301
+ | `GET /api/v1/forms/templates` | The five templates to start from: `blank`, `lead`, `appointment`, `nps`, `signup`. |
302
+ | `POST /api/v1/forms` | `{"device_id": 3, "template_key": "lead"}` (optional `device_ids`): a draft. `201` with `data`, `lint` and `numbers`. |
303
+ | `GET /api/v1/forms`, `GET /api/v1/forms/{id}` | The list (one row per form, with `sends` and `responses`) and one version with `lint`, `versions` and `numbers`. |
304
+ | `PUT /api/v1/forms/{id}` | Saves `definition` (and, optionally, `name`). Published: `409 PUBLISHED`. |
305
+ | `DELETE /api/v1/forms/{id}` | A draft (`204`). Published: `409 PUBLISHED`. |
306
+ | `POST /api/v1/forms/{id}/sync`, `…/publish` | Runs the linter and publishes. With errors, `422 LINT` with the list. The other numbers go through `publishing` and end `published`. |
307
+ | `POST /api/v1/forms/{id}/versions` | A new draft version (`201`), or `200` with `created: false` if there was one already. |
308
+ | `GET`, `POST /api/v1/forms/{id}/numbers`, `DELETE …/numbers/{deviceId}`, `POST …/numbers/retry` | The numbers of the form. The primary one cannot be removed: `422 PRIMARY_NUMBER`. |
309
+ | `GET /api/v1/forms/families/{familyId}/definition`, `…/versions`, `…/responses`, `…/responses/{runId}` | The current definition, the versions and the responses. |
310
+ | `POST /api/v1/contacts/{contact}/message/form` | `{"form_id": <family_id>, "device_id": 3}` (optional `text`, `button_text`, `client_reference`): `200` with the queued message, `type: "whatsapp_form"`. |
311
+
312
+ The send follows the order of conversations: `device_unavailable`, `404 form_not_found`, `422 number_not_eligible`, `422 form_not_published`, `422 form_not_on_number`, `422 recipient_unreachable`, `422 window_closed`, and then the pace, the brakes and the MAC. An incoming message opens the window.
313
+
314
+ **Make the contact complete it:**
315
+
316
+ ```bash
317
+ curl -s http://localhost:4010/_sandbox/forms/complete -H 'Content-Type: application/json' \
318
+ -d '{"message_id": 9002, "answers": {"nombre": "Ana", "volumen": "op_mas_de_quinientos"}}'
319
+ ```
320
+
321
+ It emits `whatsapp_form.completed` once per send, with `form` (`id` is the `family_id`, `version_id` the version sent), `message_id`, `contact_id`, `device_id` and `answers` (`{key, label, value}`, one per question, in order). What you do not send in `answers` takes an example value; options go by their id. `run_id` also works, or `contact_id` (the last pending send to that contact).
322
+
323
+ ## Webhooks
324
+
325
+ You subscribe with the same routes the SDKs and the n8n trigger use (the unified subscriptions of accounts):
326
+
327
+ | Route | What it does |
328
+ |---|---|
329
+ | `POST /api/v1/webhooks` | Creates a subscription. The answer (`201`) carries `secret` **only once**. |
330
+ | `GET /api/v1/webhooks`, `GET /api/v1/webhooks/{id}` | Lists or shows them, without the secret. |
331
+ | `PUT /api/v1/webhooks/{id}` | Changes `url`, `events`, `description`, `filters` and, optionally, `status` (`active` or `paused`). The secret does not change. |
332
+ | `DELETE /api/v1/webhooks/{id}` | Deletes it (`204`). |
333
+ | `POST /api/v1/webhooks/{id}/rotate-secret` | A new secret; the previous one stops working. |
334
+ | `POST /api/v1/webhooks/{id}/test` | Sends a signed `webhook.test` now and answers `{succeeded, response_status, error, duration_ms, delivery_id}`. |
335
+ | `GET /api/v1/webhooks/{id}/deliveries` | The history: one record per attempt, newest first. |
336
+ | `GET /api/v1/webhooks/events` | The catalog of accounts in production: 54 types (49 `active`), with `area`, `status`, `is_critical`, `label`, `description` and `example`. |
337
+
338
+ **Each delivery** is a `POST` with the portal's envelope and these headers:
339
+
340
+ ```
341
+ Content-Type: application/json
342
+ X-Funnelchat-Signature: t=1792152000,v1=5f0c…
343
+ X-Funnelchat-Event: message.received
344
+ X-Funnelchat-Delivery: 7c9e6679-7425-40de-944b-e07fc1f90ae7
345
+ ```
346
+
347
+ ```json
348
+ {
349
+ "id": "evt_6f1c2a9b0d3e4f5a6b7c8d9e",
350
+ "type": "message.received",
351
+ "version": 1,
352
+ "occurred_at": "2026-10-22T12:00:00Z",
353
+ "data": {
354
+ "message": { "id": 9002, "type": "text", "text": "Hola, quiero más información", "media": null },
355
+ "contact": { "id": 2001, "name": "Contacto de ejemplo", "phone": "+5491100000000" },
356
+ "receiver": { "phone": "+5491100000001" },
357
+ "device_id": 3
358
+ }
359
+ }
360
+ ```
361
+
362
+ - **The signature** is the portal's: `v1` is the HMAC-SHA256, with the full secret (with `whsec_`), of `"<t>." + raw body`. Verify it with `Funnelchat.webhooks.verify` (Node), `Webhook::verify` (PHP) or the portal's snippets. The sandbox's tests verify it with the Node SDK's `verify` and with the portal's `verificar-firma.js`, on the vectors of `tests/test_firmas.py`.
363
+ - **The retries** are the portal's: up to 4 attempts, waiting 60 s, 300 s and 1800 s (shorten them with `--retry-delays`). A network error, a timeout (10 s), a `5xx` or a `429` is retried; any other `4xx` or a `3xx` is final. Every attempt carries the same body and the same `id`, a new signature and another `X-Funnelchat-Delivery`.
364
+ - **Five failed deliveries in a row** disable the subscription (`status: disabled`, `disabled_reason: too_many_failures`), as in accounts. A `PUT` with `"status": "active"` turns it back on.
365
+ - The sandbox accepts `http` and local URLs, so you can point at your machine. The real API only accepts public `https`: try it with `--require-https`.
366
+ - **The events the sandbox emits on its own** are those of its state: `message.received`, `message.status_changed`, `whatsapp_form.completed`, `contact.*`, `conversation.*`, `template.status_changed`, `device.*` and the ones you fire from `/_sandbox`. Any other type of the catalog is emitted with `POST /_sandbox/events/{type}`.
367
+
368
+ ## Simulating what comes in
369
+
370
+ These routes take no key: they belong to the sandbox, not to the API.
371
+
372
+ | Route | What it simulates |
373
+ |---|---|
374
+ | `POST /_sandbox/inbound/message` | A contact writes to you: `{"phone": "5491100000000", "device_id": 3, "text": "Hola"}` (or `contact_id`; optional `name`, `type`, `file` and `occurred_at`). If the phone is new it creates the contact (`contact.created`); then it emits `message.received` and opens the 24-hour window of that number. |
375
+ | `POST /_sandbox/groups/participant-joined`, `…/participant-left` | Someone joins or leaves a group: `{"group": {"uid": "…@g.us", "name": "…"}, "participant": {"phone": "…", "name": "…"}}`. |
376
+ | `POST /_sandbox/forms/complete` | The contact completes a sent form: emits `whatsapp_form.completed` (see [WhatsApp forms](#whatsapp-forms)). |
377
+ | `POST /_sandbox/mac`, `GET /_sandbox/mac` | The account's MAC: `{"lockout": true, "active_contact_ids": [2001], "inactive_contact_ids": []}`. With `lockout`, sends to contacts that are not active answer `429 mac_lockout_24h`; an inactive contact always gets it. |
378
+ | `POST /_sandbox/contacts/{id}/tags` with `{"tag_id": 4001}`, `DELETE /_sandbox/contacts/{id}/tags/{tag}` | Someone tags the contact in the panel: `contact.tag_added` and `contact.tag_removed`. |
379
+ | `POST /_sandbox/devices/{id}/disconnect`, `…/connect` | The number disconnects or connects: `device.disconnected` and `device.connected`. Disconnected, sends answer `422 device_unavailable`. |
380
+ | `POST /_sandbox/templates/{id}/status` | Meta approves or rejects a template: `{"status": "REJECTED", "reason": "…"}` emits `template.status_changed`. |
381
+ | `POST /_sandbox/events/{type}` | Any event of the catalog, with its example; what you send in `{"data": {…}}` overrides it. `GET /_sandbox/events` lists them. `group.message_received` and `group.reaction_added` go out with the shape communities emits (`message.uid` and every key of the message), not with the example of the accounts catalog, which uses `message.id`. |
382
+ | `POST /_sandbox/window` | Opens or closes the window by hand: `{"contact_id": 2001, "device_id": 3, "open": false}`. |
383
+ | `POST /_sandbox/reset` | Back to the initial state. |
384
+ | `GET /_sandbox/state`, `/_sandbox/requests`, `/_sandbox/deliveries` | The state, the last requests and the last deliveries with what your server answered. |
385
+
386
+ Besides, as in the API: creating a contact emits `contact.created`, closing a conversation (`active_conversation: false`) emits `conversation.closed`, assigning it emits `conversation.assigned`, and a template created through the API goes from `PENDING` to `APPROVED` after 5 seconds with `template.status_changed`.
387
+
388
+ **The panel** is at `http://localhost:4010/_sandbox`: the last requests, the webhook deliveries with your server's answer, and a form to send an incoming message.
389
+
390
+ ## End to end
391
+
392
+ An Express receiver that verifies the signature with the SDK, a subscription, an incoming message and the answer with its statuses.
393
+
394
+ ```bash
395
+ npx @funnelchat-hq/sandbox --status-delays 1,1,1
396
+ ```
397
+
398
+ ```ts
399
+ // receiver.ts
400
+ import express from 'express';
401
+ import { Funnelchat, WebhookVerificationError } from '@funnelchat-hq/sdk';
402
+
403
+ const funnelchat = new Funnelchat({ apiKey: '1|fc_live_sandbox', baseUrl: 'http://localhost:4010' });
404
+ const app = express();
405
+ let secret = '';
406
+
407
+ app.post('/hooks/funnelchat', express.raw({ type: 'application/json' }), async (req, res) => {
408
+ let event;
409
+ try {
410
+ event = Funnelchat.webhooks.constructEvent(req.body, req.get('X-Funnelchat-Signature'), secret);
411
+ } catch (error) {
412
+ if (error instanceof WebhookVerificationError) return res.sendStatus(400);
413
+ throw error;
414
+ }
415
+ res.sendStatus(200);
416
+
417
+ if (event.type === 'message.received') {
418
+ // Answer through the same number: on the official one, the window is already open.
419
+ const { message } = await funnelchat.messages.send(event.data.contact.id, {
420
+ device_id: event.data.device_id!,
421
+ phone: event.data.contact.phone!.replace('+', ''),
422
+ text: '¡Gracias por escribirnos!',
423
+ client_reference: `respuesta-${event.data.message.id}`,
424
+ });
425
+ console.log('sent', message.id, message.status); // queued
426
+ }
427
+ if (event.type === 'message.status_changed') {
428
+ console.log('status', event.data.message_id, event.data.status, event.data.client_reference);
429
+ }
430
+ });
431
+
432
+ app.listen(3000, async () => {
433
+ const subscription = await funnelchat.webhooks.create({
434
+ url: 'http://localhost:3000/hooks/funnelchat',
435
+ events: ['message.received', 'message.status_changed'],
436
+ });
437
+ secret = subscription.secret; // shown only once
438
+ console.log('subscription', subscription.id);
439
+ });
440
+ ```
441
+
442
+ ```bash
443
+ # A contact writes to your official number:
444
+ curl -s http://localhost:4010/_sandbox/inbound/message -H 'Content-Type: application/json' \
445
+ -d '{"phone": "5491100000000", "device_id": 3, "text": "Hola, ¿tienen stock?"}'
446
+ ```
447
+
448
+ On the receiver's console:
449
+
450
+ ```
451
+ sent 9002 queued
452
+ status 9002 sent respuesta-9001
453
+ status 9002 delivered respuesta-9001
454
+ status 9002 read respuesta-9001
455
+ ```
456
+
457
+ And at `http://localhost:4010/_sandbox` you see each request and each delivery with what your receiver answered.
458
+
459
+ ## What it does not imitate
460
+
461
+ - **It sends nothing to WhatsApp** and charges nothing: the `sent`, `delivered` and `read` statuses are made up by the sandbox, with the delays you configure. In the real API, a message still `queued` after an hour reads as `status: null`; in the sandbox it always moves on.
462
+ - **One account and every permission:** any well-formed key gets in, is never revoked and there is no `403 permission_denied`. API keys are not created here, and subscriptions are not filtered by the key's areas (the real API answers `422` on `events` if the key lacks the event's `{area}:read`). The only `403 route_not_available` is the one of the routes no key enables.
463
+ - **There are no daily call caps** (`429 daily_cap_exceeded`), no `503 gate_unavailable` and no balance: to test those cases, force the failure. Of the MAC there is the lockout (`429 mac_lockout_24h`); `429 mac_limit_reached` is only forced, and the real API also stores in the history an error row for every send stopped by the MAC, which the sandbox does not.
464
+ - **The state is memory only:** it is lost when the process stops or with `POST /_sandbox/reset`. So your receiver does not lose the secret, put the subscription in the configuration file.
465
+ - **The routes without state** answer an example built from the schema, always the same, even if it does not match your data.
466
+ - **The validation messages** are Laravel's in Spanish, built from the schema: they may not match the API's word for word (decide by the HTTP status and by `error`, not by `message`). The real API's validation `422` is Laravel's, `{message, errors}`, without `error`; the sandbox adds `"error": "validation_failed"`.
467
+ - **Webhook URLs** may be `http` and local, without the real API's protection against internal destinations (try `--require-https`). Do not expose the sandbox to the internet: the `/_sandbox` routes take no key and the sandbox `POST`s to any URL subscribed to it.
468
+ - **The catalog** is transcribed from the code of accounts v1.4.24 (`spec/catalog.json`), not read from a response: if accounts changes it, it has to be transcribed again.
469
+ - **There are no groups or campaigns with state:** their events are fired from `/_sandbox`, the group actions answer `403 route_not_available` as in production, and the other routes answer examples.
470
+ - **Forms do not talk to Meta:** `sync` and `publish` succeed unless the linter finds an error, and the linter is a short version of the real one (screens without a title, without an ending, without questions, with a connection to the server). There are no metrics (`…/metrics`) and no dynamic forms.
471
+ - **Files are not downloaded:** the type comes from the extension or from `file_type`. The «known» extensions (the ones that give `415` instead of `422 errors.file_type`) are the common ones; the API uses symfony/mime's full list.
472
+ - **The pace and the brakes** use the numbers of conversations in production (`config/send_guards.php`, `config/antiblock.php`); the official number paired with a QR one in coexistence (15 per second, shared) is not there.
473
+
474
+ ## Pending of the spec
475
+
476
+ The spec's snapshot (`spec/funnelchat.json`) is the portal's of 15-10. What went to production afterwards, or what the portal does not document yet, is in two separate layers that the next regeneration replaces on its own once the snapshot carries it:
477
+
478
+ - `spec/pending.json`: the pending layer of the Node SDK, copied unchanged (`funnelchat-node` `feat/inicial` @ `88557ce`).
479
+ - `spec/pending-sandbox.json`: what the sandbox adds, with its sources inside.
480
+
481
+ Besides, `spec/catalog.json` is the event catalog of accounts v1.4.24, with its provenance.
482
+
483
+ | What | Where it comes from | Status |
484
+ |---|---|---|
485
+ | Webhook subscriptions: `/api/v1/webhooks`, `/{id}`, `rotate-secret`, `test`, `deliveries` | accounts v1.4.24 | In production. |
486
+ | The send with a key: `200` with the queued message, `GET /api/v1/contacts/{contact}/messages/{message}`, `client_reference`, `error.reason` and the errors of the rules | conversations v1.18.10 | In production since 21-10. |
487
+ | `message.status_changed` and `device_id` in `message.received` | conversations v1.18.10 | In production; `message.status_changed` is `active`. |
488
+ | The file by URL: `file_type`, `file_name`, their `422`s and the `415` | conversations v1.18.10 (`SendFileType`) | In production. |
489
+ | Forms: `/api/v1/forms…`, `POST /api/v1/contacts/{contact}/message/form` and `whatsapp_form.completed` | conversations v1.18.10 | In production. The portal does not document them yet. |
490
+ | The group actions and join requests, off (`403 route_not_available`) | communities v2.34.52 | In production, off. |
491
+ | `capabilities` and the new keys of the numbers | accounts v1.4.24 (`DeviceResource`, `DeviceCapabilities`) | In production. |
492
+ | `X-Request-Id` and the edge's errors (`route_not_found`, `origin_unreachable`, `edge_misconfigured`) | api-edge v0.3.0, platform-gate v0.8.0 | In production. |
493
+
494
+ ## Regenerating
495
+
496
+ Everything generated comes from a single script:
497
+
498
+ ```bash
499
+ # With a new snapshot of the portal (`npm run datos` generates it in developers-portal):
500
+ npm run generate -- --spec ../developers-portal/public/specs/funnelchat.json
501
+
502
+ # With a new pending layer of the Node SDK:
503
+ npm run generate -- --pending ../funnelchat-node/spec/pending.json
504
+
505
+ # With what is already in spec/ (for example, after touching spec/pending-sandbox.json):
506
+ npm run generate
507
+
508
+ # To check that generated/ is up to date (useful in CI):
509
+ npm run generate:check
510
+ ```
511
+
512
+ What it does:
513
+
514
+ 1. With `--spec` or `--pending`, it copies the file to `spec/` and writes in `spec/procedencia.json` the repository, the branch, the commit, the date and the `sha256`.
515
+ 2. It applies over the snapshot `spec/pending.json` and then `spec/pending-sandbox.json`, but **only what the snapshot lacks**. At the end it lists what the snapshot already carries, so you can delete it.
516
+ 3. It writes `generated/openapi.json`, the spec the sandbox validates and answers with.
517
+
518
+ **The current snapshot** is the portal's of 15-10 (branch `feat/openapi-unificada` @ `0ef4abb`, merged since): 191 paths and 230 operations of conversations `9d723d52c`, communities `fab0bb10` and accounts `c234b6f8`. The detail is in `spec/procedencia.json`.
519
+
520
+ ## Development
521
+
522
+ ```bash
523
+ npm install
524
+ npm run generate:check # generated/ is up to date
525
+ npm run typecheck # tsc
526
+ npm run lint
527
+ npm test # vitest, no network: the receivers are local servers
528
+ npm run build # dist/ in ESM, with .d.ts
529
+ ```
530
+
531
+ The tests verify the deliveries with unchanged copies of the Node SDK's `verify` and of the portal's snippet (`test/vendor/`, with their provenance). If the `funnelchat-node` repo is next to this one (or in `FUNNELCHAT_NODE_SDK`), they also run against its current code, including the whole client pointed at the sandbox.
532
+
533
+ ```
534
+ spec/ the spec, its provenance, the two pending layers and the event catalog
535
+ scripts/ generate.mjs, the only generator
536
+ generated/ the merged spec (not edited)
537
+ src/openapi/ routes, validation and examples from the spec
538
+ src/handlers/ the routes with state (contacts, messages, forms, webhooks) and the ones the gate closes
539
+ src/forms/ the form templates
540
+ src/messaging/ send rules, the file by URL, forced failures and message statuses
541
+ src/webhooks/ signature, catalog, deliveries and retries
542
+ src/sandbox/ the /_sandbox routes and the panel
543
+ test/ vitest
544
+ ```
545
+
546
+ **Publishing:** push a `vX.Y.Z` tag on `main` that matches the `version` in `package.json`, and GitHub Actions publishes it to npm with provenance (`.github/workflows/publish.yml`).
547
+
548
+ ## License
549
+
550
+ [MIT](LICENSE).