Skip to main content

phala-pay

Phala Pay for Python (3.12+), in the shape of Stripe's SDK: create a quote, hand its client_secret to the browser checkout (@phala/pay), and fulfil from the signed deposit.credited webhook.

Install

uv add phala-pay        # or: pip install phala-pay

phala-pay shares the service's version: pin the one equal to your operator's service version (compatibility). phala-pay[eoa] adds the signer an EOA treasury's proof needs (pay.treasuries.set_eoa).

Quickstart

The operator creates your account and hands your contact its first secret key, ppay_sk_test_…; roll it on receipt, keep it offline for administration, and create a restricted key (ppay_rk_…) for your servers.

Create a quote for the signed-in account and return its client secret to the browser:

import os

from phala_pay import PhalaPay

pay = PhalaPay(
    api_base="https://pay.example.com",
    api_key=os.environ["PHALA_PAY_API_KEY"],  # a restricted key, ppay_rk_…
    # Your pins, configured here and never read from the service: every quote and deposit address
    # is recomputed from them, failing closed (AddressMismatchError); a live key requires all.
    account="acct_…",
    forwarder=(FACTORY, IMPLEMENTATION),  # pinned from the attested deployment
    treasuries={11155111: TREASURY},  # your own treasury per chain, as you proved it
)

quote = pay.quotes.create(
    client_reference_id="team-42",  # your id for the customer; credits are addressed to it
    amount=2500,  # US cents
    chain_id=11155111,
    asset="pha",
    idempotency_key=order_id,  # for 24 hours the same key replays this quote and client secret
)
# The page renders <Checkout clientSecret expectedAddress apiBase /> (@phala/pay).
return {"client_secret": quote.client_secret, "expected_address": quote.address}

Fulfil from the webhook, once per deposit, and answer 2xx after the credit is committed:

from phala_pay import SignatureVerificationError

try:
    event = pay.webhooks.construct_event(
        raw_body, request.headers, WEBHOOK_PUBLIC_KEY, "acct_…", expected_livemode=False
    )
except (SignatureVerificationError, ValueError):
    return Response(status_code=400)

if event.type.startswith("deposit."):
    # Credit, refund, and reversal alike: per deposit, serially, merge the snapshot and move the
    # balance to `amount - amount_refunded - amount_reversed` (0 unless credited or reversed).
    apply_deposit(event.deposit)

Events arrive in any order; the snapshot's cumulative amount_refunded and amount_reversed make the result independent of it (docs/integration.md §2.3; apply_deposit in sdk/examples/fastapi_app.py).

WEBHOOK_PUBLIC_KEY is your account's webhook key in the mode, whpk_…, pinned from GET /v1/attestation (docs/integration.md §5.3); pass a list of keys while a rotation overlaps. construct_event fails closed: it checks the Standard Webhooks signature, the timestamp (five minutes' tolerance), that the body's id is the webhook-id, and that the event's account and livemode are the expected ones; event.data.object is the Deposit (a Quote for quote.*, a Refund for refund.*) as it was when the event happened: it is rendered with the change and never re-rendered, so read the object again for its current state. *.updated events carry event.data.previous_attributes, and an event your own request caused names it in event.request (id, idempotency_key). sdk/examples/fastapi_app.py is a complete FastAPI backend with both routes.

Reference

Call API
pay.account.retrieve() / .update(confirmation_policies=) / .pause_quotes() / .resume_quotes() / .roll_webhook_key(expires_in=) GET|POST /v1/account, POST /v1/account/pause|resume, POST /v1/account/webhook_keys/roll
pay.quotes.create(client_reference_id=, amount=, chain_id=, asset=, idempotency_key=, metadata=) POST /v1/quotes
pay.quotes.retrieve(id) / .list(client_reference_id=, status=) / .cancel(id) GET /v1/quotes[/{id}], POST /v1/quotes/{id}/cancel
pay.quotes.update(id, metadata=) POST /v1/quotes/{id}
pay.deposit_addresses.create(client_reference_id=, metadata=) POST /v1/deposit_addresses: the customer's active address, one for every supported token and network (networks), its recent payments, and a client_secret for <DepositAddress>
pay.deposit_addresses.retrieve(id) / .list(client_reference_id=, status=) / .rotate(id) / .update(id, metadata=) GET /v1/deposit_addresses[/{id}], POST /v1/deposit_addresses/{id}/rotate, POST /v1/deposit_addresses/{id}
pay.deposits.list(client_reference_id=, quote=, deposit_address=, status=, tx_hash=, created_gt=, created_gte=, created_lt=, created_lte=) GET /v1/deposits, every page; status is pending, credited, rejected, or reversed
pay.deposits.retrieve(id) / .update(id, metadata=) GET /v1/deposits/{id}, POST /v1/deposits/{id}
pay.refunds.create(deposit=, destination_address=, amount_atomic=, metadata=) / .mark_paid(id, transaction_hash=, receipt_log_index=) / .cancel(id) / .retrieve(id) / .update(id, metadata=) POST /v1/refunds, POST /v1/refunds/{id}/mark_paid, POST /v1/refunds/{id}/cancel, GET /v1/refunds/{id}, POST /v1/refunds/{id}
pay.refunds.list(deposit=, status=) GET /v1/refunds, every page
pay.config.retrieve() GET /v1/config
pay.balance.retrieve() / pay.sweeps.list(chain_id=, forwarder=, token=) / pay.forwarders.list(chain_id=, sweepable=) GET /v1/balance, GET /v1/sweeps, GET /v1/forwarders
pay.treasuries.challenge(chain_id=, address=) / .create(chain_id=, message=, signature=) / .set_eoa(chain_id=, address=, private_key=) / .list() / .retrieve(id) / .cancel(id) / .pause(id) / .resume(id) POST /v1/treasuries/challenge, GET|POST /v1/treasuries, POST /v1/treasuries/{id}/cancel|pause|resume
pay.api_keys.create(name=, permissions=) / .list() / .retrieve(id) / .roll(id, expires_in=) / .revoke(id) /v1/api_keys
pay.webhook_endpoints.create(url=, enabled_events=) / .list() / .retrieve(id) / .update(id, …) / .delete(id) / .test(id) /v1/webhook_endpoints
pay.events.list(type=, types=, delivery_success=, created_gt=, …) / .retrieve(id) / .resend(id, webhook_endpoint=) /v1/events
pay.export_account(directory) every list, written as JSON files
pay.webhooks.construct_event(payload, headers, public_key, expected_account, expected_livemode=) (also phala_pay.Webhook, no client needed) verifies a webhook delivery

Every request sends the API key as Authorization: Bearer …: a restricted key, ppay_rk_…, for production servers (pay.api_keys.create(permissions=[...]), which never manages keys, treasuries, webhook endpoints, webhook keys, or account settings), or a secret key, ppay_sk_…, kept offline for administration. Transport errors, 429 (after its Retry-After), 5xx, and 409 idempotency_key_in_use are retried with backoff, reusing one Idempotency-Key per POST; a response the service saved for the key, even a 500, comes back marked Idempotent-Replayed and is raised as it is, since the request already ran. Failures raise ApiError with the service's stable code, error_type, param, doc_url, request_id (the response's Request-Id), and retry_after. Business-state failures, such as deposit_not_final or quote_unexpected_state, are 400; only idempotency_key_in_use is 409. Status arguments have Literal hints, and EventType names every event (phala_pay.QuoteStatus, DepositStatus, RefundStatus, TreasuryStatus, EventType, …); the generated models keep statuses as str, so a value added later still parses. forwarder=(factory, implementation), pinned from the attested deployment, treasuries={chain_id: treasury}, your own treasury per chain as you proved it, and account= (acct_…) are the pins every open quote and every network of an active deposit address is recomputed from, never the response's treasury: a compromised service could return an attacker's treasury with its valid address. An address you cannot derive, or one naming another treasury than your pinned one of its chain, raises AddressMismatchError. A live key requires all three pins and fails closed without them; in test mode account is read once from GET /v1/account, and an unpinned treasury falls back to the response's with an UnpinnedTreasuryWarning. topup_sdk.deposit_address(factory, implementation, treasury, account=, livemode=, client_reference_id=, version=) recomputes any deposit address offline; pass the treasury of the network, since a chain whose treasury differs has its own address.

Sweeping. Funds stay in the forwarders until you sweep them, with your own wallet or Safe:

from topup_sdk import flush_transactions, safe_batch, write_safe_batch

forwarders = list(pay.forwarders.list(chain_id=1, sweepable=PHA))  # never a sanctioned one
calls = flush_transactions(forwarders, PHA)  # offline: one factory.flush per treasury
write_safe_batch("sweep.json", safe_batch(1, TREASURY_SAFE, calls))  # for the Transaction Builder

flush_transaction(factory, treasury, salts, token) encodes one call offline from exported forwarders (pay.export_account writes them all), so funds stay sweepable without the service; safe_batch writes the Safe Transaction Builder's BatchFile JSON with its checksum.

Treasuries. An EOA proves itself with pay.treasuries.set_eoa(chain_id=, address=, private_key=) (install phala-pay[eoa]); a Safe's owners sign the challenge's message as a Safe message with the Safe{Core} SDK and submit it with pay.treasuries.create (docs/integration.md §1.6, "Safe treasuries").

metadata follows Stripe's: up to 50 string key/value pairs, keys of up to 40 characters without square brackets, values of up to 500 characters. An update merges: a key set to "" is unset, and metadata="" unsets every key. A deposit starts with a copy of its quote's metadata, so an order id set on the quote arrives in the deposit.credited webhook's data.object.metadata. Do not store sensitive information in it.

Lower-level modules: topup_sdk (webhook and admin request signatures, address derivation, attestation, TopupClient) and topup_client (generated from crates/topup/openapi.json; do not edit). uv run topup-sdk send-test-event --url … --seed-file test.seed --client-reference-id … sends a signed test event, a duplicate, and a forged copy to a webhook receiver whose test instance pins that seed's public key. See docs/integration.md for the integration guide and the versioning and deprecation policy, deploy/product/reference_product for a complete product, and deploy/sandbox/README.md for the sandbox.

Development

make sync    # install the locked environment
make check   # ruff, mypy --strict, pytest, and the regeneration no-op check

phala-pay is released with the service: the v<version> tag's Release workflow publishes it to PyPI (environment pypi) with trusted publishing (CONTRIBUTING.md, "Releasing"). Its changes are in the top-level CHANGELOG.md, under "Python SDK"; its releases before v0.5.0, versioned on their own, stay in sdk/python.

Metadata

Release files for phala-pay 0.5.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for phala-pay 0.5.0
File Size Uploaded
phala_pay-0.5.0.tar.gz 312.3 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for phala-pay 0.5.0
File Interpreter ABI Platform
phala_pay-0.5.0-py3-none-any.whl Python 3 none any Details

Total release size: 564.8 kB

Release files / phala_pay-0.5.0.tar.gz

Download URL phala_pay-0.5.0.tar.gz
Size 312.3 kB
Tags Source
SHA-256 checksum
How to use checksums
c0aef54045c1f9bfd4c837e1d567c0de3c99f8d14eaed3416e2d0265df5bb5f2
BLAKE2b-256 checksum
How to use checksums
5a08d7770301a1d25d9c3fd3edc588694d773ff7c78d6e4d7379c70ba7d0ff28
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 2, 2026.

Transparency log

Release files / phala_pay-0.5.0-py3-none-any.whl

Download URL phala_pay-0.5.0-py3-none-any.whl
Size 252.5 kB
Tags Python 3
SHA-256 checksum
How to use checksums
26342ffdefa5671f5b08eeed4700b40bf6a2764bb9e011d022fa538b7bcd463c
BLAKE2b-256 checksum
How to use checksums
9e0b77d6a417fdbde28b4fb5d1569e374b18d206b7a59d351bb7c7f802095b65
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 2, 2026.

Transparency log

Release history Release notifications | RSS feed

0.9.2

2 release files

0.9.1

2 release files

0.9.0

2 release files

0.8.3

2 release files

0.8.2

2 release files

0.8.1

2 release files

0.8.0

2 release files

0.7.0

2 release files

0.6.0

2 release files

This release

0.5.0 This release

2 release files

0.4.0

2 release files

0.3.0

2 release files

0.2.0

2 release 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