@pilot-status/sdk
v0.13.0
Published
Official TypeScript SDK for the Pilot Status public API.
Maintainers
Readme
@pilot-status/sdk
Official TypeScript SDK for the Pilot Status public API.
Installation
npm i @pilot-status/sdkQuickstart (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 + PENDINGInviting, 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, usesubscription.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/acceptcarry the SDP (RFC 8866) produced by your WebRTC client, and audio flows directly between the browser and WhatsApp. Settings/permissions/preAcceptare Meta-only. - Web (Pilot Status / unofficial) numbers — call media is handled
server-side, so there is no SDP (omit
sdponinitiate/accept). No call permission is required beforeinitiateand there is no Meta per-minute billing. Extra media controls:play(stream an audio file into the call) andrealtimeSession(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-Keyis required onstart()(theidempotencyKeyoption, ≤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 is409 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"). documentis adata:<mime>;base64,...URI (PDF, JPG, PNG or WEBP, up to 10 MB).toDataUri(bytes, mime)builds it from aBuffer/Uint8Array.- Starts and document submissions share a limit of 10 per hour per workspace
(
429 RATE_LIMITED,retryAfterSecondsin the body). Errors carrybody.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. OptionalcorrelationId(same as HTTP 202 when present) may appear on outbound status events and onmessage.reply/message.receivedwhen correlated to a prior send. - For outbound status events (
message.sent,message.delivered,message.read,message.failed),messageIdis the WhatsApp provider message id (key.id) andinternalMessageIdis the Pilot Status message id. message.receivedincludesfromMe(boolean).message.groupis delivered for inbound group messages (includesgroupName).message.newsletteris delivered for inbound WhatsApp channel / newsletter messages (@newsletter, includesnewsletterId).- 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 asCUSTOMER_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_receivedcarries the answers of a submitted WhatsApp Flow (interactive.nfm_reply), META numbers only. Wrapped indatalike themessage.*events:{ event, data: { event, flowId, metaFlowId, flowToken, messageId, whatsappNumberId, from, receivedAt, response, responseRaw, flowMedia, submitted } }. Two fields need care. The number field iswhatsappNumberId, notnumberId— same value as everywhere else, different name on the wire, so a handler readingnumberIdgetsundefined. AndresponseandresponseRaware mutually exclusive:responseis the parsed answer object andresponseRawisnull; 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.flowIdisnullfor 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 inresponse. A media picker requires adata_exchangeendpoint, so whatresponseholds 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—nullwhen nothing was attached, and never[]. The asymmetry is deliberate: the payload stored in the webhook log of aRELAY_ONLYnumber 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";nullpasses through untouched.flowMediais also optional, and absent is notnull— 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.
- On the event,
- Both attachment shapes carry
field,fileName,mimeType,statusandurl. ThelistResponses()one also carriessizeBytes; 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"—FlowResponseMediais thelistResponses()element (the one withsizeBytes),FlowResponseMediaAttachmentthe event one.- ⛔
urlexists only whenstatus === "STORED"— it isnullonPENDINGand onFAILED. 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 fromclient.flows.listResponses(flowId). Publishing an optimistic URL in the event would hand you a link that 404s. - ⛔ No
cdn_urland 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. - ⚠️
fileNamemay 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. fieldis whatever the Flow's author named the component in the Flow JSON —photo_pickeris Meta's documentation example, not a reserved word. Attachments are matched by shape, never by name.
- ⛔
- What the person TYPED, screen by screen —
data.submittedon the event,responses[].submittedonclient.flows.listResponses(flowId). ⛔ It is notresponse.responseis what your endpoint returned (theextension_message_response.paramsof the final exchange);submittedis what the respondent's device sent on the way there, captured as Pilot Status relayed each screen. Before this field, adata_exchangeFlow's answers went to your endpoint and were kept nowhere — a real submission produced an event whoseresponsewas{"flow_token": "…"}and nothing else.- ⛔ A list, never merged into one object. Each
data_exchangecarries 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 }.screenisnullwhen Meta named none;atis ISO-8601, when the exchange was relayed.dataandomittedare complementary — one isnullexactly 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 sayingomitted: "LIMIT_REACHED", which is always the last one."UNREADABLE"means the stored bytes no longer parse. ⚠️ Treatomittedas an open string, not a union of those three: a reason your SDK version has not heard of must still arrive. - ⛔ Only
data_exchangeproduces an entry, so the list is SHORTER than the number of calls your endpoint received.INITcarries the form's initial state (the params seeded at send time) andBACKis a navigation gesture; neither is something the person filled in. - ⛔ Nothing of what YOUR endpoint answered is captured —
extension_message_responsenever appears anywhere in Pilot Status. What you returned is alreadyresponseon the same event. - The two shapes differ in exactly one way, the same way the attachment shapes do: on the event
submittedisnull, never[], when nothing was captured (the redactor that blanks aRELAY_ONLYlog 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. OnlistResponses()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.
- ⛔ A list, never merged into one object. Each
lead.receivedcarries 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 indata:{ event, data: { leadId, leadgenId, whatsappNumberId, pageId, pageName?, formId, formName?, adId?, adsetId?, campaignId?, adName?, adsetName?, campaignName?, platform?, isOrganic?, createdAt, fullName?, email?, phone?, fields, consentGiven?, conversationId? } }(typesLeadReceivedEvent/LeadReceivedEventData/LeadReceivedField). Like the Flow event, the number field iswhatsappNumberId; unlike it, there is noeventrepeated insidedata.leadgenIdis Meta's id and the natural idempotency key;leadIdis ours and reads back withclient.leads.get(leadId).createdAtis when the person submitted the form.fields[]is{ key, label?, values }—valuesis always a list, andlabelis absent when it could not be resolved (onclient.leads.*it is always present, falling back to the key).consentGiven: nullmeans unknown (no consent checkbox configured, or not answered), never a refusal.call.*payloads are flat (fields sit next toevent, nodatawrapper):{ event, callId, externalCallId?, direction, status, from, to, timestamp, duration? }.duration(seconds) appears oncall.endedonly when the call was answered;call.permission_updatedhascallId/directionnullandstatusNO_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);
}
}