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.

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})

client.numbers.update(
    number_id,
    {
        "piiMode": "STORE_X_DAYS",
        "piiRetentionDays": 30,
        "settings": {"rejectCall": True, "msgRejectCall": "I don't take calls here"},
    },
)

historyImportEnabled / webhookHistoricalMessages apply to every provider. 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/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")

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. 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, call.ringing, call.connected, call.ended, call.missed, call.permission_updated.
  • 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.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

pilot_status-1.4.0.tar.gz (35.9 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

pilot_status-1.4.0-py3-none-any.whl (28.1 kB view details)

Uploaded Python 3

File details

Details for the file pilot_status-1.4.0.tar.gz.

File metadata

  • Download URL: pilot_status-1.4.0.tar.gz
  • Upload date:
  • Size: 35.9 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for pilot_status-1.4.0.tar.gz
Algorithm Hash digest
SHA256 2fe6530afbceefe819e18115a1b17ed63f1c63100469ccb39e1e757ed2a66d5c
MD5 a7b467603fd7f0e9efe8357c1d95f02d
BLAKE2b-256 a91c9670f0046664e84d0237f4410a1e2e64a7c07c76abff8c53eadb8d9fe379

See more details on using hashes here.

Provenance

The following attestation bundles were made for pilot_status-1.4.0.tar.gz:

Publisher: publish-pypi.yml on oismaelash/pilot-status-nextjs-typescript-frontend-backend

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file pilot_status-1.4.0-py3-none-any.whl.

File metadata

  • Download URL: pilot_status-1.4.0-py3-none-any.whl
  • Upload date:
  • Size: 28.1 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for pilot_status-1.4.0-py3-none-any.whl
Algorithm Hash digest
SHA256 1f31e24459b8f4e3fc2b0549530d5b14bb52630a9abf85e7743043045c15e79d
MD5 bfb4ebf70c9eeef30ab2fb78858f16e8
BLAKE2b-256 2ca1d47940644d1944de4fa5bb21d179265b11b7db579ca8451e68e70c47b2c4

See more details on using hashes here.

Provenance

The following attestation bundles were made for pilot_status-1.4.0-py3-none-any.whl:

Publisher: publish-pypi.yml on oismaelash/pilot-status-nextjs-typescript-frontend-backend

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

1.5.0

2 files

This release

1.4.0 This release

2 files

1.3.0

2 files

1.1.0

2 files

1.0.8

2 files

0.1.4

2 files

0.1.3

2 files

0.1.2

2 files

0.1.1

2 files

0.1.0

2 files

0.0.9

2 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