Code Examples

Code Migration Examples

Conceptual v1 to v2 API pattern changes


Creating a Payment

v1 and v2 both use the /transactions endpoint. The main change is sender/receiver structure in place of implicit customer_id / processing_id.

import payload
 
pl = payload.Session('secret_key_3bW9...', api_version='v1')
 
# Create a payment: customer_id is payer, processing_id is recipient
transaction = pl.Transaction.create(
    type='payment',
    amount=150.00,
    customer_id='cus_payer123',
    processing_id='pro_receiver456',
    payment_method_id='pm_abc789'
)
 
print(f'Transaction ID: {transaction.id}')
print(f'Status: {transaction.status}')
  • Method: POST /transactions
  • Body includes: amount, type='payment', customer_id (payer), processing_id (recipient), payment_method_id
  • Response: Transaction with status=processing
import payload
 
pl = payload.Session('secret_key_3bW9...', api_version='v2')
 
# Create a payment: explicit sender and receiver
transaction = pl.Transaction.create(
    type='payment',
    amount=150.00,
    sender={'account_id': 'acct_payer123', 'method_id': 'pm_abc789'},
    receiver={'account_id': 'acct_receiver456'}
)
 
print(f'Transaction ID: {transaction.id}')
print(f'Status: {transaction.status.value}')
  • Method: POST /transactions
  • Body includes: amount, type='payment', sender (account + method_id), receiver (account)
  • Response: Transaction with status=processing

Creating a Payout

Payout type name changed from credit to payout, and sender/receiver structure is now explicit.

import payload
 
pl = payload.Session('secret_key_3bW9...', api_version='v1')
 
# Create a payout: send funds from a processing account to a customer bank account
transaction = pl.Transaction.create(
    type='credit',
    amount=500.00,
    processing_id='pro_payer123',
    customer_id='cus_recipient456',
    payment_method_id='pm_bank_xyz'
)
 
print(f'Transaction ID: {transaction.id}')
print(f'Status: {transaction.status}')
  • Method: POST /transactions
  • Body includes: amount, type='credit', processing_id (sender), customer_id (recipient), payment_method_id (bank account on customer)
  • Response: Transaction with status=processing
import payload
 
pl = payload.Session('secret_key_3bW9...', api_version='v2')
 
# Create a payout: explicit sender (biller) and receiver (payee)
transaction = pl.Transaction.create(
    type='payout',
    amount=500.00,
    sender={'account_id': 'acct_biller123'},
    receiver={'account_id': 'acct_recipient456', 'method_id': 'pm_bank_xyz'},
    funding_timing='instant'
)
 
print(f'Transaction ID: {transaction.id}')
print(f'Status: {transaction.status.value}')
  • Method: POST /transactions
  • Body includes: amount, type='payout', sender (biller account), receiver (payee account
    • method_id for the destination bank)
  • Optional: funding_timing='instant' for RTP routing

Setting Up a Customer

v1 creates customers on the /customers endpoint. v2 unifies this under /accounts with type='customer' and lets you optionally attach an Entity when you need KYC/KYB on the payer.

import payload
 
pl = payload.Session('secret_key_3bW9...', api_version='v1')
 
# v1: create a customer record
customer = pl.Customer.create(
    name='John Smith',
    email='[email protected]'
)
 
print(f'Customer: {customer.id}')
  • Method: POST /customers
  • Body includes: name, email, phone, address
  • No identity verification — customers are just billing records
import payload
 
pl = payload.Session('secret_key_3bW9...', api_version='v2')
 
# v2: unified accounts endpoint. Pass an `entity` object only when you need
# identity verification (e.g. lending, high-value recurring billing). For
# simple payment collection, omit `entity` entirely.
account = pl.Account.create(
    type='customer',
    name='John Smith',
    contact_details={'email': '[email protected]'},
    # Optional — attach an Entity to run KYC on this customer
    entity={
        'type': 'individual',
        'legal_name': 'John Smith',
        'country': 'US',
        'phone_number': '1231231234',
        'tax_id': {'value': '123121234'},
        'address': {
            'address_line_1': '123 Example St',
            'city': 'New York',
            'state_province': 'NY',
            'postal_code': '11111',
        },
    },
)
 
print(f'Customer Account: {account.id}')
if account.entity:
    print(f'Entity: {account.entity.id}')
  • Method: POST /accounts with type='customer'
  • Same core fields as v1 (name, email, phone, address)
  • Optional entity object: attach identity/KYC data when the use case requires it (lending, high-value recurring billing, international). Omit for simple payment collection.
  • When an Entity is attached, KYC triggers automatically — monitor status via webhooks

Enabling Autopay

v1 stored a single default_payment_method pointer on the customer. v2 moves this to the PaymentMethod itself via account_defaults.paying, which lets a single method cover payments, refunds, or both without an extra pointer on the account.

import payload
 
pl = payload.Session('secret_key_3bW9...', api_version='v1')
 
# v1: enable autopay by pointing the customer at a default payment method
customer = pl.Customer.get('cus_abc123')
customer.update(default_payment_method='pm_card_789')
 
print(f'Autopay enabled for {customer.id} via {customer.default_payment_method}')
  • Set default_payment_method on the Customer to the payment method ID
  • One default per customer; used for autopay and any implicit charges
import payload
 
pl = payload.Session('secret_key_3bW9...', api_version='v2')
 
# v2: enable autopay by marking a PaymentMethod as the account's default for paying.
# account_defaults.paying = 'payments' routes all paying transactions (incl. autopay) here.
pm = pl.PaymentMethod.get('pm_card_789')
pm.update(account_defaults={'paying': 'payments'})
 
print(f'Autopay default: {pm.id} ({pm.account_defaults["paying"]})')
  • Set account_defaults.paying on the PaymentMethod to 'payments' (or 'refunds' / 'all') — the account's autopay default is whichever method is flagged for payments
  • Lives on the PaymentMethod, not the account — switch defaults by updating a different method
  • Pairs with account_defaults.funding ('deposits' / 'withdraws' / 'all') for processing-account routing

Webhook Handling

Signature verification, idempotent event IDs, and configurable retries are available on both v1 and v2 webhooks — the handler pattern is the same across versions.

import hashlib
import hmac
import json
import os
from flask import Flask, request, abort
 
app = Flask(__name__)
WEBHOOK_SECRET = os.environ.get('PAYLOAD_WEBHOOK_SECRET', 'your_secret_here')
 
@app.post('/webhooks/payload')
def handle_webhook():
    # Verify signature: HMAC-SHA256 of the raw request body
    signature = request.headers.get('X-Payload-Signature', '')
    expected = hmac.new(WEBHOOK_SECRET.encode(), request.data, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(signature, expected):
        abort(401)
 
    event = json.loads(request.data)
    triggered_on = event['triggered_on']
    obj_id = triggered_on['id']
    obj = triggered_on.get('object', 'object')
 
    # Dispatch by event['trigger'] (e.g. "processed", "reject", "void", "refund")
    trigger = event['trigger']
    if trigger == 'processed':
        print(f"{obj} {obj_id} processed")
    elif trigger == 'reject':
        print(f"{obj} {obj_id} rejected")
    elif trigger == 'void':
        print(f"{obj} {obj_id} voided")
    elif trigger == 'refund':
        print(f"{obj} {obj_id} refunded")
 
    return '', 200
  • Compute HMAC-SHA256(body, webhook_secret) and compare to the X-Payload-Signature header
  • Deduplicate retries on the event ID
  • Dispatch by event.trigger (e.g. processed, reject, void, refund); use event.triggered_on.id and event.triggered_on.object to fetch the affected resource
  • Optional in v2: OAuth authentication for enterprise webhooks (access token in Authorization header)

Querying Reports (ARM System)

v1 had the full ARM (Advanced Report/Query) system. v2 keeps it largely unchanged, with optional custom attribute support.

import payload
from payload import arm
 
pl = payload.Session('secret_key_3bW9...', api_version='v1')
 
# Group transactions by card brand, summing amount over $100
rows = pl.Transaction.select(
    pl.Attr.card_brand,
    pl.Attr.amount.sum(),
    pl.Attr.count()
).filter_by(
    pl.Attr.amount > 100
).group_by(
    pl.Attr.card_brand
).all()
 
for row in rows:
    print(f"{row.card_brand}: {row['sum(amount)']} across {row.count} txns")
  • Use select, filter_by, group_by, and aggregate functions (sum, avg, count, min, max, stddev, variance)
  • Limited to built-in transaction fields (amount, status, type, created_at, etc.)
import payload
 
pl = payload.Session('secret_key_3bW9...', api_version='v2')
 
# v2 adds string-based query support via the `q:` parameter, enabling
# parenthesized groups and complex OR expressions that chained filter_by
# predicates couldn't express.
rows = (
    pl.Transaction.select(
        pl.attr.attrs.campaign_code,
        pl.attr.amount.sum(),
        pl.attr.count(),
    )
    .filter_by(
        q='status.value == "processed" && attrs[campaign_code] != null'
        ' && (amount >= 100 || attrs[tier] == "enterprise")'
        ' && created_at >= date("2024-01-01")'
    )
    .group_by(pl.attr.attrs.campaign_code)
    .all()
)
 
for row in rows:
    print(f"{row.attrs.campaign_code}: {row['sum(amount)']} across {row.count} txns")
  • select, filter_by, group_by, aggregate functions unchanged — same syntax as v1
  • New: complex query composition. Unlocks query shapes that chained per-field predicates couldn't express:
    • Parenthesized groups for precedence control
    • Complex OR / mixed AND-OR logic across different fields
    • Node: native composable helpers (pl.or(...), pl.and(...), .gte(), .ne(), etc.)
    • Other SDKs / raw API: string-based query language via the q: parameter with operators ==, !=, >=, <=, >, <, &&, ||, date("..."), attrs[name], != null
  • Include custom attributes (attrs[name]) in queries, fields, and group_by
  • Minimal migration effort: existing v1 queries work with minimal or no changes — adopt the new composition features when you want richer filter expressions

Two-Step Processing (Auth & Capture)

v1 had a per-API-key toggle. v2 redesigns it as two explicit modes.

import payload
 
pl = payload.Session('secret_key_3bW9...', api_version='v1')
 
# API key is configured with authorize_and_capture = true, so the transaction
# is created in status=authorized rather than processed.
txn = pl.Transaction.create(
    type='payment',
    amount=100.00,
    customer_id='cus_payer123',
    processing_id='pro_receiver456',
    payment_method_id='pm_abc789'
)
print(f'Authorized: {txn.id} (status={txn.status})')
 
# Capture later by flipping status to processed
txn.status = 'processed'
txn.update()
print(f'Captured: {txn.id} (status={txn.status})')
  • Configure API key setting: authorize_and_capture = true/false
  • If true: transaction created with status='authorized', capture later by updating status to 'processed'
  • If false: transaction created directly with status='processed' (single-step)
import payload
 
pl = payload.Session('secret_key_3bW9...', api_version='v2')
 
# Manual mode: you explicitly set status='authorized' on create.
# (Automatic mode is used with the Payment Form / Checkout Plugin, which
#  creates the authorization client-side; your server captures within 2 minutes.)
txn = pl.Transaction.create(
    type='payment',
    amount=100.00,
    status={'value': 'authorized'},
    sender={'account_id': 'acct_payer123', 'method_id': 'pm_abc789'},
    receiver={'account_id': 'acct_receiver456'},
)
print(f'Authorized: {txn.id} (status={txn.status['value']})')
 
# Capture later
txn.status = {'value': 'processed'}
txn.update()
print(f'Captured: {txn.id} (status={txn.status['value']})')
  • Automatic mode (default): Payment Form / Checkout Plugin creates the authorization. Your server has 2 minutes to capture (status update). Auto-cancels if not captured. Prevents order-payment mismatches.
  • Manual mode: You explicitly set status='authorized' via API. Traditional authorize-and-capture with you controlling timing. No auto-cancel. Use for charge-after-shipment.

Payment Collection UI

v1 and v2 share the same payment collection surfaces. v2 reorganizes naming and adds the Payment Form enhancement over v1's Secure Input.

import payload
 
pl = payload.Session('secret_key_3bW9...', api_version='v1')
 
# Create a hosted Payment Link for a customer to pay
link = pl.PaymentLink.create(
    amount=150.00,
    customer_id='cus_payer123',
    processing_id='pro_receiver456',
    description='Order #1234'
)
 
print(f'Payment Link: {link.url}')

API-based:

  • Create PaymentMethod inline or reference existing token
  • Create Transaction directly via POST /transactions

Form-based:

  • Secure Input (embedded form fields) via Payload.js
  • Checkout Plugin (modal) via Payload.js
  • Payment Link (hosted, one-time or reusable) — example above
  • All tokenize sensitive data client-side, return token to server
import payload
 
pl = payload.Session('secret_key_3bW9...', api_version='v2')
 
# Same Payment Link resource as v1 — terminology updated to payer/biller
link = pl.PaymentLink.create(
    amount=150.00,
    payer={'account_id': 'acct_payer123'},
    biller={'account_id': 'acct_biller456'},
    description='Order #1234'
)
 
print(f'Payment Link: {link.url}')
  • Payment API — Direct transaction creation, maximum flexibility (unchanged)
  • Payment Form — Embedded form via Payload.js with custom CSS styling (enhancement over v1 Secure Input). Tokenization still client-side, token returned to server.
  • Checkout Plugin — Modal, same as v1 Checkout Plugin
  • Payment Link — Same resource, now uses payer / biller terminology (example above)

Setting Account Funding Defaults

v1 had net / gross / itemized funding modes set directly on the processing account. v2 moves funding configuration under processing settings, renames netnetted, and adds a new manual style plus per-transaction timing tiers.

import payload
 
pl = payload.Session('secret_key_3bW9...', api_version='v1')
 
# Funding style is set on the processing account: 'net', 'gross', or 'itemized'.
account = pl.ProcessingAccount.get('pro_biller123')
account.update(funding_style='itemized')
 
print(f'Funding style: {account.funding_style}')
  • Set funding_style directly on the processing account: net, gross, or itemized
  • Standard settlement timing (next business day)
  • Single funding method per account
import payload
 
pl = payload.Session('secret_key_3bW9...', api_version='v2')
 
# v2: funding style is set on the processing settings.
# Styles: 'gross' | 'itemized' | 'netted' | 'manual'
account = pl.Account.get('acct_processing123')
 
account.processing['settings'].update(
    funding={
        'style': 'netted',
        'default_descriptor': 'ACME CORP'
    }
)
 
print('Funding configuration updated')
  • Funding style lives on processing settings (account.processing.settings.funding.style): gross, itemized, netted, or new manual (funds held until released)
  • Per-transaction timing: funding_timing on paying transactions — standard, rapid (accelerated), or instant (immediate availability)
  • Funding delay: funding.delay (0–10 days) on processing settings for global delay; funding_delay on individual transactions for overrides
  • Default funding method routing: account_defaults.funding on PaymentMethod objects — values are 'deposits', 'withdraws', or 'all' to designate which method handles which direction
  • Processing Rules: admin-configured conditional overrides based on amount, card type, etc.

Multi-Party Splits (Dynamic Funding)

Multi-party splits are net-new in v2 — there was no direct v1 equivalent.

import payload
 
pl = payload.Session('secret_key_3bW9...', api_version='v2')
 
# One transaction, atomically split across vendor, platform, and franchisee
txn = pl.Transaction.create(
    type='payment',
    amount=1000.00,
    sender={'account_id': 'acct_payer123', 'method_id': 'pm_abc789'},
    receiver={'account_id': 'acct_vendor456'},
    transfers=[
        {'amount': 900.00, 'receiver': {'account_id': 'acct_vendor456'}},
        {'amount': 50.00, 'receiver': {'account_id': 'acct_platform_fee'}},
        {'amount': 50.00, 'receiver': {'account_id': 'acct_franchise'}},
    ],
)
 
print(f'Transaction ID: {txn.id}')
for transfer in txn.transfers:
    print(f'  → {transfer.account_id}: ${transfer.amount}')
  • Single POST /transactions call includes a transfers array
  • Transaction succeeds or fails atomically — all splits happen together or not at all
  • Reverse flow also supported: multiple sources → single recipient
  • Creates Transfer objects for tracking and webhook events

Sending a Payout Enrollment Link

v1 had a dedicated PaymentActivation endpoint for sending payout enrollment links. v2 consolidates this into the general-purpose Intent API, which handles enrollment links, verification flows, and other time-bounded actions behind one resource.

import payload
 
pl = payload.Session('secret_key_3bW9...', api_version='v1')
 
# v1: send a payouts enrollment link via the PaymentActivation endpoint
activation = pl.PaymentActivation.create(
    name='Jane Smith',
    email='[email protected]'
)
 
print(f'Enrollment link sent to: {activation.email}')
  • POST /payment_activations with name and email
  • Recipient receives an email with a link to enroll a payout method
import payload
 
pl = payload.Session('secret_key_3bW9...', api_version='v2')
 
# v2: send a payouts enrollment link via the Intent API
intent = pl.Intent.create(
    type='payouts_enrollment_link',
    send_to=[
        {
            'name': 'Jane Smith',
            'email': '[email protected]'
        }
    ]
)
 
print(f"Enrollment link sent to: {intent.send_to[0]['email']}")
  • POST /intents with type: 'payouts_enrollment_link' and a send_to array
  • Send to multiple recipients in one call via the send_to array
  • Same Intent resource powers other flows (verification, etc.) — one endpoint to learn