Python module guide
PayPal client
An asynchronous wrapper for application tokens, OAuth user information, batch payouts, webhook verification, and reporting transactions.
Configuration and imports
Client(client_id, client_secret, sandbox=True, handle_rate_limit=True) selects the sandbox by default. client.url is https://api-m.sandbox.paypal.com for sandbox clients or https://api-m.paypal.com otherwise. Credentials are supplied by the caller; environment variables below are an application convention, not automatically read by the constructor.
Dependencies are the local toolbox, aiohttp_ws, and pmblue_update modules plus their installed aiohttp and requests dependencies. Importing paypal runs pmblue_update.self_update(); dependencies can also perform update checks and filesystem writes. If the updater is missing, this module contains a legacy HTTP bootstrap that downloads pmblue_update.py to the working directory. Provision the updater explicitly to avoid relying on that bootstrap.
The import also sets the shared toolbox.TxtParser.use_regex = False. All network methods are asynchronous; call them inside an event loop and await their results.
import asyncio
import os
import paypal
async def main():
client = paypal.Client(
os.environ["PAYPAL_CLIENT_ID"],
os.environ["PAYPAL_CLIENT_SECRET"],
sandbox=True,
)
token = await client.token
print("Token retrieved; expires at", token.expires_at)
asyncio.run(main())
Avoid printing token itself: both str(token) and repr(token) contain the access token.
Tokens and local rate limiting
await client.fetch_token(retry=True) sends a client-credentials request with HTTP Basic authentication. It returns a Token and retries once after 0.1 seconds if token parsing fails; a second parsing failure raises UnknownError. await client.token is an awaitable cached property, not a method, with a 30-second decorator cache. It reuses client._token until that token reports expiration.
| Token attribute | Meaning in this module |
|---|---|
access_token, scopes, app_id, nonce | Parsed token response fields; scope strings are split into a list. |
created_at | Datetime parsed from the response nonce. |
retrieved_at | Response Date header if parseable, otherwise the current UTC time. |
expires_at, expires_in, is_expired | Expiration datetime, remaining timedelta, and a boolean expiration test. |
RateLimit is a process-wide counter shared by all clients: its current constants permit 10 counted requests per seven-second period. This is a local throttle, not a statement of a remote service quota. Normal requests wait for a reset when handle_rate_limit=True or raise Exception("Rate Limit Reached") otherwise. Direct fetch_token() calls raise at the local limit even when that flag is true. The counter has no concurrency lock.
OAuth user information
generate_oauth_url(redirect_uri, scope="openid", state=None) returns an authorization URL; scope accepts either a string or a list. The URL always uses https://www.paypal.com/connect, including when the client has sandbox=True. The application must store and validate its own OAuth state; the wrapper does not check state in the callback.
import secrets
expected_state = secrets.token_urlsafe(32)
# Persist expected_state with the user's session before redirecting.
authorize_url = client.generate_oauth_url(
"https://example.com/oauth/paypal/callback",
scope=["openid"],
state=expected_state,
)
# In the callback, first compare the received state with stored state.
# Pass the full callback URL, not just its authorization code.
user_token = await client.extract_user_token(callback_url)
profile = await client.get_user_info(user_token)
extract_user_token(code) also accepts a request that toolbox.StandardRequest can convert to a URL. In this version, the token exchange is nested inside the URL branch: a plain code string returns None. A URL without a code query parameter raises ValueError. get_user_info(user_token) returns the decoded JSON response using the supplied user bearer token.
Batch payouts and idempotency
PayoutItem(receiver, amount, recipient_type, note, sender_item_id=None) prepares one item. Accepted types are EMAIL, PAYPAL_ID, USER_HANDLE, and PHONE; anything else raises ValueError. Serialization always uses USD, converts the amount to text, and adds recipient_wallet="Venmo" for USER_HANDLE and PHONE. Omitted item IDs are generated as 16-character random strings.
# Executing this block submits a payout to the client's environment.
item = paypal.PayoutItem(
receiver="[email protected]",
amount="1.00",
recipient_type="EMAIL",
note="Sandbox example",
sender_item_id="example-item-001",
)
try:
batch_id = await client.create_batch_payout(
sender_batch_id="example-batch-001",
email_subject="Example payout",
email_message="Sandbox test",
payout_items=[item],
request_id="example-request-001",
)
except paypal.IdempotencyError as error:
batch_id = error.payout_batch_id
if batch_id is None:
raise
response = await client.fetch_batch_payout(batch_id)
response.raise_for_status()
batch_details = response.json()
create_batch_payout() returns the response's batch_header.payout_batch_id. fetch_batch_payout() returns an aiohttp_ws.AiohttpResponse, so call .json() yourself. Use stable sender and request IDs when retrying the same operation; this module otherwise generates a fresh 32-character PayPal-Request-Id. Its internal invalid-token retry retains the request ID.
PayoutItem.dict is a cached property. Treat the item as immutable after its first serialization, or construct a new item for changed values. The submission method prints the payout payload and response, which can include recipient details. It does not validate positive amounts or currency precision.
Webhook signature verification
The current module includes await client.verify_webhook_signature(headers, event, webhook_id). It sends the notification's transmission fields, decoded event, and configured webhook ID to /v1/notifications/verify-webhook-signature. It returns True only for verification_status == "SUCCESS".
# Within your HTTP handler, parse the JSON notification without modifying it.
event = await request.json()
verified = await client.verify_webhook_signature(
request.headers,
event,
os.environ["PAYPAL_WEBHOOK_ID"],
)
if not verified:
raise ValueError("Webhook signature verification failed")
# Process the verified event; add your own duplicate-event tracking.
The method indexes PAYPAL-AUTH-ALGO, PAYPAL-CERT-URL, PAYPAL-TRANSMISSION-ID, PAYPAL-TRANSMISSION-SIG, and PAYPAL-TRANSMISSION-TIME. An HTTP header mapping such as aiohttp's works without changing its casing; a plain dictionary must provide those exact keys. Missing headers raise KeyError. This is remote verification, so it needs network access and a working application token.
Reporting transactions
await client.list_reporting_transactions(start_date, end_date, page=1, page_size=500) reads one page from /v1/reporting/transactions and returns decoded JSON. It always sends fields=all and balance_affecting_records_only=N; date strings are passed through to the API.
report = await client.list_reporting_transactions(
start_date="2026-09-01T00:00:00Z",
end_date="2026-09-02T00:00:00Z",
page=1,
page_size=100,
)
for transaction in report.get("transaction_details", []):
process_transaction(transaction) # Your application callback
No automatic pagination is implemented. Inspect the returned page metadata and request the remaining pages explicitly. Both reporting and webhook verification disable the generated request ID header via request_id=False.
Errors and current limitations
| Exception | Trigger and useful data |
|---|---|
InsufficientFunds | A parsed 4xx response with name="INSUFFICIENT_FUNDS". |
IdempotencyError | A 400 response with name="USER_BUSINESS_ERROR"; exposes raw data and extracts payout_batch_id from a payout link when present. |
AuthorizationError / InvalidToken | Parsed 401 responses; exposes error and message. Invalid-token responses receive one retry in the normal request path. |
UnknownError | Repeated token parsing failure; message includes response details. |
error_check()handles selected response shapes; it does not reliably raise for every non-2xx response. For methods returning a raw response, callraise_for_status(). Methods returning decoded JSON can instead fail with missing-key errors or return error dictionaries.- The retry path catches a second
InvalidTokenwithout re-raising it, and the cachedtokenproperty can retain a previously returned token for its 30-second cache window. A retry is not guaranteed to resolve authentication failure. - The Date-header fallback uses a timezone-aware datetime while
expires_insubtracts naivedatetime.utcnow(). In that fallback case, reading expiration state can raiseTypeError. - Network and JSON-decoding exceptions propagate. This module provides no persistent job queue, webhook endpoint, reconciliation database, or transaction retry policy beyond its single invalid-token retry.
Object handbook
The module's object graph is small: a Client owns application credentials and a cached Token, while PayoutItem objects supply request bodies. Most API results remain dictionaries or raw HTTP responses rather than new domain classes.
Client
Client(client_id, client_secret, sandbox=True, handle_rate_limit=True) simply stores those arguments and starts with no application token. Construction itself makes no request. url derives from sandbox; there is no shared long-lived HTTP session or close() method on the client. The wrapper creates requests through aiohttp_ws.AiohttpResponse.
| Operation | Arguments and result | Effect |
|---|---|---|
await client.token | Returns cached/fetched Token; property syntax, without parentheses. | May request or refresh an application token. |
await fetch_token(retry=True) | Returns a new Token; direct invocation does not assign client._token. | OAuth client-credentials request with one optional parsing retry. |
generate_oauth_url(redirect_uri, scope="openid", state=None) | Returns a URL string; scope accepts a string or list. | No network request or state storage. |
await extract_user_token(code) | Returns Token for the supported callback-URL path; plain codes currently return None. | Exchanges a callback authorization code. |
await get_user_info(user_token) | Accepts token text or an object whose string value is the token; returns decoded JSON. | User-info request with the supplied bearer credential. |
await create_batch_payout(sender_batch_id, email_subject, email_message, payout_items, request_id=None) | Accepts an iterable of PayoutItem; returns the batch ID. | Submits a payout and prints the request/response. |
await fetch_batch_payout(payout_batch_id) | Returns raw AiohttpResponse. | Reads one payout batch. |
await verify_webhook_signature(headers, event, webhook_id) | Returns boolean; header mapping and parsed event are required. | Sends verification data to the remote API. |
await list_reporting_transactions(start_date, end_date, page=1, page_size=500) | Returns one decoded JSON response. | Reads one reporting page, with no local storage. |
The internal _request(path, method="GET", retry=True, request_id=None, **kwargs) accepts GET, POST, and DELETE, prefixes the client's URL, supplies application authorization, and merges caller headers. request_id=None generates an ID; False omits the header; a string preserves your ID. Normal applications can use the public methods above. Authentication, status handling, and rate-limit limitations still apply to methods returning decoded JSON.
Token
Token(r) parses a response object with json() and headers. The JSON must contain access_token, expires_in, and a parseable nonce; scope may be under scope or scopes. app_id is optional. This is a parser for the token-response shape used by the module, not a constructor for a bare token string.
access_token is the bearer credential, scopes is a list, and nonce retains the original nonce. created_at is parsed from the nonce; retrieved_at is parsed from the response Date header or uses a UTC fallback. expires_at adds the response lifetime; expires_in returns a timedelta; is_expired compares that timedelta with zero. These are synchronous properties and do not refresh the token. The Date-header fallback timezone caveat described above still applies.
A user token returned by extract_user_token() is separate from the client's application token. Pass it explicitly to get_user_info(); it does not replace client.token. The class does not persist credentials or implement a user refresh-token flow.
PayoutItem
PayoutItem(receiver, amount, recipient_type, note, sender_item_id=None) stores its arguments as public attributes. The constructor validates only the recipient-type enumeration and fills in an absent item ID. The dict property returns the cached serialized request object, including a nested amount mapping with fixed currency="USD". Use item.dict, not item.dict(). Reading it performs no remote request.
The amount is converted using str(), so the caller controls precision and representation. Modifying an item after its first serialization can leave the cached dictionary stale; create a fresh item when changing receiver or amount. Preserve sender_item_id across application retries of the same item. The wrapper does not track which IDs have already been paid.
RateLimit
RateLimit is used as a class-level namespace; do not create one per client expecting separate counters. RateLimit.check() returns whether the local window currently permits a request. RateLimit.add() resets an elapsed window if needed, increments the shared counter, and returns the count. These calls are synchronous, and checking does not reserve capacity. Internal _reset(), _period_check(), and _time_to_reset() support the client's wait logic. The default policy is the source's 10 requests/seven seconds; concurrent callers can race.
Response objects and errors
A raw aiohttp_ws.AiohttpResponse has already buffered the remote body. Its status_code/status, headers, content, and text are normal attributes/properties; json() and raise_for_status() are synchronous methods. No further await is needed to read them. Payout creation returns only its batch ID, while reporting and profile methods return decoded JSON directly.
InsufficientFunds() is an exception with a fixed message. IdempotencyError(data=None) retains the parsed error mapping as data and extracts payout_batch_id when a payout URL is present. AuthorizationError(d) extracts error and message; InvalidToken subclasses it. UnknownError(r) formats a response, mapping, or other value into its message and may therefore contain remote response details. Normally catch these exceptions instead of constructing them.
The top-level error_check(response) raises selected mapped exceptions and returns False or None on other paths. Its result is not a complete success test. Use the HTTP response status where the public method exposes it, and validate required fields in dictionary results.
Worked examples
Build a stable payout batch without submitting it
from decimal import Decimal
import paypal
def build_items(rows, batch_key):
items = []
for row in rows:
amount = Decimal(str(row["amount"]))
if not amount.is_finite() or amount <= 0:
raise ValueError("Amount must be a positive finite value")
if amount != amount.quantize(Decimal("0.01")):
raise ValueError("Amount must have at most two decimal places")
items.append(paypal.PayoutItem(
receiver=row["email"],
amount=format(amount, ".2f"),
recipient_type="EMAIL",
note="Example payout",
sender_item_id=batch_key + "-" + str(row["id"]),
))
return items
items = build_items(
[{"id": "001", "email": "[email protected]", "amount": "1.25"}],
batch_key="example-batch-001",
)
preview = [item.dict for item in items]
This adds application validation before constructing the module's objects. Keep the same batch key, unique row IDs, and request ID when retrying the same payout; persist them with the application job. The helper builds dictionaries only, although importing the module retains its separate updater side effects.
Read selected reporting pages and retain the raw results
async def read_reporting_pages(client, start_date, end_date, page_numbers):
pages = []
for number in page_numbers:
if not isinstance(number, int) or number < 1:
raise ValueError("Page numbers must be positive integers")
result = await client.list_reporting_transactions(
start_date, end_date, page=number, page_size=100
)
if not isinstance(result, dict):
raise ValueError("Expected a JSON object for the reporting page")
pages.append({"requested_page": number, "response": result})
return pages
# Inside an async application using an existing sandbox client:
# pages = await read_reporting_pages(
# client, "2026-09-01T00:00:00Z", "2026-09-02T00:00:00Z", [1, 2]
# )
The caller chooses page numbers from the remote response's pagination information or its own job plan. Keeping each raw result preserves fields this wrapper does not model. This helper checks the outer JSON type; validate the remote success/error shape before treating pages as transaction records.
Wrap verification in a request handler
from aiohttp import web
def make_paypal_handler(client, webhook_id, process_verified_event):
async def receive(request):
try:
event = await request.json()
if not isinstance(event, dict):
return web.Response(status=400, text="Expected JSON object")
verified = await client.verify_webhook_signature(
request.headers, event, webhook_id
)
except (KeyError, ValueError):
return web.Response(status=400, text="Invalid webhook")
if not verified:
return web.Response(status=400, text="Verification failed")
await process_verified_event(event)
return web.Response(status=204)
return receive
# Register on an existing aiohttp application:
# app.router.add_post(
# "/webhooks/paypal",
# make_paypal_handler(client, webhook_id, process_verified_event),
# )
process_verified_event is your async application callback. It runs only after successful remote verification and is awaited before acknowledgment. Record processed event identifiers in your application when duplicate delivery must be harmless; this module does not maintain such a registry. Transport or authorization failures propagate, allowing the application to handle them separately from malformed input.