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

@pilot-status/sdk

v0.13.0

Published

Official TypeScript SDK for the Pilot Status public API.

Readme

@pilot-status/sdk

Official TypeScript SDK for the Pilot Status public API.

Installation

npm i @pilot-status/sdk

Quickstart (Node.js / TypeScript)

Create an API key in the dashboard and use it only on the backend.

import { PilotStatusClient } from "@pilot-status/sdk";

const client = new PilotStatusClient({
  apiKey: process.env.PILOT_STATUS_API_KEY!,
});

const accepted = await client.messages.send({
  templateId: "onboarding-test",
  destinationNumber: "+5511999999999",
  variables: { name: "John" },
});

const message = await client.messages.get(accepted.id);
console.log(message.status);

// Conversation history (both directions, every provider), newest first.
// This is how you read old messages — the webhook does not replay history.
const history = await client.messages.history({
  startDate: "2026-07-01T00:00:00Z",
  endDate: "2026-07-31T23:59:59Z",
  pageSize: 100,
});
console.log(history.total, history.messages[0]?.providerTimestamp);

Client options

const client = new PilotStatusClient({
  apiKey: process.env.PILOT_STATUS_API_KEY!,
  // Point at staging or a local mock. Default: https://pilotstatus.com.br
  baseUrl: process.env.PILOT_STATUS_BASE_URL,
  // Sent as `x-whatsapp-number-id` on every request.
  whatsappNumberId: "wn_1",
});

// Per-call override — returns a sibling client, never mutates this one:
await client.withNumber("wn_2").chatwoot.get();

whatsappNumberId is required for the per-number endpoints when your credential is tenant-scoped: chatwoot.* and webhooks.logs() answer 400 NUMBER_REQUIRED without it. A number-scoped key already carries its number, and the header is ignored (the key wins).

Management (API keys, numbers, workspace)

API keys

// Regenerate the default key of one number (tenant-scoped). The new usable key
// is returned once — there is no "create another key" concept.
const regenerated = await client.apiKeys.regenerateNumber("wn_1");
console.log(regenerated.key); // shown only once

const keys = await client.apiKeys.list();
// The shape depends on the credential's scope — discriminate on `revealable`:
for (const k of keys) {
  if ("revealable" in k) {
    // TENANT-scoped: one row per number; `k.key` is the real value when revealable
  } else {
    // NUMBER-scoped: that number's masked keys
  }
}

Workspace (read-only)

const ws = await client.workspace.get();
console.log(ws.seatsUsed, ws.seatLimit); // pending invites already hold a seat

const members = await client.workspace.members.list(); // ACTIVE + PENDING

Inviting, changing a role and removing a member exist in /v1, but they require an OAuth principal: writing membership needs a user on the wire, and an x-api-key request has none. Those calls answer 403 MACHINE_CREDENTIAL_CANNOT_MANAGE_MEMBERS for any ps_ key, so this SDK deliberately does not expose them — use the dashboard or an OAuth connector.

Numbers (WhatsApp)

const created = await client.numbers.create({
  name: "My WhatsApp",
  number: "+5511999999999",
});
// created.qrcodeBase64 — QR image; created.pairingCode — letter code when available (else null)

const refreshed = await client.numbers.connect(created.instance.id);
// refreshed.qrcodeBase64, refreshed.pairingCode

const status = await client.numbers.getStatus(created.instance.id);

const detail = await client.numbers.get(created.instance.id);
console.log(detail.settings.appliesTo.advanced); // "evolution-go" | "none"

Per-number settings

numbers.update() is a PARTIAL patch of the number: retention policy and/or the settings block. There is no POST /v1/numbers/{id}/settings, and Evolution's syncFullHistory has no equivalent here.

// stop replaying the device's old messages into the webhook on every reconnect
await client.numbers.updateSettings(id, { webhookHistoricalMessages: false });

// stop WhatsApp Channel (@newsletter) posts from becoming conversations,
// stored messages and webhook deliveries
await client.numbers.updateSettings(id, { ignoreNewsletters: true });

await client.numbers.update(id, {
  piiMode: "STORE_X_DAYS",
  piiRetentionDays: 30,
  settings: { rejectCall: true, msgRejectCall: "I don't take calls here" },
});

historyImportEnabled, webhookHistoricalMessages and ignoreNewsletters apply to every provider. ignoreNewsletters is where a WhatsApp Channel post is refused: the provider exposes no @newsletter gate (unlike ignoreGroups, which it applies itself), so the post always arrives and is dropped on our side — before the conversation, the stored message and the webhook. On a Meta number channels never arrive at all. The other six are the Evolution GO advancedSettings, in the GO dialect (ignoreGroups, not the v2 groupsIgnore); passing null resets one to the provider default. On a Meta number they are stored but never applied — which is what settings.appliesTo.advanced === "none" tells you.

They are also pushed to the number's connected instances; settingsSync ({applied, failed, skipped}) reports that push. It is best-effort: a disconnected instance still keeps the persisted value and picks it up on its next provisioning.

Deleting a number is reversible for 30 days

const removed = await client.numbers.delete(id);
console.log(removed.restorableUntil); // end of the 30-day window

// Changed your mind — gives the row back, NOT the session:
await client.numbers.restore(id); // { requiresReconnect: true }
await client.numbers.connect(id); // scan the QR again

// The old destructive behaviour, when you really do delete-and-recreate:
await client.numbers.purge(id); // irreversible, cascades everything

// Just unpair the device, keeping id, keys, webhooks and history:
await client.numbers.logout(id);

Breaking change in 0.6.0. numbers.delete() used to destroy the number and free the phone for immediate reconnection. It is now a 30-day soft delete. Deleting a number still does not reduce paid capacity — the slot stays available to reconnect another number. To lower the bill, use subscription.scheduleRemoval() (applies next cycle).

Messages, conversations and the 24h window

// The message LOG (delivery status), newest first — not the chat thread
const log = await client.messages.list({ status: "FAILED,SENT", direction: "out" });

// Unread inbound — `total` is the real count, NOT messages.length
const { total, messages } = await client.messages.unread();

// Read receipts and typing indicators, without sending a message
await client.messages.markRead("+5511999999999");
await client.messages.typing({ to: "+5511999999999", state: "typing" });

// Conversations, newest activity first
const { conversations } = await client.conversations.list({ pageSize: 50 });

// Meta Lead Ads leads of the Pages linked to the number, newest first (number-scoped key).
// Filters: formId, adId, pageId, startDate, endDate (on the lead's own createdAt), page, pageSize.
const { leads, total, notice } = await client.leads.list({ formId: "123", pageSize: 100 });
const { lead } = await client.leads.get(leads[0].id); // 404 LEAD_NOT_FOUND for another number's lead
// On a RELAY_ONLY number a lead keeps ids, attribution and status, with `redacted: true`
// and fullName/email/phone/fields blanked; the response then carries `notice`.

// Can I send free-form right now?
const w = await client.serviceWindow.get("+5511999999999");
// Branch on windowType, not on `open`: web numbers always report open: true
if (w.windowType === "META_24H" && !w.open) {
  /* only an approved template will go through */
}

Templates, groups and webhooks

// Templates — `examples` is required, one per {{variable}}
await client.templates.create({
  name: "boas_vindas",
  body: "Olá {{nome}}",
  examples: { nome: "Alice" },
});

// Groups — E.164 digits, not JIDs, in `participants`
const group = await client.groups.create({ subject: "Support", participants: ["5511999999999"] });
await client.groups.promoteParticipants(group.id, ["5511999999999"]);
const { inviteUrl } = await client.groups.invite(group.id);

// Webhooks CRUD — an empty `events` list dispatches NOTHING; use ["*"] for all
const hook = await client.webhooks.create({
  url: "https://api.example.com/pilot-status",
  events: ["message.received"],
});
const attempts = await client.webhooks.logs(hook.id, { limit: 20 });

Chatwoot, billing and embed sessions

// Every /v1/chatwoot endpoint is per-number
const cw = client.withNumber("wn_1").chatwoot;
await cw.test({ instanceUrl: "https://app.chatwoot.com", accountId: "7" }); // dry run
await cw.connect({ instanceUrl: "https://app.chatwoot.com", accountId: "7", userAccessToken: t });

// A hosted payment page URL — nothing is charged until a human completes it
const { url } = await client.billing.createCheckout({ purpose: "wallet_topup", amount: 100 });

// Mint an embed token on YOUR backend, then hand it to the browser
const session = await client.embed.createSession({
  surface: "chat",
  whatsappNumberIds: ["wn_1"],
  allowedOrigins: ["https://app.yourcompany.com"],
});

Analytics

const stats = await client.analytics.getDashboardStats({ tz: "America/Sao_Paulo" });
console.log(stats.totalSent, stats.failureRate);

Calls (WhatsApp Business Calling)

Voice calls over the /v1/calls* endpoints, on two kinds of numbers:

  • Meta Cloud API numbers — signaling-only: initiate/accept carry the SDP (RFC 8866) produced by your WebRTC client, and audio flows directly between the browser and WhatsApp. Settings/permissions/preAccept are Meta-only.
  • Web (Pilot Status / unofficial) numbers — call media is handled server-side, so there is no SDP (omit sdp on initiate/accept). No call permission is required before initiate and there is no Meta per-minute billing. Extra media controls: play (stream an audio file into the call) and realtimeSession (full-duplex PCM16 WebSocket).

Numbers on any other provider get 400 FEATURE_NOT_SUPPORTED. callId arguments accept the Pilot Status id (call_...) or the provider call id (Meta wacid... / Evolution GO CallID).

Billing: on Meta numbers, business-initiated calls (BIC) are billed by Meta directly on your WABA — per minute, in 6-second pulses, only when answered; user-initiated calls (UIC) are free. Calls on web numbers have no Meta billing at all. Pilot Status does not charge for calls.

Web numbers are unofficial (QR-paired) WhatsApp sessions — call quality and availability depend on the paired device/session, and heavy automated calling carries the usual unofficial-number ban risk.

// 1. Permission first (required before calling a user)
const perm = await client.calls.getPermissions("+5511999999999");
if (perm.permission.status === "no_permission") {
  await client.calls.requestPermission({
    to: "+5511999999999",
    text: "May we call you about your order?",
  });
  // the user's reply arrives as the call.permission_updated webhook
}

// 2. Start a business-initiated call (sdp = offer from your WebRTC client)
const call = await client.calls.initiate({
  to: "+5511999999999",
  sdp: offerSdp,
  bizOpaqueCallbackData: "order-42",
});

// 3. Answer an inbound call (after the call.ringing webhook)
const inbound = await client.calls.get("wacid.ABGG...", { includeSdp: true });
// feed inbound.sdpOffer to your WebRTC client, produce the answer, then:
await client.calls.accept(inbound.id, answerSdp);

// Other controls
await client.calls.reject("wacid.ABGG...");
await client.calls.terminate("wacid.ABGG...");

// History + settings (settings are Meta-only)
const { calls } = await client.calls.list({ limit: 25 });
const settings = await client.calls.getSettings();
await client.calls.updateSettings({ status: "ENABLED" });

On a web (Pilot Status) number the same flow needs no SDP and no permission step, and you get server-side media controls:

// Start a call (no sdp, no permission step)
const call = await client.calls.initiate({ to: "+5511999999999" });

// Answer an inbound call (after the call.ringing webhook) — no sdp
await client.calls.accept(call.id);

// Stream an audio file into the active call (.mp3/.wav/.opus by URL;
// queued and played on connect when the call is not active yet)
await client.calls.play(call.id, "https://cdn.example.com/ivr-greeting.mp3");

// Full-duplex realtime audio: returns { wsUrl, token, expiresInSeconds }.
// Connect a WebSocket to wsUrl and exchange RAW binary PCM16 LE frames
// (this is a plain WebSocket transport, NOT WebRTC; token is single-use, ~2 min)
const session = await client.calls.realtimeSession(call.id, "talk");

Phone lines (client.phoneLines)

Every /v1/phone-lines/* route requires a tenant-scoped API key — a number-scoped key is refused. The verification routes also require phone_lines:purchase, which only an OWNER's key has; the activation audio requires phone_lines:read (ADMIN and up). These routes do not use whatsappNumberId.

Workspace verification (unlocks buying lines)

import { randomUUID } from "node:crypto";
import { readFile } from "node:fs/promises";
import { toDataUri } from "@pilot-status/sdk";

const current = await client.phoneLines.verification.get();
// { status: "NONE" | "PENDING_CONTACT" | "NEEDS_DOCUMENT" | "IN_REVIEW" | "APPROVED" | "REJECTED",
//   canPurchase, verification: null | { ...masked details } }

// Generate the key ONCE per submission and keep it: a retry must reuse it.
const idempotencyKey = randomUUID();
const started = await client.phoneLines.verification.start(
  {
    documentType: "CNPJ",
    documentNumber: "12345678000199",
    contactEmail: "[email protected]",
    contactWhatsapp: "+55 11 99999-8888",
    document: toDataUri(await readFile("contrato-social.pdf"), "application/pdf"),
  },
  { idempotencyKey },
);
console.log(started.state.status, started.replayed); // "PENDING_CONTACT", false

// Codes go to every channel in verification.contact.requiredChannels.
await client.phoneLines.verification.confirmContact("email", "123456");
await client.phoneLines.verification.confirmContact("whatsapp", "654321");

// Resend a code (60 s cooldown; codes expire in 10 min):
await client.phoneLines.verification.sendContactCode("whatsapp");

// When document.status is UNREADABLE/UNCLEAR, or status is NEEDS_DOCUMENT:
await client.phoneLines.verification.submitDocument(
  toDataUri(await readFile("cartao-cnpj.png"), "image/png"),
);
  • Idempotency-Key is required on start() (the idempotencyKey option, ≤255 chars). If the call times out or fails on the network, retry with the same key: the server answers the current state (HTTP 200, Idempotent-Replayed: true → replayed: true; start() resolves to { state, replayed }) instead of starting again and re-sending codes. The same key with a different body is 409 IDEMPOTENCY_KEY_REUSED; while the first request is still running, 409 IDEMPOTENCY_KEY_IN_USE. The SDK never retries a POST on its own.
  • The path argument of sendContactCode() / confirmContact() is lower-case ("whatsapp" | "email"), while the channels inside the state are UPPERCASE ("WHATSAPP" | "EMAIL").
  • document is a data:<mime>;base64,... URI (PDF, JPG, PNG or WEBP, up to 10 MB). toDataUri(bytes, mime) builds it from a Buffer/Uint8Array.
  • Starts and document submissions share a limit of 10 per hour per workspace (429 RATE_LIMITED, retryAfterSeconds in the body). Errors carry body.code.

Activation audio

import { writeFile } from "node:fs/promises";

const audio = await client.phoneLines.getActivationAudio(activationId);
// { data: ArrayBuffer, contentType: "audio/mpeg", contentLength: 48213, fileName: "activation-<id>.mp3" }
await writeFile(audio.fileName ?? "activation.mp3", new Uint8Array(audio.data));

⛔ The recording speaks the activation code, which is a credential. Do not log the bytes, do not put them in a public bucket or an inline player on a shared page. An unknown id, another workspace's id, a request without a recording and a line already returned or canceled are all 404 ACTIVATION_NOT_FOUND (NotFoundError). This is the URL the audioUrl field of the phone-line webhooks points to.

Phone-line webhooks

Phone-line events come from a separate webhook registry and are not handled by parseCustomerWebhook. They are signed the same way (x-pilot-status-signature, HMAC-SHA256 hex over the raw body), keyed with the phone-line webhook secret (plwh_...), so verifyWebhookSignature works with that secret. The body is { id, event, createdAt, data }, typed as the discriminated union PhoneLineWebhookEvent:

import type { PhoneLineWebhookEvent } from "@pilot-status/sdk";

const event = JSON.parse(raw) as PhoneLineWebhookEvent; // after verifying the signature
switch (event.event) {
  case "phone_line.code_received":
    console.log(event.data.code, event.data.audioUrl); // audioUrl: string | null
    break;
  case "phone_line.returned":
    console.log(event.data.reason); // "unpaid" | "canceled_by_customer" | "verification_rejected"
    break;
}

Events: phone_line.purchased, phone_line.audio_received, phone_line.code_received, phone_line.code_failed, phone_line.renewed, phone_line.payment_failed, phone_line.suspended, phone_line.returned, phone_line.canceled, phone_line.verification_updated. name is the line's display name (the formatted number when the line has none).

Receiving webhooks (verify, then parse)

parseCustomerWebhook proves the shape of an event, nothing else — anyone on the internet can POST that JSON at your URL. Verify the signature first:

import {
  isUnknownCustomerWebhookEvent,
  parseCustomerWebhook,
  verifyWebhookSignature,
} from "@pilot-status/sdk";

export async function handler(req: Request) {
  // The RAW body. Do NOT re-serialize: the HMAC is over the bytes we sent.
  const raw = await req.text();

  if (
    !verifyWebhookSignature({
      payload: raw,
      signature: req.headers.get("x-pilot-status-signature"),
      secret: process.env.PILOT_STATUS_WEBHOOK_SECRET!,
    })
  ) {
    return new Response("invalid signature", { status: 401 });
  }

  const event = parseCustomerWebhook(JSON.parse(raw));

  // An event newer than this SDK version: acknowledge it, never fail on it.
  if (isUnknownCustomerWebhookEvent(event)) {
    return new Response("ok");
  }

  if (event.event === "message.failed") {
    console.log(event.data.errorMessage);
  }

  return new Response("ok");
}

The signature is HMAC-SHA256 hex over the raw body, in x-pilot-status-signature. A webhook created without a secret gets no header — then there is nothing to verify, and accepting it anyway is your call to make explicitly.

Events this SDK version does not know

The platform adds events, and a webhook subscribed to "*" receives a new one the day it ships — before you upgrade. parseCustomerWebhook does not throw on an event name it does not know: it returns { type: "unknown", event, data, raw } (UnknownCustomerWebhookEvent) — event as received, data as received (undefined for a flat event), raw the whole body, nothing validated. Answer it with a 2xx and ignore it.

It still throws on a body that is not an object with a string event, and on a known event whose fields break its contract.

Because the unknown case's event is a plain string, narrow it out with isUnknownCustomerWebhookEvent(event) before you switch on event.event; after that the union narrows exactly as before. To keep the old behaviour (and the old return type), pass { unknownEvents: "throw" }. isCustomerWebhookEvent stays strict: false for an unknown event name.

Notes:

  • Customer webhook payloads do not include: projectSlug, lastMessageId. Optional correlationId (same as HTTP 202 when present) may appear on outbound status events and on message.reply / message.received when correlated to a prior send.
  • For outbound status events (message.sent, message.delivered, message.read, message.failed), messageId is the WhatsApp provider message id (key.id) and internalMessageId is the Pilot Status message id.
  • message.received includes fromMe (boolean).
  • message.group is delivered for inbound group messages (includes groupName).
  • message.newsletter is delivered for inbound WhatsApp channel / newsletter messages (@newsletter, includes newsletterId).
  • Supported events in the parser: message.sent, message.delivered, message.read, message.failed, message.reply, message.received, message.group, message.newsletter, number.created, number.connected, number.disconnected, number.removed, number.restored, call.ringing, call.connected, call.ended, call.missed, call.permission_updated, flow.response_received, lead.received. The same list is exported as CUSTOMER_WEBHOOK_EVENTS — it is derived from the parser's own union, and a test compares this line against it, so the two cannot drift.
  • flow.response_received carries the answers of a submitted WhatsApp Flow (interactive.nfm_reply), META numbers only. Wrapped in data like the message.* events: { event, data: { event, flowId, metaFlowId, flowToken, messageId, whatsappNumberId, from, receivedAt, response, responseRaw, flowMedia, submitted } }. Two fields need care. The number field is whatsappNumberId, not numberId — same value as everywhere else, different name on the wire, so a handler reading numberId gets undefined. And response and responseRaw are mutually exclusive: response is the parsed answer object and responseRaw is null; when the submission could not be parsed it is the other way round, and the raw string is the only copy of the answer that exists. flowId is null for a Flow built in Meta's Flow Manager and not yet synced here — normal, not an error.
  • Files attached inside a Flow form (PhotoPicker / DocumentPicker) are not in response. A media picker requires a data_exchange endpoint, so what response holds for such a field is a reference to Meta's CDN — which Meta deletes after 20 days. The re-hosted copy is the only way to reach the bytes, and it is published in the two places the answers are readable, in shapes that are similar and not the same:
    • On the event, data.flowMedia — null when nothing was attached, and never []. The asymmetry is deliberate: the payload stored in the webhook log of a RELAY_ONLY number is redacted field by field, and the redactor blanks any field that holds something. An empty array holds something, so it would be replaced by the "content unavailable" placeholder and read back as "there were attachments and we hid them"; null passes through untouched. flowMedia is also optional, and absent is not null — absent is a backend older than the field, which the parser accepts rather than rejecting every event from an older deployment over one field it cannot read.
    • On client.flows.listResponses(flowId) (GET /v1/flows/{id}/responses), responses[].media — always an array, empty when nothing was attached.
  • Both attachment shapes carry field, fileName, mimeType, status and url. The listResponses() one also carries sizeBytes; the event does not — do not read one shape off the other. Both element types are exported by name, and the name says which shape you have: import type { FlowResponseMedia, FlowResponseMediaAttachment } from "@pilot-status/sdk" — FlowResponseMedia is the listResponses() element (the one with sizeBytes), FlowResponseMediaAttachment the event one.
    • ⛔ url exists only when status === "STORED" — it is null on PENDING and on FAILED. The re-host is asynchronous, so the transfer is often still in flight at the instant the event is dispatched: a handler that reads { status: "PENDING", url: null } is reading the ordinary case, not a failure. The final URL comes from client.flows.listResponses(flowId). Publishing an optimistic URL in the event would hand you a link that 404s.
    • ⛔ No cdn_url and no encryption material travel, in either shape. Meta derives one AES key per file and ships it beside the reference; forwarding either would put the key that opens your customer's document into your webhook — and into its logs, and into anything downstream of them.
    • ⚠️ fileName may be the FIELD's name rather than a file name. A photo taken in the camera arrives without one, and the field's key is substituted so the stored object still has a readable name. From the outside the two cases are indistinguishable.
    • field is whatever the Flow's author named the component in the Flow JSON — photo_picker is Meta's documentation example, not a reserved word. Attachments are matched by shape, never by name.
  • What the person TYPED, screen by screen — data.submitted on the event, responses[].submitted on client.flows.listResponses(flowId). ⛔ It is not response. response is what your endpoint returned (the extension_message_response.params of the final exchange); submitted is what the respondent's device sent on the way there, captured as Pilot Status relayed each screen. Before this field, a data_exchange Flow's answers went to your endpoint and were kept nowhere — a real submission produced an event whose response was {"flow_token": "…"} and nothing else.
    • ⛔ A list, never merged into one object. Each data_exchange carries only the fields of ITS screen, and merging would let the last field with a repeated name win in silence. A screen that appears twice is not a duplicate — someone walked back and submitted it again, and both answers are there, in order.
    • Each entry is { screen, at, data, omitted }. screen is null when Meta named none; at is ISO-8601, when the exchange was relayed. data and omitted are complementary — one is null exactly when the other is not, never both, never neither.
    • ⛔ Nothing is ever truncated in silence. An exchange too large to store keeps its place and its screen name and says omitted: "DATA_TOO_LARGE"; a form with more exchanges than are kept ends with an entry saying omitted: "LIMIT_REACHED", which is always the last one. "UNREADABLE" means the stored bytes no longer parse. ⚠️ Treat omitted as an open string, not a union of those three: a reason your SDK version has not heard of must still arrive.
    • ⛔ Only data_exchange produces an entry, so the list is SHORTER than the number of calls your endpoint received. INIT carries the form's initial state (the params seeded at send time) and BACK is a navigation gesture; neither is something the person filled in.
    • ⛔ Nothing of what YOUR endpoint answered is captured — extension_message_response never appears anywhere in Pilot Status. What you returned is already response on the same event.
    • The two shapes differ in exactly one way, the same way the attachment shapes do: on the event submitted is null, never [], when nothing was captured (the redactor that blanks a RELAY_ONLY log replaces anything non-empty, so [] would read as "there were answers and we hid them"), and it is also optional — absent means a backend older than the field. On listResponses() it is always an array.
    • ⚠️ Empty is not "they typed nothing". A NAVIGATE-only Flow never calls an endpoint at all — its answers are wholly in response — and a number configured to relay and keep nothing captures none. The element type is exported: import type { FlowSubmittedExchange } from "@pilot-status/sdk", and it is the SAME type on both surfaces.
  • lead.received carries a whole lead from a Facebook / Instagram Instant Form (Meta Lead Ads), delivered on the number its Page is linked to — on any provider, since the lead arrives on the Page, not through the number. Wrapped in data: { event, data: { leadId, leadgenId, whatsappNumberId, pageId, pageName?, formId, formName?, adId?, adsetId?, campaignId?, adName?, adsetName?, campaignName?, platform?, isOrganic?, createdAt, fullName?, email?, phone?, fields, consentGiven?, conversationId? } } (types LeadReceivedEvent / LeadReceivedEventData / LeadReceivedField). Like the Flow event, the number field is whatsappNumberId; unlike it, there is no event repeated inside data. leadgenId is Meta's id and the natural idempotency key; leadId is ours and reads back with client.leads.get(leadId). createdAt is when the person submitted the form. fields[] is { key, label?, values } — values is always a list, and label is absent when it could not be resolved (on client.leads.* it is always present, falling back to the key). consentGiven: null means unknown (no consent checkbox configured, or not answered), never a refusal.
  • call.* payloads are flat (fields sit next to event, no data wrapper): { event, callId, externalCallId?, direction, status, from, to, timestamp, duration? }. duration (seconds) appears on call.ended only when the call was answered; call.permission_updated has callId/direction null and status NO_PERMISSION | TEMPORARY | PERMANENT.

Errors

For non-2xx responses, the SDK throws an HTTP error with status and, when available, body.

import { PilotStatusHttpError } from "@pilot-status/sdk";

try {
  await client.messages.send({
    templateId: "x",
    destinationNumber: "+5511999999999",
    variables: {},
  });
} catch (err) {
  if (err instanceof PilotStatusHttpError) {
    console.log(err.status, err.body);
  }
}