@funnelchat-hq/sdk 0.0.0-stage → 0.1.2

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,377 @@
1
- # Temporary Holding Version
1
+ # funnelchat-node
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
+ Official Node.js and TypeScript SDK for the [Funnelchat public API](https://developers-portal.funnelchat.app/docs/): contacts, conversations and messages, WhatsApp forms, templates, tags, custom fields, groups, numbers and webhooks, with types generated from the OpenAPI spec.
4
+
5
+ - TypeScript, ESM and CommonJS. Node 18 or later, with Node's own `fetch`.
6
+ - No runtime dependencies.
7
+ - Safe retries, pagination with `for await`, typed errors carrying the `X-Request-Id` of each response, and webhook signature verification.
8
+
9
+ > **v0.** The public API is in v0 and may change. What the API already has but its spec does not describe yet is listed in [Pending of the spec](#pending-of-the-spec).
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ npm install @funnelchat-hq/sdk
15
+ ```
16
+
17
+ ## First call
18
+
19
+ You need an API key (`<id>|fc_live_...`) created in the "Developers" section of the panel, with the permissions of what you are going to use. Keep it in an environment variable: the client reads `FUNNELCHAT_API_KEY` when you do not pass it.
20
+
21
+ ```ts
22
+ import { Funnelchat } from '@funnelchat-hq/sdk';
23
+ // CommonJS: const { Funnelchat } = require('@funnelchat-hq/sdk');
24
+
25
+ const funnelchat = new Funnelchat({ apiKey: process.env.FUNNELCHAT_API_KEY });
26
+
27
+ // Your WhatsApp numbers (permission accounts:read). The `id` of each one is the `device_id` you send with.
28
+ for await (const device of funnelchat.devices.list()) {
29
+ console.log(device.id, device.number, device.capabilities.messages.templates);
30
+ }
31
+ ```
32
+
33
+ Client options:
34
+
35
+ | Option | What it is | Default |
36
+ |---|---|---|
37
+ | `apiKey` | Your key. | `FUNNELCHAT_API_KEY` |
38
+ | `environment` | `production` (`https://api.funnelchat.com`) or `staging`. | `production` |
39
+ | `baseUrl` | Another base URL, for example the [sandbox](#test-against-the-sandbox)'s; it wins over `environment`. | — |
40
+ | `timeoutMs` | Maximum time of each attempt. | `30000` |
41
+ | `maxRetries` | Automatic retries (see [Retries](#retries)). `0` turns them off. | `2` |
42
+ | `defaultHeaders` | Headers sent with every request. | — |
43
+ | `fetch` | Another `fetch` implementation. | Node's |
44
+
45
+ Every request sends `Authorization: Bearer <key>`, `Accept: application/json` and `User-Agent: funnelchat-node/<version> node/<version>`. The key never shows up in a `console.log` of the client or in an error.
46
+
47
+ ## The pieces
48
+
49
+ | Resource | What it has |
50
+ |---|---|
51
+ | `funnelchat.contacts` | `list`, `create`, `upsert`, `retrieve`, `update`, `delete`, the tags and fields of a contact, `metaWindow` (the 24-hour window), notes. |
52
+ | `funnelchat.conversations` | `list`, `update`, counters, mark everything as read or unread. |
53
+ | `funnelchat.messages` | `send`, `sendDirect`, `sendForm`, `sendContactCard`, `forward`, `get` (one message by id), `list` (the history of a contact), `react`, `removeReaction`. |
54
+ | `funnelchat.forms` | WhatsApp forms: `list`, `templates`, `create`, `retrieve`, `update`, `delete`, `createVersion`, `sync`, `publish`, numbers, `versions`, answers and `metrics`. |
55
+ | `funnelchat.templates` | `list`, `create`, `retrieve`, `update`, `quickReplyButtons`, `send`, `delete`. |
56
+ | `funnelchat.tags` | `list`, `create`, `retrieve`, `update`, `delete`, `listContacts`. |
57
+ | `funnelchat.customFields` | `list`, `create`, `retrieve`, `update`, `delete`. |
58
+ | `funnelchat.groups` | Read groups, participants, events and messages. |
59
+ | `funnelchat.devices` | `list`, `retrieve` (with `capabilities`), `update`, `status`, `health`, `me`, `checkPhone`, connection history. |
60
+ | `funnelchat.webhooks` | Subscriptions (`list`, `create`, `retrieve`, `update`, `delete`, `test`, `listDeliveries`), the catalog (`events`), `verify` and `constructEvent`. |
61
+
62
+ Parameters, bodies and responses are typed from the spec. For any other route, see [Any route](#any-route).
63
+
64
+ ## Send a message
65
+
66
+ Sending with a key needs the `conversations:send` permission and follows the WhatsApp rules. **Every send answers right away with the registered message, `status: 'queued'`:** accepted is not delivered. What happens next arrives by the `message.status_changed` webhook, or is read with `messages.get`.
67
+
68
+ **Open window: free text.** With an official Meta number you can only send free text if the contact wrote to you in the last 24 hours. Check the window first:
69
+
70
+ ```ts
71
+ const window = await funnelchat.contacts.metaWindow(2001, { device_id: 3 });
72
+
73
+ if (window.is_open) {
74
+ const { message } = await funnelchat.messages.send(2001, {
75
+ device_id: 3,
76
+ phone: '5491100000000', // the phone of the same contact, without +
77
+ text: 'Hi! Here is the info…',
78
+ client_reference: 'order-8841', // optional: your reference to follow it
79
+ });
80
+ console.log(message.id, message.status); // 1002 'queued'
81
+ }
82
+ ```
83
+
84
+ - `message.id` is the same `message_id` of `message.status_changed`. With it, `funnelchat.messages.get(2001, message.id)` tells you how it is going: `status` moves from `queued` to `sent`, `delivered`, `read` or `failed`, and a `failed` one carries `error` (`code`, `title` and `reason`).
85
+ - `client_reference` (1 to 64 visible ASCII characters, no spaces) comes back in the history of the contact and in `message.status_changed`.
86
+ - `contact_id` goes in the body, but `send` fills it with the contact of the route when you leave it out. Send the `phone` (or `whatsapp_id`) of the same contact: when it does not match, the API answers `422 recipient_mismatch`.
87
+ - `sendDirect` is the same operation through `POST /api/v1/message/send-message`, with `device_id` and `contact_id` in the body.
88
+ - Contact cards (`sendContactCard`) return `{ messages }`, one per card; a forward (`forward`) one item per destination with its `message` (or `null` when that destination was blocked); a template (`templates.send`) and a form (`sendForm`) `{ message }`.
89
+
90
+ **A file by URL.** `file` is the URL of the file. When it does not end in a known extension (a signed URL, or one without extension), say what it is with `file_type` and, for a document, the name it arrives with:
91
+
92
+ ```ts
93
+ await funnelchat.messages.send(2001, {
94
+ device_id: 3,
95
+ phone: '5491100000000',
96
+ file: 'https://files.example.com/download?signature=abc123',
97
+ file_type: 'document', // image, video, audio, document or sticker
98
+ file_name: 'invoice.pdf',
99
+ });
100
+ ```
101
+
102
+ Without `file_type`, a URL without a known extension answers `422` with `errors.file_type`, and an unsupported extension `415` (`UnsupportedMediaTypeError`, with `supported_file_types` in `error.body`).
103
+
104
+ **Closed window: an approved template.** The route takes the number (`3`) and the id of the template (`45`), which comes from `funnelchat.templates.list()`:
105
+
106
+ ```ts
107
+ const { message } = await funnelchat.templates.send(3, '45', {
108
+ phone: '5491100000000',
109
+ contact_id: 2001,
110
+ template_variables: { body: ['Lucía'] }, // the body variables, in order
111
+ });
112
+ ```
113
+
114
+ **When a send is stopped.** A send that breaks a rule is neither sent nor stored: you get an error with its `code`.
115
+
116
+ | Error | What happened | What to do |
117
+ |---|---|---|
118
+ | `422 window_closed` | Free text with the window closed, on an official number. | Send an approved template. |
119
+ | `422 antiblock_duplicate_content` | The same content repeated to the same contact. | Change the text or wait. |
120
+ | `422 antiblock_unanswered_limit` | Too many messages in a row to someone who does not answer. | Wait until they answer. |
121
+ | `422 recipient_mismatch` | The `phone` or `whatsapp_id` is not the contact of the route. | Use that contact's data. |
122
+ | `422 recipient_unsupported` | The contact is a group, a broadcast list or a channel. | This route only writes to people. |
123
+ | `422 provider_unsupported` | The number cannot send that. | Check the `capabilities` of the number. |
124
+ | `422 device_unavailable` | The number is not connected. | Reconnect it. |
125
+ | `429 rate_limited` | You went over the pace of your number. It carries `retryAfter`. | Wait those seconds and retry. |
126
+ | `429 mac_lockout_24h` | The account is in the 24-hour lockout after using up the monthly active contacts. | Waiting a few seconds does not fix it. |
127
+
128
+ `isSendRuleError(error.code)` tells you whether the error is a rejected send; `SEND_ERROR_CODES` has all of them, with their HTTP status. A send is **never retried automatically** (see [Retries](#retries)).
129
+
130
+ ## Forms
131
+
132
+ A WhatsApp form is built, published on one or more numbers and sent to a contact. There are two ids: `id` is one version; `family_id` is the form itself, the one you send it with and the one that comes back as `form.id` in `whatsapp_form.completed`.
133
+
134
+ ```ts
135
+ const { data: draft } = await funnelchat.forms.create({ device_id: 3, template_key: 'lead' }); // permission conversations:write
136
+ await funnelchat.forms.publish(draft.id);
137
+
138
+ const { message } = await funnelchat.messages.sendForm(2001, {
139
+ form_id: draft.family_id,
140
+ device_id: 3,
141
+ text: 'How did it go?',
142
+ client_reference: 'survey-8841',
143
+ });
144
+ ```
145
+
146
+ The answers arrive by the `whatsapp_form.completed` webhook (`answers`: `key`, `label` and `value`) and are read with `forms.listResponses(familyId)`. `forms.templates()` lists the templates to start from; `forms.update(id, { definition, expected_updated_at })` saves a draft and answers `409 STALE` when someone saved it first.
147
+
148
+ ## Receive webhooks with Express
149
+
150
+ Verify the signature over the **raw body**, exactly as it arrived. If you parse it and serialize it again, the signature changes: use `express.raw`, not `express.json`.
151
+
152
+ ```ts
153
+ import express from 'express';
154
+ import { Funnelchat, WebhookVerificationError } from '@funnelchat-hq/sdk';
155
+
156
+ const app = express();
157
+
158
+ app.post('/hooks/funnelchat', express.raw({ type: 'application/json' }), (req, res) => {
159
+ let event;
160
+ try {
161
+ event = Funnelchat.webhooks.constructEvent(
162
+ req.body, // Buffer: the raw body
163
+ req.get('X-Funnelchat-Signature'),
164
+ process.env.FUNNELCHAT_WEBHOOK_SECRET!, // the full secret, with whsec_
165
+ );
166
+ } catch (error) {
167
+ if (error instanceof WebhookVerificationError) return res.sendStatus(400); // error.reason says why
168
+ throw error;
169
+ }
170
+
171
+ res.sendStatus(200); // answer fast (under 10 s) and process later
172
+ queue.push(event); // deduplicate by event.id: a retry brings the same one
173
+ });
174
+
175
+ function handle(event) {
176
+ switch (event.type) {
177
+ case 'message.received':
178
+ console.log(event.data.device_id, event.data.contact.phone, event.data.message.text);
179
+ break;
180
+ case 'message.status_changed':
181
+ console.log(event.data.message_id, event.data.status, event.data.client_reference);
182
+ break;
183
+ case 'whatsapp_form.completed':
184
+ console.log(event.data.form.id, event.data.answers);
185
+ break;
186
+ default:
187
+ // A type your code does not know yet: it is not an error, answer 2xx anyway.
188
+ }
189
+ }
190
+ ```
191
+
192
+ - `constructEvent` verifies the signature and returns the typed event: with `event.type`, TypeScript knows the shape of `event.data`. When something fails, it throws `WebhookVerificationError` with `reason`: `missing_header`, `malformed_header`, `timestamp_out_of_tolerance`, `signature_mismatch`, `invalid_body`, `invalid_json`…
193
+ - To just get a yes or no, `Funnelchat.webhooks.verify(rawBody, signatureHeader, secret)` returns `true` or `false` and never throws.
194
+ - Both reject a signature whose `t` is more than 300 seconds away from your clock. To change it: `{ toleranceSeconds: 600 }` as the fourth argument.
195
+ - The algorithm is the portal's: `X-Funnelchat-Signature: t=<unix>,v1=<hex>`, where `v1` is the HMAC-SHA256, with the full secret, of `"<t>." + raw body`, compared in constant time; there can be several `v1`. The tests use the portal's test vectors.
196
+ - `webhookEvents` lists the types the SDK knows, with their status (`live` or `planned`).
197
+
198
+ ### Subscribe
199
+
200
+ ```ts
201
+ const subscription = await funnelchat.webhooks.create({
202
+ url: 'https://your-server.example/hooks/funnelchat',
203
+ events: ['message.received', 'message.status_changed', 'whatsapp_form.completed'],
204
+ description: 'My integration',
205
+ });
206
+ // Store subscription.id and subscription.secret: the secret is shown only once.
207
+ ```
208
+
209
+ - Permissions: `webhooks:read` to read and `webhooks:write` for the rest. Also, a key can only subscribe to events of the areas it can read (`conversations:read`, `communities:read`, `accounts:read`): asking for others is a `422` on `events`.
210
+ - Subscriptions belong to the account: `list()` also brings the ones of other integrations. To change the URL use `update(id, …)` (the secret stays). `test(id)` sends a signed `webhook.test` right now and `listDeliveries(id)` is the history.
211
+ - `rotateSecret(id)` is **not open to API keys** (`403 route_not_available`): rotate the secret from the panel.
212
+
213
+ ## Pagination
214
+
215
+ `list…` methods return a lazy page: nothing is requested until you use it.
216
+
217
+ ```ts
218
+ // Every item of every page:
219
+ for await (const contact of funnelchat.contacts.list({ perPage: 100, tag_mode: 'any', tag_ids: [12] })) {
220
+ console.log(contact.id);
221
+ }
222
+
223
+ // Page by page:
224
+ let page = await funnelchat.tags.list({ perPage: 50 });
225
+ console.log(page.data, page.requestId);
226
+ while (page.hasNextPage()) {
227
+ page = await page.getNextPage();
228
+ }
229
+ ```
230
+
231
+ The SDK understands the three ways the API paginates: by page number (`meta.current_page` and `meta.last_page`), by cursor (`meta.next_cursor`) and lists that come whole in an array. The page size is asked with the parameter of each route (`perPage`, `per_page` or `pageSize`, as the spec says).
232
+
233
+ ## Errors
234
+
235
+ Everything the SDK throws extends `FunnelchatError`. When the API answers with an error, it is an `APIError`:
236
+
237
+ | Property | What it is |
238
+ |---|---|
239
+ | `status` | The HTTP status. |
240
+ | `code` | The stable `error` of the body (`window_closed`, `rate_limited`, `route_not_available`…), or `undefined` when there is none (for example a validation `422`). |
241
+ | `message` | The `message` of the body, meant for people. Decide by `status` and `code`, not by `message`. |
242
+ | `requestId` | The `X-Request-Id` of the response. **Quote it when you report a problem.** |
243
+ | `retryAfter` | The seconds of `Retry-After`, when present. |
244
+ | `errors` | In a validation `422`, the messages per field. |
245
+ | `body` | The full body, with the fields of each error. |
246
+
247
+ Each HTTP status has its class: `BadRequestError` (400), `AuthenticationError` (401), `PermissionDeniedError` (403), `NotFoundError` (404), `ConflictError` (409), `UnsupportedMediaTypeError` (415), `UnprocessableEntityError` (422), `RateLimitError` (429) and `InternalServerError` (5xx). No response: `APIConnectionError`, and `APIConnectionTimeoutError` when it went over `timeoutMs`. Aborted with your own `AbortSignal`: `APIUserAbortError`.
248
+
249
+ ```ts
250
+ import { APIError, RateLimitError } from '@funnelchat-hq/sdk';
251
+
252
+ try {
253
+ await funnelchat.contacts.retrieve(2001);
254
+ } catch (error) {
255
+ if (error instanceof RateLimitError) {
256
+ console.log(`Wait ${error.retryAfter} s`);
257
+ } else if (error instanceof APIError) {
258
+ console.log(error.status, error.code, error.message, error.requestId);
259
+ } else {
260
+ throw error;
261
+ }
262
+ }
263
+ ```
264
+
265
+ The errors of the API entry and of access control apply to any route (`ACCESS_ERROR_CODES`):
266
+
267
+ | Error | What happened |
268
+ |---|---|
269
+ | `401 unauthenticated` | The key is missing or not valid. |
270
+ | `403 permission_denied` | The key lacks the permission of the route. |
271
+ | `403 route_not_available` | The route is not open to API keys (or it is turned off). |
272
+ | `404 route_not_found` | The route does not exist in the public API. |
273
+ | `429 daily_cap_exceeded` | The daily call cap of the key was reached. It carries `Retry-After`. |
274
+ | `500 edge_misconfigured` | A problem on our side: report it with the `requestId`. |
275
+ | `502 origin_unreachable` | The API could not reach the area of the route. Treat it as a timeout: if it was a write, check whether it was applied before repeating it. |
276
+ | `503 gate_unavailable` | The call could not be evaluated and nothing ran. It carries `Retry-After`. |
277
+
278
+ **The `X-Request-Id` of a successful response** is at hand too: every call returns a promise with `withResponse()`.
279
+
280
+ ```ts
281
+ const { data, requestId, response } = await funnelchat.contacts.retrieve(2001).withResponse();
282
+ ```
283
+
284
+ ## Retries
285
+
286
+ - Reads and writes that give the same result when repeated (`GET`, `PUT`, `DELETE`) are retried on a `429`, a `5xx` or a network error (also when `timeoutMs` ran out), up to `maxRetries` times (2 by default).
287
+ - Other writes (`POST`, `PATCH`) are retried **only when the API guarantees they were not applied**: `429` and `503`. A `500`, `502` or `504`, a timeout or a dropped connection can come after the write was applied, so the error is thrown right away: read the state before repeating it.
288
+ - **A send is never retried automatically**: the `POST`s with the `conversations:send` permission (messages, forms, cards, forwards, templates). If a `429 rate_limited` sent nothing and you want the SDK to retry for you, ask for it in that call with `{ retrySend: true }`: it then follows the rule of writes. `mac_lockout_24h` and `mac_limit_reached` are never retried.
289
+ - With `Retry-After`, the wait is exactly what it asks for. If it asks for more than 60 seconds, there is no wait: the error is thrown right away with its `retryAfter` and you decide. Without `Retry-After`, the wait is exponential: 0.5 s, 1 s, 2 s… up to 8 s, with some jitter.
290
+ - Per call you can change `maxRetries`, `timeoutMs` and `signal`:
291
+
292
+ ```ts
293
+ await funnelchat.tags.list({}, { maxRetries: 0, timeoutMs: 5000, signal: AbortSignal.timeout(10_000) });
294
+ ```
295
+
296
+ ## Any route
297
+
298
+ `request` calls any route. With a route of the spec, the path parameters, the query, the body and the response are typed:
299
+
300
+ ```ts
301
+ const contact = await funnelchat.request('GET', '/api/v1/contacts/{contact}', { path: { contact: 2001 } });
302
+
303
+ const stats = await funnelchat.request('GET', '/api/v1/broadcasts/{broadcast}/statistics', { path: { broadcast: 77 } });
304
+ ```
305
+
306
+ A route that is not in the spec yet works the same, untyped (or with the types you pass):
307
+
308
+ ```ts
309
+ const result = await funnelchat.request<{ ok: boolean }>('POST', '/api/v1/something-new', { body: { a: 1 } });
310
+ ```
311
+
312
+ `paginate` is the same for lists: `for await (const item of funnelchat.paginate('/api/v1/broadcasts'))`.
313
+
314
+ The query is built the way Laravel reads it: arrays as `key[]=a&key[]=b`, objects with brackets and booleans as `1` and `0`.
315
+
316
+ The types of the spec are exported too: `paths`, `components`, `operations`, and the shorthands `Body<'/api/v1/tags', 'POST'>`, `Query<'/api/v1/contacts'>`, `ResponseBody<…>`. The `routes` table has every operation with its area and its permission.
317
+
318
+ ## Test against the sandbox
319
+
320
+ [funnelchat-sandbox](https://github.com/Funnelchat20/funnelchat-sandbox) imitates the API on your machine: sends with their status cycle, signed webhooks and the WhatsApp rules, without a real account. Point the client at it:
321
+
322
+ ```ts
323
+ const funnelchat = new Funnelchat({ apiKey: '1|fc_live_sandbox', baseUrl: 'http://localhost:4010' });
324
+ ```
325
+
326
+ ## Pending of the spec
327
+
328
+ The published spec does not describe everything the API in production already has yet. What is missing lives in a separate layer, `spec/pending.json`, which generation adds to the types **only while the spec does not bring it**; each entry cites where it came from:
329
+
330
+ - what sends answer (the queued message), `messages.get`, `client_reference`, `file_type` and `file_name`;
331
+ - forms and `messages.sendForm`;
332
+ - the `capabilities` of numbers;
333
+ - webhook subscriptions and the events the spec does not bring (`message.status_changed` with its shape, `whatsapp_form.completed`, the group ones and the account ones).
334
+
335
+ `src/pending/` has what is not OpenAPI (`device_id` in `message.received`) and `src/core/error-codes.ts` the error codes.
336
+
337
+ ## Development
338
+
339
+ ```bash
340
+ npm install
341
+ npm run generate:check # the generated files and spec/pending.json are up to date
342
+ npm run typecheck # tsc, including the type checks of test/types.test.ts
343
+ npm run lint
344
+ npm test # vitest, no network and no keys: a mocked fetch
345
+ npm run build # dist/ in ESM and CJS, with .d.ts
346
+ FUNNELCHAT_SANDBOX_URL=http://127.0.0.1:4010 npm run test:e2e # against a running sandbox
347
+ ```
348
+
349
+ The SDK runs on Node 18 or later; the development tools (vitest 4, eslint 9) need Node 20 or later.
350
+
351
+ To release, push a `vX.Y.Z` tag on `main` matching the `version` in `package.json`; GitHub Actions (`.github/workflows/publish.yml`) publishes it to npm with provenance.
352
+
353
+ Everything generated comes from one script:
354
+
355
+ ```bash
356
+ npm run generate -- --spec <path>/funnelchat.json # with a new unified spec of the portal
357
+ npm run generate # with the one in spec/, for example after editing spec/pending.json
358
+ ```
359
+
360
+ 1. With `--spec`, it copies the spec to `spec/funnelchat.json` and writes `spec/procedencia.json`: repository, branch, commit, date and `sha256` of the file.
361
+ 2. It adds `spec/pending.json`, but only what the spec does not bring, and lists what it already brings so you can delete it from there.
362
+ 3. It writes `src/generated/openapi.ts` (with [openapi-typescript](https://openapi-ts.dev)), `src/generated/routes.ts` (every operation, with area and permission) and `src/generated/events.ts` (the event catalog and the shape of the `data` of each one).
363
+
364
+ ```
365
+ spec/ the spec, its provenance and the pending layer
366
+ scripts/ generate.mjs (the generator) and format-pending.mjs
367
+ src/generated/ generated code (never edited by hand)
368
+ src/pending/ what is pending of the spec and is not OpenAPI
369
+ src/core/ HTTP, retries, pagination, errors and their codes
370
+ src/resources/ the pieces
371
+ src/webhooks/ signature and events
372
+ test/ vitest; test/e2e against the sandbox
373
+ ```
374
+
375
+ ## License
376
+
377
+ [MIT](LICENSE)