Python
Python SDK
whire is the async Python client for the whole API: typed responses, automatic retries, and idempotency keys on every write, so a retried call never makes two records.
Install#
pip install "whire>=0.2"Python 3.11 or newer. It depends only on httpx and pydantic v2, and ships type hints.
Create a client#
from whire import WhireClient
# the sandbox: https://sandbox.whire.ai
client = WhireClient(environment="sandbox")
# production: a key is required
client = WhireClient(api_key="…", environment="production")
# your own deployment
client = WhireClient(base_url="https://payouts.example.com", api_key="…")The key can also come from WHIRE_API_KEY, and the target from WHIRE_BASE_URL or WHIRE_ENVIRONMENT. It is sent as X-API-Key. Use async with WhireClient(...) as client: or call await client.close() when you are done.
Every method is async. Arguments are keyword-only except the leading record id. Responses are pydantic models with snake_case attributes (payer.payer_id), Decimal money and timezone-aware datetimes.
Start every integration by asking what the deployment can do:
caps = await client.capabilities()
print(caps.settlement, caps.simulated) # True True on the sandboxsettlement says whether it moves money at all, simulated whether that money is simulated.
What is where#
| Namespace | Methods |
|---|---|
client.payers |
create, activate, suspend, add_funding_source, set_default_funding_source, get, list |
client.users |
create, add_payment_method, set_default_payment_method, create_beneficiary, get, list |
client.beneficiaries |
create, get, validate_iban |
client.mandates |
create, validate, revoke, get, list |
client.authorizations |
create, verify |
client.payouts |
create, submit, confirm, execute, get, list, record_event, wait, wait_for_kyc, wait_for_settlement |
client.simulation |
get, reset |
client.x402 |
supported, verify, settle, quote, pay |
client.mcp |
the MCP endpoint from the same client |
Each call maps to a REST endpoint; the field names are the same in snake_case. The Quickstart walks the first five.
The payout lifecycle#
create makes a draft under a mandate, submit sends it to KYC review, execute sends the money. On a deployment with a rail the KYC decision arrives by itself, so you poll for it. There are no webhooks.
payout = await client.payouts.create(
beneficiary_id=payee.beneficiary_id,
mandate_id=mandate.mandate_id,
amount="20.00", # Decimal, int, float or str; positive, two decimals at most
reason="Invoice 2026-091",
)
await client.payouts.submit(payout.payout_id)
payout = await client.payouts.wait_for_kyc(payout.payout_id, timeout=30)
if payout.status == "kyc_rejected": # final: create a new draft
raise SystemExit(payout.last_note)
execution = await client.payouts.execute(payout.payout_id)
if execution.status == "processing": # accepted; settlement is reported later
payout = await client.payouts.wait_for_settlement(
payout.payout_id, timeout=60
)wait_for_kyc polls until the payout leaves pending_kyc; wait_for_settlement until it leaves processing. Both raise WhireTimeoutError on expiry, carrying the last payout read. payouts.wait(id, until=lambda p: ...) is the general form.
confirm is an optional human confirmation between approved and execute. It settles nothing.
Execute outcomes#
A payout is executed at most once. If the rail gives no answer, the payment may or may not have gone out, and a retry is refused until the rail reports.
payouts.execute() returns a PayoutExecution or raises PayoutExecutionRefused.
| Outcome | Meaning | Do |
|---|---|---|
status == "paid", provider_reference set |
Settled. | Nothing. |
status == "processing" |
Accepted, settlement pending. | wait_for_settlement() |
status == "processing" and unresolved |
The rail did not confirm. | Do not resend. wait_for_settlement() until it reports paid or failed. |
PayoutExecutionRefused, execution_refused |
Refused before sending, or rejected by the rail (failed). |
Read the payout, then ask a human. |
PayoutExecutionRefused, already_executed |
Already sent. | Nothing. Never create a replacement automatically. |
from whire import PayoutExecutionRefused
try:
execution = await client.payouts.execute(payout.payout_id)
except PayoutExecutionRefused as e:
# failed, or unchanged with a note in its history
payout = await client.payouts.get(payout.payout_id)
print(e.error_code, e, payout.status)Idempotency and retries#
The SDK sends a fresh Idempotency-Key on every POST and reuses it, with the same body, on every retry of that call. For money-moving calls, pin the key yourself and store it with the payout id:
import uuid
from whire import AmbiguousResponseError, NetworkError
key = str(uuid.uuid4())
try:
execution = await client.payouts.execute(payout.payout_id, idempotency_key=key)
except AmbiguousResponseError:
raise # it may have been processed: read the payout first
except NetworkError as e:
# it never arrived; repeating it with the same key is safe
execution = await client.payouts.execute(
payout.payout_id, idempotency_key=e.idempotency_key
)The same key and body again replays the stored answer (.replayed is True); the same key with a different body is 422. Keys live 24 hours; see Idempotency.
Reads, keyed writes and the other safe calls are retried on network errors, 429, 409 and 5xx with jittered backoff (max_retries=3). A write that may have reached the server is never retried blindly: a read timeout raises AmbiguousResponseError straight away.
Errors#
Every exception derives from WhireError. str(e) is the server's sentence and e.to_agent_dict() is a JSON-safe dict an agent can act on: error, error_code, status_code, retryable, needs_user_action, is_input_error, suggestion.
| Exception | When | Retried |
|---|---|---|
AuthenticationError |
401: missing or wrong key |
no |
BadRequestError |
400: the message says what to change |
no |
NotFoundError |
unknown record (400) or route (404) |
no |
PayoutExecutionRefused |
execute refused |
no |
IdempotencyMismatchError |
422: key reused with another body |
no |
RateLimitError, ServerError |
429, 5xx |
yes |
NetworkError |
the request never arrived | yes |
AmbiguousResponseError |
it may have been processed | no |
InvalidInputError |
rejected locally; nothing was sent | no |
WhireTimeoutError |
a wait_* call ran out of time |
no |
An unknown record id is a 400 with the sentence "Payout abc not found."; a 404 means the route does not exist, almost always a wrong base_url.