@whatsapi.sh/sdk 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.
@@ -0,0 +1,371 @@
1
+ // GENERATED FILE — do not edit. Run `bun run sdk:gen`.
2
+ // Source: apps/api/src/routes/v1 (the mounted operations)
3
+ //
4
+ // One method per capability, named the way its MCP tool is named, returning
5
+ // the DTO the operation declares. `Whatsapi` in ../client.ts supplies request().
6
+
7
+ import type { RequestSpec } from "../client.ts";
8
+ import type {
9
+ BatchMessageDto,
10
+ DeletedNumberDto,
11
+ DeletedWebhookDto,
12
+ MarkReadDto,
13
+ MessageDto,
14
+ NumberCheckDto,
15
+ NumberDto,
16
+ OtpCheckDto,
17
+ OtpDto,
18
+ PairNumberDto,
19
+ SavedWebhookDto,
20
+ TypingDto,
21
+ UnpairedNumberDto,
22
+ UsageDto,
23
+ WebhookDeliveryDto,
24
+ WebhookEndpointDto,
25
+ WebhookTestResultDto,
26
+ } from "./contract.ts";
27
+ import type {
28
+ CheckNumberParams,
29
+ CheckOtpParams,
30
+ CreateNumberParams,
31
+ DeleteNumberParams,
32
+ ListWebhookDeliveriesParams,
33
+ MarkReadParams,
34
+ NumberStatusParams,
35
+ PairNumberParams,
36
+ SendBatchParams,
37
+ SendContactParams,
38
+ SendLocationParams,
39
+ SendMediaParams,
40
+ SendMessageParams,
41
+ SendOtpParams,
42
+ SetWebhookParams,
43
+ TypingParams,
44
+ UnpairNumberParams,
45
+ } from "./params.ts";
46
+
47
+ /** The version this client reports in `x-whatsapi-client`. */
48
+ export const SDK_VERSION = "0.1.0";
49
+
50
+ /** The hosted API. */
51
+ export const DEFAULT_BASE_URL = "https://api.whatsapi.sh/v1";
52
+
53
+ export abstract class GeneratedOperations {
54
+ protected abstract request<T>(spec: RequestSpec, params?: object): Promise<T>;
55
+
56
+ /**
57
+ * Send a WhatsApp message
58
+ *
59
+ * Send a text message to a phone number over WhatsApp. Counts as one message against the
60
+ * account's monthly quota. The reply carries WhatsApp's message id — delivery and read
61
+ * receipts arrive later on the account's webhook, not here.
62
+ *
63
+ * `POST /messages`
64
+ */
65
+ sendMessage(params: SendMessageParams): Promise<MessageDto> {
66
+ return this.request({ method: "POST", path: "/messages" }, params);
67
+ }
68
+
69
+ /**
70
+ * Send a photo, video, voice note or file
71
+ *
72
+ * Send media over WhatsApp from a public URL or base64 data. `type` picks the bubble the
73
+ * recipient sees: `image` and `video` support a caption, `document` is a file attachment named
74
+ * by `fileName`, and `audio` becomes a voice note when the file is ogg/opus — any other audio
75
+ * format is sent as a playable attachment instead, because WhatsApp will not render a voice
76
+ * note that is not opus. Files are capped at 16MB, and `url` beats `base64` for anything
77
+ * sizeable. Counts as one message.
78
+ *
79
+ * `POST /messages/media`
80
+ */
81
+ sendMedia(params: SendMediaParams): Promise<MessageDto> {
82
+ return this.request({ method: "POST", path: "/messages/media" }, params);
83
+ }
84
+
85
+ /**
86
+ * Send one text to many recipients
87
+ *
88
+ * Send the same text to up to 25 recipients in one call — one message, and one message's worth
89
+ * of quota, per recipient. The answer reports each recipient separately: a batch where some
90
+ * land and some do not is the normal case, not an error. If something systemic stops the run
91
+ * (the number drops its session, the quota or rate limit refuses) the remaining recipients
92
+ * come back as `skipped` rather than being attempted, so they can be sent again once that is
93
+ * cleared. Sends inside a batch leave back-to-back, so keep lists small and space calls
94
+ * minutes apart — pacing keeps your number off WhatsApp's spam radar (see the avoiding-bans
95
+ * guide). Paid plans only.
96
+ *
97
+ * `POST /messages/batch`
98
+ */
99
+ sendBatch(params: SendBatchParams): Promise<BatchMessageDto> {
100
+ return this.request({ method: "POST", path: "/messages/batch" }, params);
101
+ }
102
+
103
+ /**
104
+ * Send a location pin
105
+ *
106
+ * Send a map pin over WhatsApp. Counts as one message.
107
+ *
108
+ * `POST /messages/location`
109
+ */
110
+ sendLocation(params: SendLocationParams): Promise<MessageDto> {
111
+ return this.request({ method: "POST", path: "/messages/location" }, params);
112
+ }
113
+
114
+ /**
115
+ * Send a contact card
116
+ *
117
+ * Send a contact card (vCard) over WhatsApp, so the recipient can save the number with one
118
+ * tap. Counts as one message.
119
+ *
120
+ * `POST /messages/contact`
121
+ */
122
+ sendContact(params: SendContactParams): Promise<MessageDto> {
123
+ return this.request({ method: "POST", path: "/messages/contact" }, params);
124
+ }
125
+
126
+ /**
127
+ * Send a verification code
128
+ *
129
+ * Generate a 6-digit code, send it over WhatsApp and return it. Returning the code is
130
+ * deliberate: verify it yourself, or hand it back to check_otp and keep nothing. Codes expire
131
+ * in 5 minutes by default and one number can only be sent a new code once a minute. Counts as
132
+ * one message.
133
+ *
134
+ * `POST /otp`
135
+ */
136
+ sendOtp(params: SendOtpParams): Promise<OtpDto> {
137
+ return this.request({ method: "POST", path: "/otp" }, params);
138
+ }
139
+
140
+ /**
141
+ * Check a verification code
142
+ *
143
+ * Verify a code sent with send_otp and consume it, so it can never be used twice. A wrong or
144
+ * expired code answers 400 OTP_INVALID — deliberately an error, not a `verified: false`, so a
145
+ * missing check at the caller can never read as success. Five wrong guesses kill the code.
146
+ * Free.
147
+ *
148
+ * `POST /otp/check`
149
+ */
150
+ checkOtp(params: CheckOtpParams): Promise<OtpCheckDto> {
151
+ return this.request({ method: "POST", path: "/otp/check" }, params);
152
+ }
153
+
154
+ /**
155
+ * List your WhatsApp numbers
156
+ *
157
+ * Every WhatsApp number on the account with its connection status. Start here when you do not
158
+ * know which number to send from, or to check whether one needs re-pairing. Free.
159
+ *
160
+ * `GET /numbers`
161
+ */
162
+ listNumbers(): Promise<NumberDto[]> {
163
+ return this.request({ method: "GET", path: "/numbers" });
164
+ }
165
+
166
+ /**
167
+ * Add a WhatsApp number
168
+ *
169
+ * Create a new WhatsApp number slot on the account and return it. This is step one of two: the
170
+ * slot starts `disconnected` and cannot send until pair_number links a real WhatsApp account
171
+ * to it. How many can exist at once is what the plan sells. Free.
172
+ *
173
+ * `POST /numbers`
174
+ */
175
+ createNumber(params: CreateNumberParams): Promise<NumberDto> {
176
+ return this.request({ method: "POST", path: "/numbers" }, params);
177
+ }
178
+
179
+ /**
180
+ * Check whether a phone is on WhatsApp
181
+ *
182
+ * Ask WhatsApp whether a phone number has an account, and get its profile picture when it
183
+ * does. Brazilian mobiles are checked with and without the extra 9 and the answer reports the
184
+ * form that actually matched. If WhatsApp cannot be reached the answer assumes yes and sets
185
+ * `verified: false` — an outage must not block sends to real people. Free.
186
+ *
187
+ * `GET /numbers/check/:phone`
188
+ */
189
+ checkNumber(params: CheckNumberParams): Promise<NumberCheckDto> {
190
+ return this.request(
191
+ { method: "GET", path: "/numbers/check/:phone", pathParams: ["phone"] },
192
+ params,
193
+ );
194
+ }
195
+
196
+ /**
197
+ * Get one number's status
198
+ *
199
+ * One number's current state: `connected` (ready to send), `pairing` (waiting for a scan),
200
+ * `disconnected` (session dropped — pair it again) or `banned` (WhatsApp restricted it; wait
201
+ * out the 24h window before reconnecting). Free.
202
+ *
203
+ * `GET /numbers/:id`
204
+ */
205
+ numberStatus(params: NumberStatusParams): Promise<NumberDto> {
206
+ return this.request(
207
+ { method: "GET", path: "/numbers/:numberId", pathParams: ["numberId"] },
208
+ params,
209
+ );
210
+ }
211
+
212
+ /**
213
+ * Pair a number with WhatsApp
214
+ *
215
+ * Start (or resume) linking a WhatsApp account to one of your numbers. `qr` returns a QR code
216
+ * to scan from WhatsApp → Linked devices; `code` returns an 8-character linking code to type
217
+ * there instead, and needs the phone number being linked. A QR lives ~90 seconds and a new one
218
+ * can only be minted every few minutes, so call again to poll: `state: "pairing"` with no code
219
+ * yet simply means it is still being minted. Free.
220
+ *
221
+ * `POST /numbers/:id/pair`
222
+ */
223
+ pairNumber(params: PairNumberParams): Promise<PairNumberDto> {
224
+ return this.request(
225
+ { method: "POST", path: "/numbers/:numberId/pair", pathParams: ["numberId"] },
226
+ params,
227
+ );
228
+ }
229
+
230
+ /**
231
+ * Disconnect a number from WhatsApp
232
+ *
233
+ * Log the linked WhatsApp account out of this number, keeping the number itself. Use it to
234
+ * link a different phone: a connected number cannot be re-paired until it is unpaired. Free.
235
+ *
236
+ * `POST /numbers/:id/unpair`
237
+ */
238
+ unpairNumber(params: UnpairNumberParams): Promise<UnpairedNumberDto> {
239
+ return this.request(
240
+ { method: "POST", path: "/numbers/:numberId/unpair", pathParams: ["numberId"] },
241
+ params,
242
+ );
243
+ }
244
+
245
+ /**
246
+ * Delete a number
247
+ *
248
+ * Remove a number from the account for good, logging its WhatsApp session out on the way. This
249
+ * frees a slot against the plan's limit. Messages already sent are unaffected, and the usage
250
+ * they cost stays on the bill. Free, and not reversible.
251
+ *
252
+ * `DELETE /numbers/:id`
253
+ */
254
+ deleteNumber(params: DeleteNumberParams): Promise<DeletedNumberDto> {
255
+ return this.request(
256
+ { method: "DELETE", path: "/numbers/:numberId", pathParams: ["numberId"] },
257
+ params,
258
+ );
259
+ }
260
+
261
+ /**
262
+ * Show the typing indicator
263
+ *
264
+ * Show (or clear) the “typing…” indicator in a chat. Free — it is not a message. WhatsApp
265
+ * clears it on its own after a few seconds, so call it again for a long reply.
266
+ *
267
+ * `POST /chats/:phone/typing`
268
+ */
269
+ typing(params: TypingParams): Promise<TypingDto> {
270
+ return this.request(
271
+ { method: "POST", path: "/chats/:to/typing", pathParams: ["to"] },
272
+ params,
273
+ );
274
+ }
275
+
276
+ /**
277
+ * Mark messages as read
278
+ *
279
+ * Put blue ticks on messages your number received in a chat. Free. Message ids come from the
280
+ * inbound webhook.
281
+ *
282
+ * `POST /chats/:phone/read`
283
+ */
284
+ markRead(params: MarkReadParams): Promise<MarkReadDto> {
285
+ return this.request(
286
+ { method: "POST", path: "/chats/:to/read", pathParams: ["to"] },
287
+ params,
288
+ );
289
+ }
290
+
291
+ /**
292
+ * Get your webhook endpoint
293
+ *
294
+ * The endpoint events are being POSTed to, which ones it is subscribed to, and whether it is
295
+ * still healthy. `null` when none is registered. `disabledReason: "failures"` means we stopped
296
+ * delivering after a long run of dead attempts — fix the endpoint and set it again (or fire a
297
+ * test) to switch it back on. Free.
298
+ *
299
+ * `GET /webhooks`
300
+ */
301
+ getWebhook(): Promise<WebhookEndpointDto | null> {
302
+ return this.request({ method: "GET", path: "/webhooks" });
303
+ }
304
+
305
+ /**
306
+ * Register your webhook endpoint
307
+ *
308
+ * Point whatsapi.sh at an https URL and start receiving events there. The answer carries the
309
+ * signing secret ONCE, on first registration: keep it — every delivery is signed with it in
310
+ * the `x-whatsapi-signature` header (`t=<unix>,v1=<hmac-sha256 of "<t>.<body>">`), and that
311
+ * signature is how you know a request is really from us. Calling this again updates the URL or
312
+ * the subscription and keeps the same secret unless you ask to rotate it. Saving also
313
+ * re-enables an endpoint we had disabled. Free.
314
+ *
315
+ * `PUT /webhooks`
316
+ */
317
+ setWebhook(params: SetWebhookParams): Promise<SavedWebhookDto> {
318
+ return this.request({ method: "PUT", path: "/webhooks" }, params);
319
+ }
320
+
321
+ /**
322
+ * Fire a test event at your endpoint
323
+ *
324
+ * Send a `ping` event to your registered endpoint right now and wait for the answer, so you
325
+ * can verify the URL and your signature check without waiting for a real message. The result
326
+ * is the actual outcome: `ok: false` carries the HTTP status your server answered, or why we
327
+ * could not reach it. A successful ping also revives an endpoint we had auto-disabled. Free.
328
+ *
329
+ * `POST /webhooks/test`
330
+ */
331
+ testWebhook(): Promise<WebhookTestResultDto> {
332
+ return this.request({ method: "POST", path: "/webhooks/test" });
333
+ }
334
+
335
+ /**
336
+ * Recent webhook deliveries
337
+ *
338
+ * The last deliveries we attempted, newest first — what we sent, what your endpoint answered,
339
+ * and how many attempts it took. This is the answer to “the event never arrived”. Deliveries
340
+ * are kept for a week. Free.
341
+ *
342
+ * `GET /webhooks/deliveries`
343
+ */
344
+ listWebhookDeliveries(params?: ListWebhookDeliveriesParams): Promise<WebhookDeliveryDto[]> {
345
+ return this.request({ method: "GET", path: "/webhooks/deliveries" }, params);
346
+ }
347
+
348
+ /**
349
+ * Remove your webhook endpoint
350
+ *
351
+ * Stop sending events and forget the endpoint, its secret and its delivery history.
352
+ * Registering again mints a new secret. Free.
353
+ *
354
+ * `DELETE /webhooks`
355
+ */
356
+ deleteWebhook(): Promise<DeletedWebhookDto> {
357
+ return this.request({ method: "DELETE", path: "/webhooks" });
358
+ }
359
+
360
+ /**
361
+ * Check quota and usage
362
+ *
363
+ * Messages sent this month against the plan quota, today against the daily fair-use cap, the
364
+ * leftover credit balance, and the per-key breakdown. Read this before a bulk send. Free.
365
+ *
366
+ * `GET /usage`
367
+ */
368
+ getUsage(): Promise<UsageDto> {
369
+ return this.request({ method: "GET", path: "/usage" });
370
+ }
371
+ }
@@ -0,0 +1,203 @@
1
+ // GENERATED FILE — do not edit. Run `bun run sdk:gen`.
2
+ // Source: apps/api/src/operations/*.ts (zod input schemas)
3
+ //
4
+ // What each call takes. These are the same schemas the API validates with,
5
+ // so a value TypeScript accepts here is a value the server accepts too.
6
+
7
+ /** Arguments for `sendMessage()`. */
8
+ export interface SendMessageParams {
9
+ /** Recipient — a phone number in international format (+5511988887777) or a WhatsApp address
10
+ * (5511988887777@s.whatsapp.net, an @lid contact, or a @g.us group). */
11
+ to: string;
12
+ /** Which of your WhatsApp numbers sends this — its id or its phone number. Optional: with a
13
+ * single connected number it is resolved for you. */
14
+ from?: string;
15
+ /** The message body. */
16
+ text: string;
17
+ /** Render a preview card for the first link in the text. Default: on. */
18
+ linkPreview?: boolean;
19
+ }
20
+
21
+ /** Arguments for `sendMedia()`. */
22
+ export interface SendMediaParams {
23
+ /** Recipient — a phone number in international format (+5511988887777) or a WhatsApp address
24
+ * (5511988887777@s.whatsapp.net, an @lid contact, or a @g.us group). */
25
+ to: string;
26
+ /** Which of your WhatsApp numbers sends this — its id or its phone number. Optional: with a
27
+ * single connected number it is resolved for you. */
28
+ from?: string;
29
+ /** Which kind of media bubble to send. */
30
+ type: "image" | "video" | "audio" | "document";
31
+ /** Public https URL of the file — the better option. We stream the download and forward the
32
+ * bytes, so the size of the file never rides in your request. */
33
+ url?: string;
34
+ /** The file as base64, bare or as a `data:<mime>;base64,…` URI. Base64 inflates a request by a
35
+ * third, so prefer `url` for anything but small files. */
36
+ base64?: string;
37
+ /** Overrides the detected content type, e.g. `image/webp`. */
38
+ mimeType?: string;
39
+ /** Text shown under the media. Images and videos only — WhatsApp has no caption field for
40
+ * documents or audio. */
41
+ caption?: string;
42
+ /** Documents: the filename WhatsApp shows on the bubble. */
43
+ fileName?: string;
44
+ /** Voice notes: duration, so the bubble shows the right length. */
45
+ seconds?: number;
46
+ }
47
+
48
+ /** Arguments for `sendBatch()`. */
49
+ export interface SendBatchParams {
50
+ /** Recipients, up to 25. Each gets its own message; no duplicates. */
51
+ to: string[];
52
+ /** Which of your WhatsApp numbers sends this — its id or its phone number. Optional: with a
53
+ * single connected number it is resolved for you. */
54
+ from?: string;
55
+ /** The message body — the same for everyone. */
56
+ text: string;
57
+ }
58
+
59
+ /** Arguments for `sendLocation()`. */
60
+ export interface SendLocationParams {
61
+ /** Recipient — a phone number in international format (+5511988887777) or a WhatsApp address
62
+ * (5511988887777@s.whatsapp.net, an @lid contact, or a @g.us group). */
63
+ to: string;
64
+ /** Which of your WhatsApp numbers sends this — its id or its phone number. Optional: with a
65
+ * single connected number it is resolved for you. */
66
+ from?: string;
67
+ latitude: number;
68
+ longitude: number;
69
+ /** Label shown on the pin. */
70
+ name?: string;
71
+ }
72
+
73
+ /** Arguments for `sendContact()`. */
74
+ export interface SendContactParams {
75
+ /** Recipient — a phone number in international format (+5511988887777) or a WhatsApp address
76
+ * (5511988887777@s.whatsapp.net, an @lid contact, or a @g.us group). */
77
+ to: string;
78
+ /** Which of your WhatsApp numbers sends this — its id or its phone number. Optional: with a
79
+ * single connected number it is resolved for you. */
80
+ from?: string;
81
+ /** Display name on the card. */
82
+ name: string;
83
+ /** The vCard payload, e.g. `BEGIN:VCARD\nVERSION:3.0\n…\nEND:VCARD`. */
84
+ vcard: string;
85
+ }
86
+
87
+ /** Arguments for `sendOtp()`. */
88
+ export interface SendOtpParams {
89
+ /** Recipient — a phone number in international format (+5511988887777) or a WhatsApp address
90
+ * (5511988887777@s.whatsapp.net, an @lid contact, or a @g.us group). */
91
+ to: string;
92
+ /** Which of your WhatsApp numbers sends this — its id or its phone number. Optional: with a
93
+ * single connected number it is resolved for you. */
94
+ from?: string;
95
+ /** Message body. Must contain `{code}`; `{minutes}` expands to the expiry. Default: “{code} is
96
+ * your verification code. It expires in {minutes} minutes.” */
97
+ template?: string;
98
+ /** How long the code stays valid. Default 300 (5 minutes). */
99
+ expiresInSeconds?: number;
100
+ }
101
+
102
+ /** Arguments for `checkOtp()`. */
103
+ export interface CheckOtpParams {
104
+ /** Recipient — a phone number in international format (+5511988887777) or a WhatsApp address
105
+ * (5511988887777@s.whatsapp.net, an @lid contact, or a @g.us group). */
106
+ to: string;
107
+ /** The code the user typed. */
108
+ code: string;
109
+ }
110
+
111
+ /** Arguments for `createNumber()`. */
112
+ export interface CreateNumberParams {
113
+ /** A label you choose, e.g. “Support” — only you ever see it. */
114
+ name: string;
115
+ /** Pull the phone's existing chat history on pairing. Off by default: it copies conversations
116
+ * we otherwise never see. */
117
+ historySync?: boolean;
118
+ }
119
+
120
+ /** Arguments for `checkNumber()`. */
121
+ export interface CheckNumberParams {
122
+ /** The phone number to look up, e.g. +5511988887777. */
123
+ phone: string;
124
+ /** Which of your WhatsApp numbers sends this — its id or its phone number. Optional: with a
125
+ * single connected number it is resolved for you. */
126
+ from?: string;
127
+ }
128
+
129
+ /** Arguments for `numberStatus()`. */
130
+ export interface NumberStatusParams {
131
+ /** Id of the number, from list_numbers. */
132
+ numberId: string;
133
+ }
134
+
135
+ /** Arguments for `pairNumber()`. */
136
+ export interface PairNumberParams {
137
+ /** Id of the number to pair, from list_numbers. */
138
+ numberId: string;
139
+ /** `qr` (default) or `code` for the type-in linking code. */
140
+ mode?: "qr" | "code";
141
+ /** Required with `mode: "code"` — the phone number being linked. */
142
+ phone?: string;
143
+ }
144
+
145
+ /** Arguments for `unpairNumber()`. */
146
+ export interface UnpairNumberParams {
147
+ /** Id of the number, from list_numbers. */
148
+ numberId: string;
149
+ }
150
+
151
+ /** Arguments for `deleteNumber()`. */
152
+ export interface DeleteNumberParams {
153
+ /** Id of the number to delete, from list_numbers. */
154
+ numberId: string;
155
+ }
156
+
157
+ /** Arguments for `typing()`. */
158
+ export interface TypingParams {
159
+ /** Recipient — a phone number in international format (+5511988887777) or a WhatsApp address
160
+ * (5511988887777@s.whatsapp.net, an @lid contact, or a @g.us group). */
161
+ to: string;
162
+ /** Which of your WhatsApp numbers sends this — its id or its phone number. Optional: with a
163
+ * single connected number it is resolved for you. */
164
+ from?: string;
165
+ /** `composing` shows the indicator, `paused` clears it. Default: composing. */
166
+ state?: "composing" | "paused";
167
+ /** Show “recording audio…” instead of “typing…”. */
168
+ recording?: boolean;
169
+ }
170
+
171
+ /** Arguments for `markRead()`. */
172
+ export interface MarkReadParams {
173
+ /** Recipient — a phone number in international format (+5511988887777) or a WhatsApp address
174
+ * (5511988887777@s.whatsapp.net, an @lid contact, or a @g.us group). */
175
+ to: string;
176
+ /** Which of your WhatsApp numbers sends this — its id or its phone number. Optional: with a
177
+ * single connected number it is resolved for you. */
178
+ from?: string;
179
+ /** Ids of the received messages to acknowledge. */
180
+ messageIds: string[];
181
+ }
182
+
183
+ /** Arguments for `setWebhook()`. */
184
+ export interface SetWebhookParams {
185
+ /** Where to POST events, e.g. https://api.yourapp.com/webhooks/whatsapp. */
186
+ url: string;
187
+ /** Which events to receive: message.created, message.status, number.connected,
188
+ * number.disconnected, number.logged_out. Defaults to all of them. */
189
+ events?:
190
+ | "message.created"
191
+ | "message.status"
192
+ | "number.connected"
193
+ | "number.disconnected"
194
+ | "number.logged_out"[];
195
+ /** Mint a new signing secret and return it — the old one stops working. */
196
+ rotateSecret?: boolean;
197
+ }
198
+
199
+ /** Arguments for `listWebhookDeliveries()`. */
200
+ export interface ListWebhookDeliveriesParams {
201
+ /** How many to return, newest first. Up to 50 (the default). */
202
+ limit?: number;
203
+ }
package/src/index.ts ADDED
@@ -0,0 +1,11 @@
1
+ // The published surface of `whatsapi`.
2
+ export { Whatsapi } from "./client.ts";
3
+ export type { RequestSpec, WhatsapiOptions } from "./client.ts";
4
+ export { WebhookSignatureError, WhatsapiError } from "./errors.ts";
5
+ export type { ClientErrorCode, WhatsapiErrorInit } from "./errors.ts";
6
+ export { verifyWebhook } from "./webhooks.ts";
7
+ export type { VerifyWebhookInput } from "./webhooks.ts";
8
+ export { DEFAULT_BASE_URL, SDK_VERSION } from "./generated/operations.ts";
9
+ // Every DTO, error code and event shape the API speaks.
10
+ export * from "./generated/contract.ts";
11
+ export type * from "./generated/params.ts";
@@ -0,0 +1,96 @@
1
+ // Receiving events: check the signature, then hand back a typed event.
2
+ //
3
+ // Verifying is not optional. The URL is public, so anything can POST to it —
4
+ // the `x-whatsapi-signature` header is the only thing that separates our
5
+ // delivery from someone else's forgery, and it signs the TIMESTAMP alongside
6
+ // the body so a captured payload cannot be replayed at you later.
7
+ import { WebhookSignatureError } from "./errors.ts";
8
+ import type { WebhookEvent } from "./generated/contract.ts";
9
+
10
+ export interface VerifyWebhookInput {
11
+ /** The RAW request body — the exact bytes we signed. A body that has been
12
+ * parsed and re-serialized will not match, even when it looks identical. */
13
+ payload: string | Uint8Array;
14
+ /** The `x-whatsapi-signature` header. */
15
+ signature: string | null | undefined;
16
+ /** The `whsec_` secret, shown once when the endpoint was registered. */
17
+ secret: string;
18
+ /** How old a delivery may be, in seconds. Default 300. */
19
+ toleranceSeconds?: number;
20
+ }
21
+
22
+ const encoder = new TextEncoder();
23
+
24
+ function hex(buffer: ArrayBuffer): string {
25
+ return Array.from(new Uint8Array(buffer), (b) => b.toString(16).padStart(2, "0")).join("");
26
+ }
27
+
28
+ /** Constant time in the length that matters: comparing hex digit by digit with
29
+ * `===` leaks how much of a guess was right through timing. */
30
+ function timingSafeEqual(a: string, b: string): boolean {
31
+ if (a.length !== b.length) return false;
32
+ let diff = 0;
33
+ for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
34
+ return diff === 0;
35
+ }
36
+
37
+ /** `t=<unix seconds>,v1=<hex>` — tolerant of spacing and of future `vN=` parts. */
38
+ function parseHeader(header: string): { timestamp: number; v1: string } | null {
39
+ let timestamp = NaN;
40
+ let v1 = "";
41
+ for (const part of header.split(",")) {
42
+ const [key, value = ""] = part.trim().split("=", 2);
43
+ if (key === "t") timestamp = Number(value);
44
+ if (key === "v1") v1 = value;
45
+ }
46
+ return Number.isFinite(timestamp) && v1 ? { timestamp, v1 } : null;
47
+ }
48
+
49
+ /**
50
+ * Verify a delivery and parse it.
51
+ *
52
+ * Throws `WebhookSignatureError` for anything that does not check out — answer
53
+ * 400 and log it. On success you get the event, discriminated on `type`:
54
+ *
55
+ * ```ts
56
+ * const event = await verifyWebhook({ payload: rawBody, signature: req.headers["x-whatsapi-signature"], secret });
57
+ * if (event.type === "message.created") reply(event.data.message.text);
58
+ * ```
59
+ *
60
+ * Deliveries retry, so the same `event.id` (also the `x-whatsapi-delivery`
61
+ * header) can arrive twice: record it and drop repeats.
62
+ */
63
+ export async function verifyWebhook(input: VerifyWebhookInput): Promise<WebhookEvent> {
64
+ const { payload, signature, secret, toleranceSeconds = 300 } = input;
65
+ if (!signature) throw new WebhookSignatureError("Missing x-whatsapi-signature header.");
66
+ if (!secret) throw new WebhookSignatureError("Missing webhook secret.");
67
+
68
+ const parsed = parseHeader(signature);
69
+ if (!parsed) throw new WebhookSignatureError("Malformed x-whatsapi-signature header.");
70
+
71
+ const age = Math.abs(Date.now() / 1000 - parsed.timestamp);
72
+ if (age > toleranceSeconds)
73
+ throw new WebhookSignatureError(
74
+ `Delivery is ${Math.round(age)}s old, outside the ${toleranceSeconds}s tolerance.`,
75
+ );
76
+
77
+ const body = typeof payload === "string" ? payload : new TextDecoder().decode(payload);
78
+ const key = await crypto.subtle.importKey(
79
+ "raw",
80
+ encoder.encode(secret),
81
+ { name: "HMAC", hash: "SHA-256" },
82
+ false,
83
+ ["sign"],
84
+ );
85
+ const expected = hex(
86
+ await crypto.subtle.sign("HMAC", key, encoder.encode(`${parsed.timestamp}.${body}`)),
87
+ );
88
+ if (!timingSafeEqual(expected, parsed.v1))
89
+ throw new WebhookSignatureError("Signature does not match — the body is not from us.");
90
+
91
+ try {
92
+ return JSON.parse(body) as WebhookEvent;
93
+ } catch {
94
+ throw new WebhookSignatureError("Delivery body is not JSON.");
95
+ }
96
+ }