Metadata-Version: 2.4
Name: pilot-status
Version: 1.3.0
Summary: Official Python SDK for the Pilot Status public API.
Author: Pilot Status
License: MIT
Project-URL: Homepage, https://pilotstatus.com.br
Project-URL: Repository, https://github.com/pilot-status/pilot-status
Project-URL: Issues, https://github.com/pilot-status/pilot-status/issues
Keywords: pilot-status,whatsapp,api,sdk,python
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Requires-Python: >=3.10
Description-Content-Type: text/markdown
Requires-Dist: typing-extensions>=4.8.0
Provides-Extra: dev
Requires-Dist: pytest; extra == "dev"
Requires-Dist: pytest-cov; extra == "dev"

# pilot-status (Python SDK)

Official Python SDK for the Pilot Status public API.

## Installation

```bash
pip install pilot-status
```

## Quickstart

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

```python
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

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

projects = client.projects.list()
```

### API keys

```python
# 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.
```

### Numbers (WhatsApp)

```python
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.
```

## Analytics

```python
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.

```python
# 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:

```python
# 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)

```python
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`.
