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.

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.11.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.11.0
File Size Uploaded
pilot_status-1.11.0.tar.gz 64.2 kB Details

Built distribution (wheel)

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

Total release size: 109.1 kB

Release files / pilot_status-1.11.0.tar.gz

Download URL pilot_status-1.11.0.tar.gz
Size 64.2 kB
Tags Source
SHA-256 checksum
How to use checksums
9f277fcba1386b61f31559d1ae0bcb92eeb083da5e97ea7411cf41a8944cf1bf
BLAKE2b-256 checksum
How to use checksums
084a0694b46bb8ea121037d6880837d62577c4c3da2789128894cefea02648e8
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 8, 2026.

Transparency log

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

Download URL pilot_status-1.11.0-py3-none-any.whl
Size 44.9 kB
Tags Python 3
SHA-256 checksum
How to use checksums
51a76d5d4f1ed1416a42525e817a9a11ac98420b347716d7b13a16c58e43a234
BLAKE2b-256 checksum
How to use checksums
4489ffd6baace81ded86ea8d520dcc5f821234e63d1e41ff79eba0a39e6d46b9
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 8, 2026.

Transparency log

Release history Release notifications | RSS feed

1.12.0

2 release files

This release

1.11.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