Python module guide

PayPal client

An asynchronous wrapper for application tokens, OAuth user information, batch payouts, webhook verification, and reporting transactions.

paypal.py 1.0.6.2Async PythonSource reviewed 2026-09-20

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 attributeMeaning in this module
access_token, scopes, app_id, nonceParsed token response fields; scope strings are split into a list.
created_atDatetime parsed from the response nonce.
retrieved_atResponse Date header if parseable, otherwise the current UTC time.
expires_at, expires_in, is_expiredExpiration 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

ExceptionTrigger and useful data
InsufficientFundsA parsed 4xx response with name="INSUFFICIENT_FUNDS".
IdempotencyErrorA 400 response with name="USER_BUSINESS_ERROR"; exposes raw data and extracts payout_batch_id from a payout link when present.
AuthorizationError / InvalidTokenParsed 401 responses; exposes error and message. Invalid-token responses receive one retry in the normal request path.
UnknownErrorRepeated 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, call raise_for_status(). Methods returning decoded JSON can instead fail with missing-key errors or return error dictionaries.
  • The retry path catches a second InvalidToken without re-raising it, and the cached token property 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_in subtracts naive datetime.utcnow(). In that fallback case, reading expiration state can raise TypeError.
  • 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.

OperationArguments and resultEffect
await client.tokenReturns 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.

Complete source API

Generated from modules/paypal.py; version 1.0.6.2. Includes public functions, classes, directly declared methods, properties, and Python protocol methods. Conditional APIs may require optional dependencies. Inherited members and dynamically assigned attributes are explained in the guide where relevant.

Signatures and docstrings are extracted without importing or running the module. A property or AsyncProperty decorator changes how a member is accessed; see its decorators and the guide.

Classes and objects

Module functions

class RateLimit

Source line 62

No constructor is declared here. Some utility classes group functions for direct class access; use the call style shown in the guide.

Declared functions, properties, and nested objects:

RateLimit.check()

Source line 76

No caller-supplied parameters are declared.

RateLimit.add()

Source line 85

No caller-supplied parameters are declared.

class InsufficientFunds(Exception)

Source line 92

InsufficientFunds.

Construct: InsufficientFunds()

Declared functions, properties, and nested objects:

InsufficientFunds.__init__(self)

Source line 94

No caller-supplied parameters are declared.

class IdempotencyError(Exception)

Source line 97

Idempotency error occured, request was likely attempted twice.

Construct: IdempotencyError(data=None)

Fields assigned by the constructor: data, payout_batch_id. Some assignments may be conditional; see the object guide for meaning and lifecycle.

Declared functions, properties, and nested objects:

IdempotencyError.__init__(self, data=None)

Source line 99

ParameterPassing conventionDefault / required
datapositional or keywordNone
class AuthorizationError(Exception)

Source line 111

Authorization error occured, token likely expired.

Construct: AuthorizationError(d)

Fields assigned by the constructor: error, message. Some assignments may be conditional; see the object guide for meaning and lifecycle.

Declared functions, properties, and nested objects:

AuthorizationError.__init__(self, d)

Source line 113

ParameterPassing conventionDefault / required
dpositional or keywordrequired
class InvalidToken(AuthorizationError)

Source line 129

Construct: InvalidToken(r)

Declared functions, properties, and nested objects:

InvalidToken.__init__(self, r)

Source line 130

ParameterPassing conventionDefault / required
rpositional or keywordrequired
class UnknownError(Exception)

Source line 133

An unknown error occured.

Construct: UnknownError(r)

Declared functions, properties, and nested objects:

UnknownError.__init__(self, r)

Source line 135

ParameterPassing conventionDefault / required
rpositional or keywordrequired
error_check(r)

Source line 152

ParameterPassing conventionDefault / required
rpositional or keywordrequired
class Token

Source line 200

Construct: Token(r)

Fields assigned by the constructor: access_token, app_id, created_at, nonce, retrieved_at, scopes. Some assignments may be conditional; see the object guide for meaning and lifecycle.

Declared functions, properties, and nested objects:

Token.__init__(self, r)

Source line 203

ParameterPassing conventionDefault / required
rpositional or keywordrequired
Token.__str__(self)

Source line 230

No caller-supplied parameters are declared.

Token.__repr__(self)

Source line 232

No caller-supplied parameters are declared.

Token.expires_at(self)

Source line 235

Decorators: @property

No caller-supplied parameters are declared.

Token.expires_in(self)

Source line 238

Decorators: @property

No caller-supplied parameters are declared.

Token.is_expired(self)

Source line 241

Decorators: @property

No caller-supplied parameters are declared.

class Client

Source line 251

Construct: Client(client_id, client_secret, sandbox=True, handle_rate_limit=True)

Fields assigned by the constructor: client_id, client_secret, handle_rate_limit, sandbox. Some assignments may be conditional; see the object guide for meaning and lifecycle.

Declared functions, properties, and nested objects:

Client.__init__(self, client_id, client_secret, sandbox=True, handle_rate_limit=True)

Source line 252

ParameterPassing conventionDefault / required
client_idpositional or keywordrequired
client_secretpositional or keywordrequired
sandboxpositional or keywordTrue
handle_rate_limitpositional or keywordTrue
async Client.fetch_token(self, retry=True)

Source line 259

ParameterPassing conventionDefault / required
retrypositional or keywordTrue

Declared with async def. Normal calls return an awaitable, or an async generator if the body yields. Decorated properties may be accessed differently; follow the object guide.

async Client.token(self)

Source line 280

Decorators: @toolbox.CachedProperty(expire=30)

No caller-supplied parameters are declared.

Declared with async def. Normal calls return an awaitable, or an async generator if the body yields. Decorated properties may be accessed differently; follow the object guide.

Client.generate_oauth_url(self, redirect_uri, scope='openid', state=None)

Source line 291

ParameterPassing conventionDefault / required
redirect_uripositional or keywordrequired
scopepositional or keyword'openid'
statepositional or keywordNone
async Client.extract_user_token(self, code)

Source line 300

ParameterPassing conventionDefault / required
codepositional or keywordrequired

Declared with async def. Normal calls return an awaitable, or an async generator if the body yields. Decorated properties may be accessed differently; follow the object guide.

async Client.get_user_info(self, user_token)

Source line 313

ParameterPassing conventionDefault / required
user_tokenpositional or keywordrequired

Declared with async def. Normal calls return an awaitable, or an async generator if the body yields. Decorated properties may be accessed differently; follow the object guide.

Client.url(self)

Source line 319

Decorators: @property

No caller-supplied parameters are declared.

async Client.create_batch_payout(self, sender_batch_id, email_subject, email_message, payout_items, request_id=None)

Source line 374

ParameterPassing conventionDefault / required
sender_batch_idpositional or keywordrequired
email_subjectpositional or keywordrequired
email_messagepositional or keywordrequired
payout_itemspositional or keywordrequired
request_idpositional or keywordNone

Declared with async def. Normal calls return an awaitable, or an async generator if the body yields. Decorated properties may be accessed differently; follow the object guide.

async Client.fetch_batch_payout(self, payout_batch_id)

Source line 393

ParameterPassing conventionDefault / required
payout_batch_idpositional or keywordrequired

Declared with async def. Normal calls return an awaitable, or an async generator if the body yields. Decorated properties may be accessed differently; follow the object guide.

async Client.verify_webhook_signature(self, headers, event, webhook_id)

Source line 398

Ask PayPal to verify a webhook notification's transmission signature.
ParameterPassing conventionDefault / required
headerspositional or keywordrequired
eventpositional or keywordrequired
webhook_idpositional or keywordrequired

Declared with async def. Normal calls return an awaitable, or an async generator if the body yields. Decorated properties may be accessed differently; follow the object guide.

async Client.list_reporting_transactions(self, start_date, end_date, page=1, page_size=500)

Source line 417

Read a page of PayPal reporting transactions for reconciliation.
ParameterPassing conventionDefault / required
start_datepositional or keywordrequired
end_datepositional or keywordrequired
pagepositional or keyword1
page_sizepositional or keyword500

Declared with async def. Normal calls return an awaitable, or an async generator if the body yields. Decorated properties may be accessed differently; follow the object guide.

class PayoutItem

Source line 438

Construct: PayoutItem(receiver, amount, recipient_type, note, sender_item_id=None)

Fields assigned by the constructor: amount, note, receiver, recipient_type, sender_item_id. Some assignments may be conditional; see the object guide for meaning and lifecycle.

Declared functions, properties, and nested objects:

PayoutItem.__init__(self, receiver, amount, recipient_type, note, sender_item_id=None)

Source line 439

ParameterPassing conventionDefault / required
receiverpositional or keywordrequired
amountpositional or keywordrequired
recipient_typepositional or keywordrequired
notepositional or keywordrequired
sender_item_idpositional or keywordNone
PayoutItem.dict(self)

Source line 452

Decorators: @toolbox.CachedProperty

No caller-supplied parameters are declared.

async main()

Source line 479

No caller-supplied parameters are declared.

Declared with async def. Normal calls return an awaitable, or an async generator if the body yields. Decorated properties may be accessed differently; follow the object guide.