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.
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/acceptcarry the SDP (RFC 8866) produced by your WebRTC client, and audio flows directly between the client and WhatsApp. Settings/permissions/pre_acceptare 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) andrealtime_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 parse_customer_webhook
def handler(payload: dict):
event = parse_customer_webhook(payload)
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"))
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. message.receivedincludesfromMe(boolean).message.groupis delivered for inbound group messages (includesgroupName).message.newsletteris 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,call.ringing,call.connected,call.ended,call.missed,call.permission_updated. 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/directionNoneandstatusNO_PERMISSION|TEMPORARY|PERMANENT.
Metadata
Release files for pilot-status 1.9.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| pilot_status-1.9.0.tar.gz | 56.1 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| pilot_status-1.9.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 95.4 kB
Release files / pilot_status-1.9.0.tar.gz
| Download URL | pilot_status-1.9.0.tar.gz |
|---|---|
| Size | 56.1 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
20957e539bf9d5e8505d43a8895f25d3687bbc2977d531e2d1fde7f8cc041190
|
|
BLAKE2b-256 checksum How to use checksums |
295e428221610a66d4bfc05fe8e7c22078d369c690fe9ff19bc88f928d17a4dc
|
| 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 3, 2026.
Transparency logRelease files / pilot_status-1.9.0-py3-none-any.whl
| Download URL | pilot_status-1.9.0-py3-none-any.whl |
|---|---|
| Size | 39.3 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
1308734f6ad02c9d908be61bb8d4c35a46d1edef3460d015f8c2a0a582d772d3
|
|
BLAKE2b-256 checksum How to use checksums |
bc5f557e61fe5743fc6af24b608381822c19087cfb31db087cbe56347f273904
|
| 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 3, 2026.
Transparency log