@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.
- package/LICENSE +21 -0
- package/README.md +149 -0
- package/dist/client.d.ts +35 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +169 -0
- package/dist/client.js.map +1 -0
- package/dist/errors.d.ts +40 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +42 -0
- package/dist/errors.js.map +1 -0
- package/dist/generated/contract.d.ts +318 -0
- package/dist/generated/contract.d.ts.map +1 -0
- package/dist/generated/contract.js +32 -0
- package/dist/generated/contract.js.map +1 -0
- package/dist/generated/operations.d.ts +240 -0
- package/dist/generated/operations.d.ts.map +1 -0
- package/dist/generated/operations.js +285 -0
- package/dist/generated/operations.js.map +1 -0
- package/dist/generated/params.d.ts +177 -0
- package/dist/generated/params.d.ts.map +1 -0
- package/dist/generated/params.js +7 -0
- package/dist/generated/params.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/webhooks.d.ts +28 -0
- package/dist/webhooks.d.ts.map +1 -0
- package/dist/webhooks.js +73 -0
- package/dist/webhooks.js.map +1 -0
- package/package.json +47 -0
- package/src/client.ts +204 -0
- package/src/errors.ts +71 -0
- package/src/generated/contract.ts +402 -0
- package/src/generated/operations.ts +371 -0
- package/src/generated/params.ts +203 -0
- package/src/index.ts +11 -0
- package/src/webhooks.ts +96 -0
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@whatsapi.sh/sdk",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Official TypeScript client for whatsapi.sh — send WhatsApp messages, OTPs and media over a flat REST API.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"homepage": "https://whatsapi.sh",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"whatsapp",
|
|
9
|
+
"whatsapp-api",
|
|
10
|
+
"messaging",
|
|
11
|
+
"otp",
|
|
12
|
+
"webhooks",
|
|
13
|
+
"sdk"
|
|
14
|
+
],
|
|
15
|
+
"type": "module",
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"types": "./dist/index.d.ts",
|
|
19
|
+
"default": "./dist/index.js"
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"dist",
|
|
24
|
+
"src",
|
|
25
|
+
"!src/__tests__"
|
|
26
|
+
],
|
|
27
|
+
"sideEffects": false,
|
|
28
|
+
"publishConfig": {
|
|
29
|
+
"access": "public"
|
|
30
|
+
},
|
|
31
|
+
"engines": {
|
|
32
|
+
"node": ">=20"
|
|
33
|
+
},
|
|
34
|
+
"scripts": {
|
|
35
|
+
"build": "tsc -p tsconfig.build.json",
|
|
36
|
+
"prepack": "bun run build",
|
|
37
|
+
"release": "bun run typecheck && bun run test && bun publish",
|
|
38
|
+
"typecheck": "tsc --noEmit",
|
|
39
|
+
"lint": "eslint .",
|
|
40
|
+
"test": "vitest --run"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@types/node": "^24.12.0",
|
|
44
|
+
"typescript": "^5.9.3",
|
|
45
|
+
"vitest": "^4.1.2"
|
|
46
|
+
}
|
|
47
|
+
}
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
// The transport under every generated method: one request, one `{ data }`
|
|
2
|
+
// unwrap, one error taxonomy.
|
|
3
|
+
//
|
|
4
|
+
// The retry policy is the opinionated part, and it is deliberately narrow:
|
|
5
|
+
// READS retry, WRITES never do. There is no idempotency key on /v1, so a
|
|
6
|
+
// retried send is a second WhatsApp message to a real person and a second unit
|
|
7
|
+
// off the quota — worse than the failure it was papering over. Writes come back
|
|
8
|
+
// as a WhatsapiError carrying `code` and `retryAfter`, and the caller decides.
|
|
9
|
+
import { WhatsapiError, type ClientErrorCode } from "./errors.ts";
|
|
10
|
+
import type { ErrorCode } from "./generated/contract.ts";
|
|
11
|
+
import { DEFAULT_BASE_URL, GeneratedOperations, SDK_VERSION } from "./generated/operations.ts";
|
|
12
|
+
|
|
13
|
+
export interface WhatsapiOptions {
|
|
14
|
+
/** A `wa_` key from the dashboard. */
|
|
15
|
+
apiKey: string;
|
|
16
|
+
/** Defaults to the hosted API; point it at a self-hosted deployment if you run one. */
|
|
17
|
+
baseUrl?: string;
|
|
18
|
+
/** Per-attempt timeout in ms. Default 30000. */
|
|
19
|
+
timeout?: number;
|
|
20
|
+
/** Extra attempts for READ calls only. Default 2, 0 disables. */
|
|
21
|
+
maxRetries?: number;
|
|
22
|
+
/** Swap the fetch implementation (tests, proxies, a custom agent). */
|
|
23
|
+
fetch?: typeof globalThis.fetch;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** What a generated method hands the transport. */
|
|
27
|
+
export interface RequestSpec {
|
|
28
|
+
method: "GET" | "POST" | "PUT" | "DELETE";
|
|
29
|
+
/** Template with `:name` placeholders naming the params that fill them. */
|
|
30
|
+
path: string;
|
|
31
|
+
pathParams?: readonly string[];
|
|
32
|
+
/** Values the ROUTE fixes rather than the caller. */
|
|
33
|
+
fixed?: Readonly<Record<string, unknown>>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
type Params = Record<string, unknown>;
|
|
37
|
+
|
|
38
|
+
/** A Retry-After we would honor beyond this is not a retry, it is a hang — the
|
|
39
|
+
* caller gets the error (with `retryAfter` on it) and schedules it themselves. */
|
|
40
|
+
const MAX_RETRY_DELAY_MS = 10_000;
|
|
41
|
+
|
|
42
|
+
/** How the API answers when it never got to answer at all. */
|
|
43
|
+
function statusCode(status: number): ErrorCode {
|
|
44
|
+
if (status === 503) return "SERVICE_UNAVAILABLE";
|
|
45
|
+
if (status === 404) return "NOT_FOUND";
|
|
46
|
+
if (status === 401) return "UNAUTHORIZED";
|
|
47
|
+
return "INTERNAL_ERROR";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Seconds from `Retry-After` (delta form) or the error's own details. */
|
|
51
|
+
function retryAfterOf(header: string | null, details: unknown): number | undefined {
|
|
52
|
+
const fromDetails =
|
|
53
|
+
typeof details === "object" && details !== null
|
|
54
|
+
? (details as { retryAfterSecs?: unknown }).retryAfterSecs
|
|
55
|
+
: undefined;
|
|
56
|
+
if (typeof fromDetails === "number" && Number.isFinite(fromDetails)) return fromDetails;
|
|
57
|
+
const seconds = header === null ? NaN : Number(header);
|
|
58
|
+
return Number.isFinite(seconds) ? seconds : undefined;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
62
|
+
|
|
63
|
+
export class Whatsapi extends GeneratedOperations {
|
|
64
|
+
private readonly apiKey: string;
|
|
65
|
+
private readonly baseUrl: string;
|
|
66
|
+
private readonly timeout: number;
|
|
67
|
+
private readonly maxRetries: number;
|
|
68
|
+
private readonly fetchImpl: typeof globalThis.fetch;
|
|
69
|
+
|
|
70
|
+
constructor(options: WhatsapiOptions | string) {
|
|
71
|
+
super();
|
|
72
|
+
const opts = typeof options === "string" ? { apiKey: options } : options;
|
|
73
|
+
if (!opts.apiKey)
|
|
74
|
+
throw new TypeError(
|
|
75
|
+
"whatsapi: an API key is required — create one at https://app.whatsapi.sh/keys",
|
|
76
|
+
);
|
|
77
|
+
this.apiKey = opts.apiKey;
|
|
78
|
+
// Trailing slashes would double up against the leading slash of a path.
|
|
79
|
+
this.baseUrl = (opts.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
|
|
80
|
+
this.timeout = opts.timeout ?? 30_000;
|
|
81
|
+
this.maxRetries = opts.maxRetries ?? 2;
|
|
82
|
+
const impl = opts.fetch ?? globalThis.fetch;
|
|
83
|
+
if (typeof impl !== "function")
|
|
84
|
+
throw new TypeError(
|
|
85
|
+
"whatsapi: no global fetch — use Node 20+, or pass `fetch` in the options.",
|
|
86
|
+
);
|
|
87
|
+
// Bound: an unbound global fetch throws "Illegal invocation" in browsers.
|
|
88
|
+
this.fetchImpl = opts.fetch ?? impl.bind(globalThis);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
protected async request<T>(spec: RequestSpec, params: object = {}): Promise<T> {
|
|
92
|
+
// Reads are the only calls we may repeat: a re-sent message is a second
|
|
93
|
+
// message to a real person, and no header on /v1 makes it idempotent.
|
|
94
|
+
const retries = spec.method === "GET" ? this.maxRetries : 0;
|
|
95
|
+
for (let attempt = 0; ; attempt++) {
|
|
96
|
+
try {
|
|
97
|
+
return await this.attempt<T>(spec, params);
|
|
98
|
+
} catch (error) {
|
|
99
|
+
if (!(error instanceof WhatsapiError) || attempt >= retries) throw error;
|
|
100
|
+
const delay = this.retryDelay(error, attempt);
|
|
101
|
+
if (delay === undefined) throw error;
|
|
102
|
+
await sleep(delay);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** How long to wait before repeating a read, or undefined to give up now. */
|
|
108
|
+
private retryDelay(error: WhatsapiError, attempt: number): number | undefined {
|
|
109
|
+
const worthRepeating =
|
|
110
|
+
error.code === "NETWORK_ERROR" ||
|
|
111
|
+
error.code === "TIMEOUT" ||
|
|
112
|
+
error.code === "RATE_LIMIT_EXCEEDED" ||
|
|
113
|
+
// A quota clears when the month rolls over and a pairing cooldown
|
|
114
|
+
// runs for minutes: both are answers, not hiccups.
|
|
115
|
+
(error.status >= 500 && error.code !== "GATEWAY_TIMEOUT");
|
|
116
|
+
if (!worthRepeating) return undefined;
|
|
117
|
+
if (error.retryAfter !== undefined) {
|
|
118
|
+
const ms = error.retryAfter * 1000;
|
|
119
|
+
return ms > MAX_RETRY_DELAY_MS ? undefined : ms;
|
|
120
|
+
}
|
|
121
|
+
// Exponential with jitter, so a fleet of clients does not resynchronize
|
|
122
|
+
// onto the same second after an outage.
|
|
123
|
+
return Math.min(250 * 2 ** attempt, 4_000) * (0.5 + Math.random());
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
private async attempt<T>(spec: RequestSpec, params: object): Promise<T> {
|
|
127
|
+
const rest: Params = { ...params };
|
|
128
|
+
let path = spec.path;
|
|
129
|
+
for (const name of spec.pathParams ?? []) {
|
|
130
|
+
const value = rest[name];
|
|
131
|
+
if (value === undefined || value === null || value === "")
|
|
132
|
+
throw new TypeError(`whatsapi: \`${name}\` is required`);
|
|
133
|
+
delete rest[name];
|
|
134
|
+
path = path.replace(`:${name}`, encodeURIComponent(String(value)));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const url = new URL(this.baseUrl + path);
|
|
138
|
+
const hasBody = spec.method === "POST" || spec.method === "PUT";
|
|
139
|
+
if (!hasBody)
|
|
140
|
+
for (const [key, value] of Object.entries(rest))
|
|
141
|
+
if (value !== undefined && value !== null) url.searchParams.set(key, String(value));
|
|
142
|
+
|
|
143
|
+
const headers: Record<string, string> = {
|
|
144
|
+
authorization: `Bearer ${this.apiKey}`,
|
|
145
|
+
accept: "application/json",
|
|
146
|
+
"x-whatsapi-client": `whatsapi-js/${SDK_VERSION}`,
|
|
147
|
+
};
|
|
148
|
+
if (hasBody) headers["content-type"] = "application/json";
|
|
149
|
+
|
|
150
|
+
let response: Response;
|
|
151
|
+
try {
|
|
152
|
+
response = await this.fetchImpl(url.toString(), {
|
|
153
|
+
method: spec.method,
|
|
154
|
+
headers,
|
|
155
|
+
...(hasBody
|
|
156
|
+
? { body: JSON.stringify({ ...rest, ...(spec.fixed ?? {}) }) }
|
|
157
|
+
: undefined),
|
|
158
|
+
signal: AbortSignal.timeout(this.timeout),
|
|
159
|
+
});
|
|
160
|
+
} catch (cause) {
|
|
161
|
+
const timedOut = cause instanceof Error && cause.name === "TimeoutError";
|
|
162
|
+
throw new WhatsapiError({
|
|
163
|
+
code: (timedOut ? "TIMEOUT" : "NETWORK_ERROR") satisfies ClientErrorCode,
|
|
164
|
+
status: 0,
|
|
165
|
+
message: timedOut
|
|
166
|
+
? `whatsapi: no answer within ${this.timeout}ms — the call may still have gone through.`
|
|
167
|
+
: `whatsapi: could not reach ${url.host}.`,
|
|
168
|
+
cause,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const requestId = response.headers.get("x-request-id") ?? undefined;
|
|
173
|
+
const text = await response.text();
|
|
174
|
+
let payload: unknown;
|
|
175
|
+
try {
|
|
176
|
+
payload = text ? (JSON.parse(text) as unknown) : undefined;
|
|
177
|
+
} catch {
|
|
178
|
+
payload = undefined;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (response.ok) return (payload as { data: T }).data;
|
|
182
|
+
|
|
183
|
+
const envelope =
|
|
184
|
+
typeof payload === "object" && payload !== null
|
|
185
|
+
? (payload as { error?: Record<string, unknown> }).error
|
|
186
|
+
: undefined;
|
|
187
|
+
const details = envelope?.details;
|
|
188
|
+
throw new WhatsapiError({
|
|
189
|
+
code: (envelope?.code as ErrorCode | undefined) ?? statusCode(response.status),
|
|
190
|
+
status: response.status,
|
|
191
|
+
// A proxy or a cold start can answer HTML; say what actually came back
|
|
192
|
+
// rather than pretending the body was ours.
|
|
193
|
+
message:
|
|
194
|
+
typeof envelope?.message === "string"
|
|
195
|
+
? envelope.message
|
|
196
|
+
: `whatsapi: ${spec.method} ${path} failed with ${response.status}.`,
|
|
197
|
+
messageKey: typeof envelope?.messageKey === "string" ? envelope.messageKey : undefined,
|
|
198
|
+
params: envelope?.params as Record<string, string | number> | undefined,
|
|
199
|
+
details: envelope ? details : text.slice(0, 500) || undefined,
|
|
200
|
+
retryAfter: retryAfterOf(response.headers.get("retry-after"), details),
|
|
201
|
+
requestId,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Every failure this client can raise, as one class.
|
|
2
|
+
//
|
|
3
|
+
// The API answers refusals with a machine-readable code (see ErrorCode), and
|
|
4
|
+
// acting on the code is the whole point: some clear by waiting, one clears only
|
|
5
|
+
// by paying, and one — GATEWAY_TIMEOUT — must never be retried at all. Collapse
|
|
6
|
+
// that into `Error("Request failed")` and the caller is left guessing, which in
|
|
7
|
+
// practice means re-sending a message that already arrived.
|
|
8
|
+
import type { ErrorCode } from "./generated/contract.ts";
|
|
9
|
+
|
|
10
|
+
/** Failures that never reached the API, so they carry no server code. */
|
|
11
|
+
export type ClientErrorCode = "NETWORK_ERROR" | "TIMEOUT";
|
|
12
|
+
|
|
13
|
+
export interface WhatsapiErrorInit {
|
|
14
|
+
code: ErrorCode | ClientErrorCode;
|
|
15
|
+
message: string;
|
|
16
|
+
/** HTTP status, or 0 when no answer arrived. */
|
|
17
|
+
status: number;
|
|
18
|
+
messageKey?: string;
|
|
19
|
+
params?: Record<string, string | number>;
|
|
20
|
+
details?: unknown;
|
|
21
|
+
/** Seconds until the call is worth making again, when the API said. */
|
|
22
|
+
retryAfter?: number;
|
|
23
|
+
/** `X-Request-ID` — quote it in a support request and we can find the call. */
|
|
24
|
+
requestId?: string;
|
|
25
|
+
cause?: unknown;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class WhatsapiError extends Error {
|
|
29
|
+
readonly code: ErrorCode | ClientErrorCode;
|
|
30
|
+
readonly status: number;
|
|
31
|
+
readonly messageKey?: string;
|
|
32
|
+
readonly params?: Record<string, string | number>;
|
|
33
|
+
readonly details?: unknown;
|
|
34
|
+
readonly retryAfter?: number;
|
|
35
|
+
readonly requestId?: string;
|
|
36
|
+
|
|
37
|
+
constructor(init: WhatsapiErrorInit) {
|
|
38
|
+
super(init.message, init.cause !== undefined ? { cause: init.cause } : undefined);
|
|
39
|
+
this.name = "WhatsapiError";
|
|
40
|
+
this.code = init.code;
|
|
41
|
+
this.status = init.status;
|
|
42
|
+
this.messageKey = init.messageKey;
|
|
43
|
+
this.params = init.params;
|
|
44
|
+
this.details = init.details;
|
|
45
|
+
this.retryAfter = init.retryAfter;
|
|
46
|
+
this.requestId = init.requestId;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The id a timed-out send went out with.
|
|
50
|
+
*
|
|
51
|
+
* `GATEWAY_TIMEOUT` means WhatsApp did not confirm in time — NOT that the
|
|
52
|
+
* message failed. It was accepted upstream and most likely delivered, so
|
|
53
|
+
* sending it again sends it twice. Watch for this id on the
|
|
54
|
+
* `message.status` webhook instead. */
|
|
55
|
+
get messageId(): string | undefined {
|
|
56
|
+
const details = this.details;
|
|
57
|
+
if (this.code !== "GATEWAY_TIMEOUT" || typeof details !== "object" || details === null)
|
|
58
|
+
return undefined;
|
|
59
|
+
const id = (details as { messageId?: unknown }).messageId;
|
|
60
|
+
return typeof id === "string" ? id : undefined;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A delivery whose signature did not check out — treat the body as hostile and
|
|
65
|
+
* answer 400. Never a WhatsapiError: nothing here came from a call we made. */
|
|
66
|
+
export class WebhookSignatureError extends Error {
|
|
67
|
+
constructor(message: string) {
|
|
68
|
+
super(message);
|
|
69
|
+
this.name = "WebhookSignatureError";
|
|
70
|
+
}
|
|
71
|
+
}
|