@whatsapi.sh/sdk
v0.7.0
Published
Official TypeScript client for whatsapi.sh. Send WhatsApp messages, OTPs and media over a flat REST API.
Maintainers
Readme
@whatsapi.sh/sdk
The official TypeScript client for whatsapi.sh. Send WhatsApp messages, media and login codes through numbers you pair yourself, and receive signed events back.
Every method, type and doc comment in this package is generated from the API's own operation definitions, so what TypeScript tells you here is what the server does.
npm install @whatsapi.sh/sdkSend a message
import { Whatsapi } from "@whatsapi.sh/sdk";
const wa = new Whatsapi({ apiKey: process.env.WHATSAPI_KEY! });
const message = await wa.sendMessage({
to: "+5511988887777",
text: "Your table is ready.",
});
console.log(message.status, message.quotaRemaining);The key comes from the dashboard. With a single
connected number you never name a sender. With several, pass from.
Messages you start come back queued, even for known contacts. With pacing on,
only a reply to someone who messaged this number in the last 24 hours goes out now.
Queued messages are accepted and charged, then leave one at a time, 15 to 25 minutes
apart by default. Each gap starts from the previous message's actual release, so an
old schedule cannot cause a burst after a delay. message.scheduledAt is an estimate.
The message.sent webhook arrives when the message leaves, and message.id is the
queue id until then:
if (message.status === "queued") {
console.log(`estimated send time: ${message.scheduledAt}`);
const waiting = await wa.listQueuedMessages({});
// Changed your mind? Cancel before the send begins.
const canceled = await wa.cancelQueuedMessage({ id: message.id });
console.log(canceled.refunded); // Whether the message quota was returned.
}Every batch message uses the full configured gap, including replies and known contacts.
Single group messages and sendOtp skip the queue. skipQueue: true, or disabling the
number's pacing, also bypasses it; then spacing the sends is your job. Dashboard sends
follow the number's settings too.
If the number disconnects or is restricted, messages that have not left stay queued. They resume with the configured spacing when it reconnects. A timeout or interrupted send can leave delivery unknown: we do not replay it or refund it as unsent. Check the chat and delivery receipts before sending it again. Read the send queue for the full contract.
Pair a number
A number is a slot on your account, plus a real WhatsApp session linked to it. Create the slot, then pair it by QR code (scan it from WhatsApp → Linked devices) or by an 8-character code you type into the phone:
const number = await wa.createNumber({ name: "Support" });
const pairing = await wa.pairNumber({ numberId: number.id, mode: "qr" });
if (pairing.qrCode) showToUser(pairing.qrCode); // a PNG data URLpairNumber is also the poll. Call it every few seconds while state is "pairing":
WhatsApp swaps the QR every 20 seconds, so always show the newest qrCode. state turns
"connected" when the scan lands. The codes stop after expiresIn seconds; call again
to start a new round.
If the number is restricted, first check that WhatsApp works normally on the phone.
Temporary restrictions often clear after about an hour, but may take up to 24 hours.
Follow the notice on the phone; there is no fixed wait here. Only after the owner has
checked can you call pairNumber({ numberId, recoveryConfirmed: true }). Never confirm
recovery automatically, and do not keep reconnecting while the phone shows a restriction.
The restriction timestamp is kept for the number's recovery ramp.
Login codes
sendOtp generates the code, sends it, and gives it back to you. Verify it yourself,
or let us do it and keep nothing:
const otp = await wa.sendOtp({ to: "+5511988887777" });
// …later, with what the user typed:
await wa.checkOtp({ to: "+5511988887777", code: "123456" });A wrong or expired code throws OTP_INVALID instead of answering "not verified". A
check you forget to read can never look like a pass.
Using Supabase Auth? Its phone codes can go out from your number instead of by SMS, on Supabase Cloud or on a Supabase you host. Supabase still makes and checks each code; we only deliver it:
const { record, secret } = await wa.setSupabaseHook({ template: "Your Acme code is {code}" });
// Supabase Cloud: Authentication → Auth Hooks → add a Send SMS hook (HTTPS), paste both.
// Self-hosted: GOTRUE_HOOK_SEND_SMS_URI = record.url, GOTRUE_HOOK_SEND_SMS_SECRETS = secret
// You only see `secret` this once.Self-hosted, also set GOTRUE_HOOK_SEND_SMS_ENABLED: "true", then recreate Supabase's auth
service with docker compose up -d auth (a plain restart keeps the old values).
record.statusUrl answers { data: { ready } } to anyone, with no key, so a sign-in page
can hide the phone option while codes can't go out. Each code is a normal message, and the
first one to someone new uses one of that number's new conversations for the day. The full
setup is in
the Supabase guide.
Receive events
One endpoint per account receives all seven events, signed. Verify before you trust:
import { verifyWebhook, WebhookSignatureError } from "@whatsapi.sh/sdk";
app.post("/webhooks/whatsapp", express.raw({ type: "application/json" }), async (req, res) => {
try {
const event = await verifyWebhook({
payload: req.body, // the RAW body, not the parsed one
signature: req.header("x-whatsapi-signature"),
secret: process.env.WHATSAPI_WEBHOOK_SECRET!,
});
if (event.type === "message.created") {
await reply(event.data.number.id, event.data.message.text ?? "");
}
res.sendStatus(200);
} catch (error) {
res.sendStatus(error instanceof WebhookSignatureError ? 400 : 500);
}
});event.type narrows event.data, so the compiler knows a message.status event has
messageIds and a number.logged_out event does not. Deliveries retry, so the same
event.id can arrive twice. Record it and drop repeats.
Errors
Every failure is a WhatsapiError carrying the API's own code, plus status,
retryAfter and the requestId to quote at support.
import { WhatsapiError } from "@whatsapi.sh/sdk";
try {
await wa.sendMessage({ to, text });
} catch (error) {
if (!(error instanceof WhatsapiError)) throw error;
if (error.quotaScope === "newConversations") return tryAgainTomorrow(); // see below
if (error.code === "PAYMENT_REQUIRED") return askThemToUpgrade();
if (error.code === "NUMBER_NOT_CONNECTED") return askThemToPairAgain();
if (error.code === "GATEWAY_TIMEOUT") return watchFor(error.messageId); // see below
throw error;
}Four rules worth knowing before you write a retry loop of your own:
GATEWAY_TIMEOUTleaves delivery unconfirmed. WhatsApp did not confirm in time. Whenerror.messageIdis present, watch for it on themessage.statuswebhook to learn whether the message arrived. Check the chat before sending again. Sending again with the sameidempotencyKeygets the same answer back without a second send. With a new key, or with none, another send may duplicate the message. A send waits up to 4 minutes for this answer. Set a lowertimeout, or pass{ timeoutMs }to one call, and a slow send ends inTIMEOUTinstead, with no id to watch for.INVALID_RESPONSEmeans a2xxcame back that is not an answer from this API, such as a proxy's page. The call may have run, and a send may have reached the person, so this client did not send it again. Do not retry it blindly.error.detailsholds the first 500 characters of the body, anderror.requestIdis set when the answer carried one.PAYMENT_REQUIREDnever clears by waiting, unlikeRATE_LIMIT_EXCEEDEDandQUOTA_EXCEEDED, which carry aretryAfter.QUOTA_EXCEEDEDnames its ceiling inerror.quotaScope.dayandmonthare your account's message limits.newConversationsmeans this number already wrote to as many new people as its limit allows today. No plan lifts that one: try again after midnight, or send from another number.updateNumber({ numberId, newConversationsPerDay })sets a number's own limit, but going above the automatic one makes a ban more likely.
Retry a send safely
When a send fails with no answer (a timeout, a dropped connection, a 5xx), the message
may already have gone out. Give each send an idempotencyKey, an id you choose, such as
a UUID:
const message = await wa.sendMessage(
{ to: "+5511988887777", text: "Your table is ready." },
{ idempotencyKey: order.id },
);With a key, this client tries a failed send again for you, and the API answers the
retry with the first send's answer instead of sending twice. Keep the key with what you
are sending, so a retry after a crash uses the same one. Answers are kept for 24 hours.
Every send, checkOtp and createNumber take a key. The API ignores the key on any other
call, so TypeScript refuses one there, and plain JavaScript gets a TypeError before
anything is sent.
Two answers only a key can get:
CONFLICT(409): the first call with this key is still running. This client waitsretryAfterseconds and asks again with the same key. If it still gets a 409 aftermaxRetries, it throws: wait and call again with the same key.IDEMPOTENCY_KEY_REUSED(422): the key was already used for a different call. Use a new key.
What this client retries, twice by default (maxRetries):
- a read, after a
5xxor no answer; - a write with a key, after a
5xxor no answer, or a409saying the first call is still running; - any call refused with
408,425or429, because a refusal means nothing ran.
A 409 or 429 is retried only when its retryAfter is 10 seconds or less. The first
retry comes about 1 second later and the second about 2 seconds later, each moved at
random by up to half, or after retryAfter when that is longer.
A write without a key is never repeated after a 5xx or no answer: that would be a
second real message to a real person.
To stop a call yourself, pass { signal } from an AbortController. The call and any
retry of it stop, and the call throws the signal's reason rather than a WhatsapiError.
Agents
If you are wiring up an LLM rather than an app, you probably want the MCP server
instead of this package: the same operations, as tools, at
https://api.whatsapi.sh/mcp with the same wa_ key. See
the MCP reference.
Before you ship
whatsapi.sh runs on the unofficial WhatsApp protocol, the same one WhatsApp Web
uses. It is not the WhatsApp Business API, there is no Meta approval, and WhatsApp can
restrict or ban a number that behaves like a broadcaster. What gets numbers banned is
cold outreach, identical messages at speed, and large recipient lists. The send queue
above paces the part that is hardest to get right by hand, and every send except sendOtp
is performed the way a person would do it: online, reads, types. A code skips that act,
because someone is waiting for it. The rest is still yours. Message
people who expect you, vary what you write, and do not put a number you cannot afford
to lose on it. We tell you this before you pair, not after.
Requirements
Node 20+, or any runtime with fetch and Web Crypto: Bun, Deno, Cloudflare Workers, or
the browser, though a wa_ key does not belong in a browser. ESM only. On Node 22.12+
require("@whatsapi.sh/sdk") works too.
- Docs: docs.whatsapi.sh
- REST reference: docs.whatsapi.sh/reference
