npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@whatsapi.sh/sdk

v0.7.0

Published

Official TypeScript client for whatsapi.sh. Send WhatsApp messages, OTPs and media over a flat REST API.

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/sdk

Send 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 URL

pairNumber 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_TIMEOUT leaves delivery unconfirmed. WhatsApp did not confirm in time. When error.messageId is present, watch for it on the message.status webhook to learn whether the message arrived. Check the chat before sending again. Sending again with the same idempotencyKey gets 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 lower timeout, or pass { timeoutMs } to one call, and a slow send ends in TIMEOUT instead, with no id to watch for.
  • INVALID_RESPONSE means a 2xx came 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.details holds the first 500 characters of the body, and error.requestId is set when the answer carried one.
  • PAYMENT_REQUIRED never clears by waiting, unlike RATE_LIMIT_EXCEEDED and QUOTA_EXCEEDED, which carry a retryAfter.
  • QUOTA_EXCEEDED names its ceiling in error.quotaScope. day and month are your account's message limits. newConversations means 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 waits retryAfter seconds and asks again with the same key. If it still gets a 409 after maxRetries, 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 5xx or no answer;
  • a write with a key, after a 5xx or no answer, or a 409 saying the first call is still running;
  • any call refused with 408, 425 or 429, 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.