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.
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")
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.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file pilot_status-1.5.0.tar.gz.
File metadata
- Download URL: pilot_status-1.5.0.tar.gz
- Upload date:
- Size: 36.7 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
731ff79d351aae132ec0c4ddc50caa2018d5f57eca0c92a645986f970e926236
|
|
| MD5 |
4fc5b3312b088a683839e5d967de494d
|
|
| BLAKE2b-256 |
c73694957c2e404184eddc8277399bc921ed6031067559ac1def9278cc855196
|
Provenance
The following attestation bundles were made for pilot_status-1.5.0.tar.gz:
Publisher:
publish-pypi.yml on oismaelash/pilot-status-nextjs-typescript-frontend-backend
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
pilot_status-1.5.0.tar.gz -
Subject digest:
731ff79d351aae132ec0c4ddc50caa2018d5f57eca0c92a645986f970e926236 - Sigstore transparency entry: 2589294366
- Sigstore integration time:
-
Permalink:
oismaelash/pilot-status-nextjs-typescript-frontend-backend@05666ed891cca526d658d065e96b2b4b6500d2a2 -
Branch / Tag:
refs/tags/sdk-python-v1.5.0 - Owner: https://github.com/oismaelash
-
Access:
private
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish-pypi.yml@05666ed891cca526d658d065e96b2b4b6500d2a2 -
Trigger Event:
push
-
Statement type:
File details
Details for the file pilot_status-1.5.0-py3-none-any.whl.
File metadata
- Download URL: pilot_status-1.5.0-py3-none-any.whl
- Upload date:
- Size: 28.5 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
32d674dccf3ccce891dfb51c3bd195411013829d5c219b9a8d8f33f014cb220d
|
|
| MD5 |
51138ff4163264be381e7480c3e1764c
|
|
| BLAKE2b-256 |
f46662892fc6cba252a35006883babddb16845450e83665c2062f6a8ba38676f
|
Provenance
The following attestation bundles were made for pilot_status-1.5.0-py3-none-any.whl:
Publisher:
publish-pypi.yml on oismaelash/pilot-status-nextjs-typescript-frontend-backend
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
pilot_status-1.5.0-py3-none-any.whl -
Subject digest:
32d674dccf3ccce891dfb51c3bd195411013829d5c219b9a8d8f33f014cb220d - Sigstore transparency entry: 2589294454
- Sigstore integration time:
-
Permalink:
oismaelash/pilot-status-nextjs-typescript-frontend-backend@05666ed891cca526d658d065e96b2b4b6500d2a2 -
Branch / Tag:
refs/tags/sdk-python-v1.5.0 - Owner: https://github.com/oismaelash
-
Access:
private
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish-pypi.yml@05666ed891cca526d658d065e96b2b4b6500d2a2 -
Trigger Event:
push
-
Statement type: