Skip to main content

pilot-status (Python SDK)

Official Python SDK for the Pilot Status public API.

Installation

pip install pilot-status

Quickstart

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

import os

from pilot_status import PilotStatusClient

client = PilotStatusClient(
    api_key=os.environ["PILOT_STATUS_API_KEY"],
)

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

message = client.messages.get(accepted["id"])
print(message["status"])

Management (projects, API keys, numbers)

These endpoints create resources within the scope (project + environment) of the current api_key.

Projects

project = client.projects.create(
    {
        "name": "My Project",
        "description": "Optional description",
    }
)

projects = client.projects.list()

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.
regenerated = client.api_keys.regenerate_number("wn_1")
print(regenerated["key"])  # shown only once

keys = client.api_keys.list()
# With a number-scoped key this returns that number's masked keys (`ApiKeyListItem[]`).
# With a tenant-scoped key it returns a lean per-number list (`NumberApiKeyReveal[]`):
# { numberId, number, displayName, keyId, keyLast4, key, revealable } — `key` is the real usable value when `revealable` is true.

Message history

# Conversation history (both directions, every provider), newest first.
# This is how you read old messages — the webhook does not replay history.
page = client.messages.history(
    start_date="2026-07-01T00:00:00Z",
    end_date="2026-07-31T23:59:59Z",
    page_size=100,
)
print(page["total"], page["messages"][0]["providerTimestamp"])
# Number-scoped key required. Rows outside the PII window come back with
# content/media/peer nulled and "redacted": True — the envelope is kept.

Leads (Meta Lead Ads)

# Leads from Facebook / Instagram Instant Forms of the Pages linked to the key's
# number, newest first by when the person submitted the form.
page = client.leads.list(
    form_id="1234567890",          # also: ad_id=, page_id=
    start_date="2026-10-01T00:00:00Z",
    end_date="2026-10-31T23:59:59Z",
    page_size=100,
)
for lead in page["leads"]:
    print(lead["leadgenId"], lead["fullName"], lead["consentGiven"])

one = client.leads.get(page["leads"][0]["id"])["lead"]
# Number-scoped key required (`chat:read`). `consentGiven` is True / False / None —
# None means unknown, never a refusal. A lead hidden by the number's PII policy comes
# back with "redacted": True, name/e-mail/phone None and `fields` empty; ids and ad
# attribution are kept. `notice` is set when the number is RELAY_ONLY.

Numbers (WhatsApp)

all_numbers = client.numbers.list()
# JSON array: id, instanceName, primaryLink/secondaryLink, apiKeys refs — no upstream tokens

created = client.numbers.create(
    {
        "name": "My WhatsApp",
        "number": "+5511999999999",
    }
)
# created["qrcodeBase64"], created["pairingCode"] (letter code or None)

refreshed = client.numbers.connect(created["instance"]["id"])
# refreshed["qrcodeBase64"], refreshed["pairingCode"]

status = client.numbers.get_status(created["instance"]["id"])
print(status["state"])
# status["stale"] is False for a live reading (with "checkedAt") and True when
# the provider did not answer and this is the last remembered state (with
# "lastKnownAt"). A 503 raises with body["code"]: PROVIDER_NOT_CONFIGURED is
# permanent (stop polling), UPSTREAM_TIMEOUT / UPSTREAM_ERROR are worth retrying.

detail = client.numbers.get(created["instance"]["id"])
print(detail["settings"]["appliesTo"]["advanced"])  # "evolution-go" | "none"

Per-number settings

numbers.update() is a PARTIAL patch: 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
client.numbers.update_settings(number_id, {"webhookHistoricalMessages": False})

# stop WhatsApp Channel (@newsletter) posts from becoming conversations,
# stored messages and webhook deliveries
client.numbers.update_settings(number_id, {"ignoreNewsletters": True})

client.numbers.update(
    number_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 None resets one to the provider default. On a Meta number they are stored but never applied — that is what settings["appliesTo"]["advanced"] == "none" means.

They are also pushed to the number's connected instances, reported in settingsSync ({applied, failed, skipped}). Best-effort: a disconnected instance keeps the persisted value and picks it up on its next provisioning.

Deleting, restoring and purging

delete() is a 30-day soft delete: the device is logged out right away and the number is kept until restorableUntil, then purged for good. purge() is the permanent delete — immediate and irreversible (history, chats, templates and the number's API keys go with it).

removed = client.numbers.delete(number_id)
print(removed["restorableUntil"])  # ISO-8601 deadline for restore()

client.numbers.restore(number_id)  # gives back the number, NOT the connection:
client.numbers.connect(number_id)  # pair again after restoring

# Delete-and-recreate the SAME phone: create() answers 409 NUMBER_SOFT_DELETED
# while the number is in the window, so purge it first.
client.numbers.delete(number_id)
client.numbers.purge(number_id)

Purging saves nothing compared to delete(): on Pay As You Go a deleted number is already out of the next cycle's charge, and on the other plans paid capacity only drops through a scheduled reduction of extra numbers. Use a tenant-scoped key — a key bound to the deleted number stops authenticating once the number is deleted.

Paying the extra-number charge of a number

A number past the workspace's paid capacity (an extra number) is charged when it first connects. If that charge is refused, the number stays connected but sending from it is blocked — messages.send() raises a 409 NUMBER_BLOCKED with reason: "extra_charge_unpaid" — until the charge is paid. Both numbers.list() and numbers.get(id) say so at the top level of each number:

  • extraChargeUnpaidSince — ISO timestamp of the block; None when the number is not blocked.
  • extraChargeDisconnectAt — ISO timestamp at which the number is disconnected if it stays unpaid (7 days after the block). Always None on a Meta Cloud API number, which is never disconnected for this.

numbers.pay(id) is "pay now" (POST /v1/numbers/{id}/pay, no body). With a card on file it charges it. It needs a tenant-scoped API key with the billing:manage permission. A wallet top-up or a newly saved card also retries the charge on its own, without this call.

from pilot_status import PilotStatusHttpError

blocked = [n for n in client.numbers.list() if n["extraChargeUnpaidSince"] is not None]

for n in blocked:
    try:
        result = client.numbers.pay(n["id"])
        # "paid": charged, the number can send again.
        # "not_required": nothing was owed for it — safe to call again.
        print(n["id"], result["status"])
    except PilotStatusHttpError as e:
        # 402 has no exception class of its own: branch on the status and on body["code"].
        code = e.body.get("code") if isinstance(e.body, dict) else None
        if e.status == 402 and code == "PAYMENT_FAILED":
            # The card was refused. declineCode is the issuer's reason, when it gave one.
            print(e.body.get("declineCode"), e.body.get("proratedTotal"), e.body.get("currency"))
        elif e.status == 402 and code == "INSUFFICIENT_FUNDS":
            pass  # no credits and no usable card: add credits or a card, then pay again
        elif e.status == 409 and code == "PAYMENT_IN_PROGRESS":
            pass  # another payment of this workspace is running: call again in a few seconds
        elif e.status == 502 and code == "PAYMENT_PROVIDER_ERROR":
            pass  # not a refusal: the charge could not be concluded and the server retries it
        else:
            raise  # 404 NUMBER_NOT_FOUND, 403 NUMBER_SCOPE_NOT_ALLOWED / PERMISSION_DENIED
Status body["code"] Raised as Meaning
402 PAYMENT_FAILED PilotStatusHttpError Charge refused. May carry declineCode, proratedTotal, currency.
402 INSUFFICIENT_FUNDS PilotStatusHttpError No credits and no usable card.
502 PAYMENT_PROVIDER_ERROR ServerError The charge could not be concluded; the server retries it.
409 PAYMENT_IN_PROGRESS ConflictError Another payment of the workspace is running.
404 NUMBER_NOT_FOUND NotFoundError No such number in this workspace.
403 NUMBER_SCOPE_NOT_ALLOWED / PERMISSION_DENIED ForbiddenError Key is not tenant-scoped, or lacks billing:manage.

Every class above is a PilotStatusHttpError, so the single except catches them all. The SDK never retries a POST on its own, so pay() sends exactly one request. The body shape is typed as NumberPayErrorBody, the result as NumberPayResult.

Analytics

stats = client.analytics.get_dashboard_stats(tz="America/Sao_Paulo")
print(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 client and WhatsApp. Settings/permissions/pre_accept 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 realtime_session (full-duplex PCM16 WebSocket).

Numbers on any other provider get 400 FEATURE_NOT_SUPPORTED. call_id 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)
perm = client.calls.get_permissions("+5511999999999")
if perm["permission"]["status"] == "no_permission":
    client.calls.request_permission("+5511999999999", text="May we call you?")
    # the user's reply arrives as the call.permission_updated webhook

# 2. Start a business-initiated call (sdp = offer from your WebRTC client)
call = client.calls.initiate({"to": "+5511999999999", "sdp": offer_sdp})

# 3. Answer an inbound call (after the call.ringing webhook)
inbound = client.calls.get("wacid.ABGG...", include_sdp=True)
# feed inbound["sdpOffer"] to your WebRTC client, produce the answer, then:
client.calls.accept(inbound["id"], answer_sdp)

# Other controls
client.calls.reject("wacid.ABGG...")
client.calls.terminate("wacid.ABGG...")

# History + settings (settings are Meta-only)
calls = client.calls.list(limit=25)["calls"]
settings = client.calls.get_settings()
client.calls.update_settings({"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)
call = client.calls.initiate({"to": "+5511999999999"})

# Answer an inbound call (after the call.ringing webhook) — no sdp
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)
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
# (plain WebSocket transport, NOT WebRTC; token is single-use, ~2 min)
session = client.calls.realtime_session(call["id"], "talk")

Phone Lines

client.phone_lines requires a tenant-scoped API key (a number-scoped key is refused). The verification routes need phone_lines:purchase, which only the workspace OWNER has; the activation audio needs phone_lines:read. Every verification call returns the same VerificationState: { status, canPurchase, verification } with status one of NONE, PENDING_CONTACT, NEEDS_DOCUMENT, IN_REVIEW, APPROVED, REJECTED.

Account verification

import base64
import uuid

state = client.phone_lines.verification.get()
print(state["status"], state["canPurchase"])

with open("contrato-social.pdf", "rb") as f:
    document = "data:application/pdf;base64," + base64.b64encode(f.read()).decode()

# Idempotency-Key is REQUIRED. Generate it once and REUSE THE SAME KEY when you
# retry this exact request (timeout, network error): the server then returns the
# current state with replayed=True instead of submitting twice.
key = str(uuid.uuid4())
result = client.phone_lines.verification.start(
    document_type="CNPJ",                    # "CPF" | "CNPJ"
    document_number="12.345.678/0001-90",
    contact_email="finance@acme.com.br",
    contact_whatsapp="+55 11 99999-8888",    # country + area code
    document=document,                        # data URL: PDF/JPG/PNG/WEBP, <= 10 MB
    idempotency_key=key,
)
result["state"]["status"]    # usually "PENDING_CONTACT": codes went to the required channels
result["replayed"]           # True when the response is a replay (Idempotent-Replayed: true)

# Contact channels in the PATH are lower-case ("whatsapp" | "email");
# in the response body they are UPPERCASE ("EMAIL" | "WHATSAPP").
client.phone_lines.verification.send_contact_code("whatsapp")   # resend (60 s cooldown)
state = client.phone_lines.verification.confirm_contact("whatsapp", "123456")
state = client.phone_lines.verification.confirm_contact("email", "654321")

# When the document came back UNREADABLE/UNCLEAR, or the status is NEEDS_DOCUMENT:
state = client.phone_lines.verification.submit_document(document)

Errors are the usual typed exceptions with body["code"], e.g. ConflictError IDEMPOTENCY_KEY_REUSED (same key, different body) / IDEMPOTENCY_KEY_IN_USE, RateLimitError RATE_LIMITED (10 submissions/hour, retryAfterSeconds) / CONTACT_CODE_COOLDOWN, ValidationError CONTACT_CODE_INVALID / CONTACT_CODE_EXPIRED.

Activation audio

from pilot_status import NotFoundError

try:
    audio = client.phone_lines.get_activation_audio("act_...")
except NotFoundError as e:
    print(e.body["code"])   # ACTIVATION_NOT_FOUND
else:
    audio.content_type      # e.g. "audio/mpeg"
    audio.content_length    # int | None
    audio.file_name         # from Content-Disposition, or None
    with open(audio.file_name or "activation.mp3", "wb") as f:
        f.write(audio.data)

⚠️ The recording speaks the activation code, which is a credential: never log it, never embed it in a public page. audio.data is left out of repr() on purpose.

Phone-line webhooks

Phone-line events come from a separate webhook registry, signed like the numbers webhooks (x-pilot-status-signature, HMAC-SHA256 hex over the raw body) but with the phone-line secret (plwh_...). Envelope: { id, event, createdAt, data }. They are typed as PhoneLineWebhookEvent (phone_line.purchased, audio_received, code_received, code_failed, renewed, payment_failed, suspended, returned, canceled, verification_updated) and are not accepted by parse_customer_webhook. audioUrl (may be None) points to the audio route above — fetch it with your API key.

Webhooks (parse / validation)

from pilot_status import is_unknown_customer_webhook_event, parse_customer_webhook

def handler(payload: dict):
    event = parse_customer_webhook(payload)

    # An event newer than this SDK version: acknowledge it (2xx), never fail on it.
    if is_unknown_customer_webhook_event(event):
        return

    if event["event"] == "message.failed":
        print(event["data"]["errorMessage"])

    if event["event"] == "call.ended":
        # call.* payloads are FLAT (no "data" wrapper)
        print(event["status"], event.get("duration"))

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. parse_customer_webhook does not raise on an event name it does not know: it returns {"type": "unknown", "event", "data", "raw"} (UnknownCustomerWebhookEvent) — event and data as received (data is None for a flat event), raw the whole body, nothing validated. Answer it with a 2xx and ignore it; is_unknown_customer_webhook_event(event) tells it apart (no known event has a top-level type).

It still raises WebhookParseError on a body that is not an object with a string event, and on a known event whose fields break its contract. Pass unknown_events="raise" to keep the old behaviour. is_customer_webhook_event 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.
  • message.received includes fromMe (boolean).
  • message.group is delivered for inbound group messages (includes groupName).
  • message.newsletter is delivered for inbound channel messages (JID ending in @newsletter).
  • 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, lead.received, call.ringing, call.connected, call.ended, call.missed, call.permission_updated.
  • lead.received: data is the whole lead — { leadId, leadgenId, whatsappNumberId, pageId, pageName?, formId, formName?, adId?, adsetId?, campaignId?, adName?, adsetName?, campaignName?, platform?, isOrganic?, createdAt, fullName?, email?, phone?, fields: [{ key, label?, values }], consentGiven?, conversationId? }. Use leadgenId (Meta's lead id) as the idempotency key; leadId is the id for client.leads.get. In fields, label is absent when it could not be resolved; on client.leads.* it is always present, falling back to the key.
  • 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 None and status NO_PERMISSION | TEMPORARY | PERMANENT.

Metadata

Release files for pilot-status 1.12.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for pilot-status 1.12.0
File Size Uploaded
pilot_status-1.12.0.tar.gz 70.4 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for pilot-status 1.12.0
File Interpreter ABI Platform
pilot_status-1.12.0-py3-none-any.whl Python 3 none any Details

Total release size: 117.9 kB

Release files / pilot_status-1.12.0.tar.gz

Download URL pilot_status-1.12.0.tar.gz
Size 70.4 kB
Tags Source
SHA-256 checksum
How to use checksums
b96e850bb504706501b356d1290f08a24881cf4c8913cb6ecd8fa184ff8f0cdb
BLAKE2b-256 checksum
How to use checksums
e41ac52a4c6a3ac842b8ed7826a94497052ea02b85aeaafe77cd33952aa163e7
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 11, 2026.

Transparency log

Release files / pilot_status-1.12.0-py3-none-any.whl

Download URL pilot_status-1.12.0-py3-none-any.whl
Size 47.5 kB
Tags Python 3
SHA-256 checksum
How to use checksums
900128c769bc5fb0b667ad3c7e0d9b201c929355584a7dded973595d456bd48f
BLAKE2b-256 checksum
How to use checksums
e50fcb20dfa2929efe38c37d04b632554c6711d7a0e61e5c96b93e63138a0bc6
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 11, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

1.12.0 This release

2 release files

1.9.0

2 release files

1.8.0

2 release files

1.7.0

2 release files

1.6.0

2 release files

1.5.0

2 release files

1.4.0

2 release files

1.3.0

2 release files

1.1.0

2 release files

1.0.8

2 release files

0.1.4

2 release files

0.1.3

2 release files

0.1.2

2 release files

0.1.1

2 release files

0.1.0

2 release files

0.0.9

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page