Connecting to the Thrive PayPal API

PayPal Sandbox

Overview

The Thrive PayPal Product API is a partner middleware: your consumer (plugin, SaaS, CLI) talks to it, and it talks to PayPal on the merchant's behalf. Your code never holds PayPal partner credentials and never calls PayPal's REST API directly.

The Product API is currently pointed at PayPal Sandbox. When you onboard, a real PayPal sandbox merchant account is connected; real PayPal sandbox redirects and webhooks fire. The integration steps and code you write here will not change when we flip to live PayPal - only the server-side env vars do.

┌──────────────┐        ┌──────────────────────────────────────────┐        ┌──────────────────┐
│ Your plugin  │──HTTP─►│ Thrive PayPal API                        │──HTTP─►│ PayPal Sandbox   │
│ (WP / Node / │        │ https://tpa.stagingthrivethemes.com/api/ │        │ api.sandbox.     │
│  anything)   │◄──JSON─│ paypal/v1                                │◄──JSON─│ paypal.com       │
└──────────────┘        └──────────────────────────────────────────┘        └──────────────────┘

A note on response shapes

This guide shows the minimum shape of each response - the fields you'll usually need. PayPal returns many additional documented fields (e.g. seller_receivable_breakdown on captures, oauth_integrations on merchant info, seller_payable_breakdown on refunds, varying capability sets per account). Code defensively: don't assume the response is exhaustive, and don't crash on unexpected fields.

Field-level provenance for each fixture is documented in tests/fixtures/paypal/README.md (internal). Fixtures are captured from real PayPal sandbox responses and refreshed when the contract drifts.

What changed in this version (v1): initial public release. Onboarding flow + Bearer auth. Future versions will be listed in the version dropdown above. If the API contract ever changes incompatibly, your plugin can stay on the docs version it was written against until you upgrade.

Configuration

Define one constant in your plugin (or environment). For WordPress, put it in wp-config.php ABOVE the require_once ABSPATH . 'wp-settings.php'; line.

WP Config

// wp-config.php
define( 'THRIVE_PAYPAL_API_BASE', 'https://tpa.stagingthrivethemes.com/api/paypal/v1' );

// Optional: lengthen HTTP timeout if the server is slow under load.
define( 'THRIVE_PAYPAL_HTTP_TIMEOUT', 15 );

// Optional: enable verbose logging of API responses to wp-content/debug.log
define( 'THRIVE_PAYPAL_DEBUG', false );

Server-side configuration (for reference - not yours to set)

These are the env vars set on the Product API host. You don't configure them; this is only here so you understand what your traffic is hitting.

PAYPAL_ENV=sandbox
PAYPAL_CLIENT_ID=<Thrive partner sandbox client_id>
PAYPAL_CLIENT_SECRET=<Thrive partner sandbox client_secret>
PAYPAL_PARTNER_MERCHANT_ID=HVEZ66K74VENY
PAYPAL_WEBHOOK_ID=<id returned from paypal:webhooks:register>
PAYPAL_BN_CODE=ThriveThemesPPCP_SP
For Product API operators: the PAYPAL_WEBHOOK_ID above isn't something you pick. PayPal generates it when you register a webhook subscription. The Product API includes an Artisan command that does the registration for you:
# Register (one-time per environment)
php artisan paypal:webhooks:register

# List currently-registered hooks
php artisan paypal:webhooks:register --list

# Delete a hook (e.g. before re-registering against a new URL)
php artisan paypal:webhooks:register --delete=WH-XXXXXXXX
After registration, paste the returned id into .env as PAYPAL_WEBHOOK_ID and run php artisan config:clear. Signature verification on incoming webhooks needs it set.

The 5-step flow at a glance

┌──────────┐   ┌──────────────────┐   ┌─────────────┐   ┌─────────┐
│ Your     │   │ Thrive PayPal    │   │ PayPal      │   │Merchant │
│ plugin   │   │ Product API      │   │ Hosted UI   │   │(buyer's │
│          │   │ /api/paypal/v1   │   │             │   │admin)   │
└────┬─────┘   └────────┬─────────┘   └──────┬──────┘   └────┬────┘
     │ 1. POST /onboarding/start       │               │
     │    { secret, site_url }               │               │
     ├──────────────────►│                   │               │
     │   ◄─ { url }      │                   │               │
     │                   │                   │               │
     │ 2. Redirect merchant to url           │               │
     ├───────────────────┼──────────────────►│               │
     │                   │   merchant signs in + approves    │
     │                   │                   ├──────────────►│
     │ ◄── PayPal redirects merchant back ───┤               │
     │     ?merchantIdInPayPal=XYZ&permissionsGranted=true   │
     │                   │                   │               │
     │ 3. POST /onboarding/complete            │               │
     │    { secret, referral_token, merchant_id, site_url } │
     ├──────────────────►│                   │               │
     │   ◄─ { client_id, sdk_client_token, … }               │
     │                   │                   │               │
     │ 4. POST /auth/token           │               │
     │    Authorization: Basic b64(mid:secret)               │
     ├──────────────────►│                   │               │
     │   ◄─ { access_token, expires_in }     │               │
     │                   │                   │               │
     │ 5. Use Bearer access_token on every subsequent call   │
     │    (orders, vault subs, refunds, webhook config…)     │

Steps 1, 3, 4, 5 are HTTP calls you make. Step 2 happens on PayPal's website - you redirect the merchant there, you don't call any API during it.

A reusable PHP client (drop into your plugin)

Shape this as you like; what matters is that the API surface is well-isolated so you can swap the underlying transport (WordPress's wp_remote_* vs. Guzzle, etc.) without touching the business logic.

<?php
namespace YourPlugin\Paypal;

class ThrivePaypalClient {
    private string $base;
    private int    $timeout;

    public function __construct() {
        $this->base    = defined( 'THRIVE_PAYPAL_API_BASE' )
            ? rtrim( THRIVE_PAYPAL_API_BASE, '/' )
            : 'https://tpa.stagingthrivethemes.com/api/paypal/v1';
        $this->timeout = defined( 'THRIVE_PAYPAL_HTTP_TIMEOUT' ) ? (int) THRIVE_PAYPAL_HTTP_TIMEOUT : 10;
    }

    /** Generate a 32-char client-side tracking secret. Store it BEFORE redirecting. */
    public function generateSecret(): string {
        return bin2hex( random_bytes( 16 ) );
    }

    /** POST /onboarding/start -> { url, expires_in } */
    public function partnerReferral( string $secret, string $site_url ): array {
        return $this->request( 'POST', '/onboarding/start', [], [
            'secret'   => $secret,
            'site_url' => $site_url,
        ] );
    }

    /** POST /onboarding/complete -> { client_id, sdk_client_token, ... } */
    public function exchangeCredentials( string $secret, string $referral_token, string $merchant_id, string $site_url, ?string $webhooks_url = null ): array {
        $body = compact( 'secret', 'referral_token', 'merchant_id', 'site_url' );
        if ( $webhooks_url ) $body['webhooks_url'] = $webhooks_url;
        return $this->request( 'POST', '/onboarding/complete', [], $body );
    }

    /** POST /auth/token (Basic auth) -> { access_token, expires_in } */
    public function accessToken( string $merchant_id, string $secret ): array {
        return $this->request( 'POST', '/auth/token', [
            'Authorization' => 'Basic ' . base64_encode( $merchant_id . ':' . $secret ),
        ] );
    }

    /** GET /merchant -> capabilities + payments_receivable */
    public function merchantInfo( string $bearer ): array {
        return $this->request( 'GET', '/merchant', [
            'Authorization' => 'Bearer ' . $bearer,
        ] );
    }

    /** Generic transport. */
    private function request( string $method, string $path, array $headers = [], ?array $body = null ): array {
        $args = [
            'method'  => $method,
            'timeout' => $this->timeout,
            'headers' => array_merge( [ 'Accept' => 'application/json' ], $headers ),
        ];
        if ( $body !== null ) {
            $args['headers']['Content-Type'] = 'application/json';
            $args['body'] = wp_json_encode( $body );
        }
        $resp = wp_remote_request( $this->base . $path, $args );
        if ( is_wp_error( $resp ) ) {
            throw new \RuntimeException( 'HTTP error: ' . $resp->get_error_message() );
        }
        $status = wp_remote_retrieve_response_code( $resp );
        $json   = json_decode( wp_remote_retrieve_body( $resp ), true );
        if ( $status >= 400 ) {
            throw new \RuntimeException( "Thrive PayPal API {$status}: " . ( $json['error'] ?? 'unknown' ) );
        }
        return is_array( $json ) ? $json : [];
    }
}

Notice there are zero mode-specific branches. When we flip from sandbox to live PayPal, only the THRIVE_PAYPAL_API_BASE changes; your code stays put.

POST /onboarding/start

Step 1 - Start onboarding

When the merchant clicks "Connect PayPal" in your plugin admin: generate a random 32-char secret, persist it on the site, then call partner-referral.

// In your plugin's admin action handler
$client = new ThrivePaypalClient();
$secret = $client->generateSecret();
update_option( 'thrive_paypal_secret', $secret );  // persist BEFORE redirecting

$site_url    = home_url();
$webhook_url = home_url( '/wp-json/your-plugin/paypal/webhook' );
update_option( 'thrive_paypal_webhook_url', $webhook_url );

$resp = $client->partnerReferral( $secret, $site_url );
wp_redirect( $resp['url'] );
exit;

curl equivalent

curl -X POST https://tpa.stagingthrivethemes.com/api/paypal/v1/onboarding/start \
  -H 'Content-Type: application/json' \
  -d '{"secret":"a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6","site_url":"https://merchant.example"}'

Expected response

{
  "url":        "https://www.sandbox.paypal.com/bizsignup/partner/entry?referralToken=ZWQ...",
  "expires_in": 1783171822    // Unix timestamp after which PayPal rejects the referral
}

url is what you redirect the merchant to. expires_in is the absolute Unix timestamp after which PayPal will reject the link; in practice this is hours away, but if the merchant abandons mid-flow and comes back days later, generate a fresh URL.

The URL returned is a real PayPal sandbox onboarding endpoint. Redirect the merchant there; they sign in with a PayPal sandbox business account and approve the partner permissions.
EXTERNAL PayPal hosted UI

Step 2 - Merchant approves on PayPal

No API call from you. The merchant lands on PayPal, signs in, reviews and approves. PayPal redirects them back to the site_url you supplied in Step 1, with query parameters:

https://merchant.example/?
    merchantId=<your secret echoed back by PayPal as tracking_id>
    &merchantIdInPayPal=HF5SN9RKCTNJ2
    &productIntentId=addipmt
    &isEmailConfirmed=true
    &accountStatus=BUSINESS_ACCOUNT
    &permissionsGranted=true
    &consentStatus=true
    &riskStatus=SUBSCRIBED_WITH_ALL_FEATURES

Key params: merchantIdInPayPal is the real PayPal merchant id you'll use in Step 3. merchantId is your own secret echoed back (PayPal uses our tracking_id field for it). permissionsGranted=true + consentStatus=true confirm a clean approval. Bail with a friendly error if either is missing.

Your plugin must handle this redirect and capture the parameters:

// admin-init hook or wherever you handle PayPal's return
add_action( 'init', function() {
    if ( ! isset( $_GET['merchantIdInPayPal'] ) || ! is_admin() ) return;

    $merchant_id     = sanitize_text_field( $_GET['merchantIdInPayPal'] );
    $permissions_ok  = ( $_GET['permissionsGranted'] ?? '' ) === 'true';
    $referral_token  = sanitize_text_field( $_GET['referral_token'] ?? '' );

    if ( ! $permissions_ok ) {
        // Merchant declined. Show error and bail.
        wp_die( 'PayPal onboarding cancelled. You can retry from the settings page.' );
    }

    // Proceed to Step 3 with these values.
    do_action( 'your_plugin_paypal_complete_onboarding', $merchant_id, $referral_token );
} );
POST /onboarding/complete

Step 3 - Exchange credentials

Combine the secret you stored in Step 1 with the merchant_id + referral_token from Step 2 to register the merchant in the Product API.

add_action( 'your_plugin_paypal_complete_onboarding', function( $merchant_id, $referral_token ) {
    $client       = new ThrivePaypalClient();
    $secret       = get_option( 'thrive_paypal_secret' );
    $site_url     = home_url();
    $webhooks_url = get_option( 'thrive_paypal_webhook_url' );

    try {
        $creds = $client->exchangeCredentials( $secret, $referral_token, $merchant_id, $site_url, $webhooks_url );
    } catch ( \RuntimeException $e ) {
        // Surface the error to the admin and retain $secret so they can retry.
        wp_die( 'PayPal credentials exchange failed: ' . esc_html( $e->getMessage() ) );
    }

    // Persist the merchant identity + JS SDK creds.
    update_option( 'thrive_paypal_merchant_id',    $merchant_id );
    update_option( 'thrive_paypal_client_id',      $creds['client_id'] );
    update_option( 'thrive_paypal_sdk_token',      $creds['sdk_client_token'] );
    update_option( 'thrive_paypal_sdk_expires',    time() + (int) $creds['sdk_client_token_expires_in'] );
}, 10, 2 );

Expected response

{
  "env":                         "sandbox",                  // or "live"
  "client_id":                   "AXCsMzCO5aAUmaBeRlm_AottTkIZwGhSC4SOXOfdzX-PmU5s_PqiS2fVUDQ5w1XJSjwASQfypFVO0UAI",
  "partner_merchant_id":         "HVEZ66K74VENY",
  "client_token":                "eyJicmFpbnRyZWUiOnsi…",   // nested Braintree+PayPal JWT
  "client_token_expires_in":     3239,                       // ~54 minutes
  "sdk_client_token":            "eyJraWQiOiJjMDg…",         // ES256-signed JWT
  "sdk_client_token_expires_in": 900,                        // 15 minutes
  "webhook_secret":              "b6af202343ec65854cdb34ce753ef483c68bf66dcf5f2813b42ecdf088901339"
}

client_id and sdk_client_token go into your PayPal JS SDK initialization on merchant checkout pages. Both tokens expire; refresh either via GET /merchant/credentials (Bearer auth) once you have the access token from Step 4.

The webhook_secret field is critical and delivered ONCE here. It's the 64-character HMAC-SHA256 key your plugin uses to verify the X-Thrive-Webhook-Signature header on every forwarded webhook. Store it encrypted on the merchant site immediately - if you lose it, fetch it again via GET /merchant/credentials. Without it your plugin cannot trust the webhook chain.
The env field tells you which PayPal environment this Product API instance is connected to - "sandbox" on staging, "live" on production. Cache it alongside the merchant so your admin UI can render a clear "Connected to Sandbox" or "Connected to Live" badge. The same value also appears on GET /merchant, so a settings page can refresh it without re-running onboarding.
POST /auth/token

Step 4 - Get a Bearer token

Trade (merchant_id, secret) for a long-lived (7d default) Bearer token. This is the token you'll send on every subsequent call.

function thrive_paypal_get_bearer(): string {
    $cached = get_transient( 'thrive_paypal_bearer' );
    if ( $cached ) return $cached;

    $client      = new ThrivePaypalClient();
    $merchant_id = get_option( 'thrive_paypal_merchant_id' );
    $secret      = get_option( 'thrive_paypal_secret' );

    $resp = $client->accessToken( $merchant_id, $secret );
    set_transient( 'thrive_paypal_bearer', $resp['access_token'], (int) $resp['expires_in'] - 60 );

    return $resp['access_token'];
}

curl equivalent

BASIC=$(printf '%s' "$MERCHANT_ID:$SECRET" | base64)
curl -X POST https://tpa.stagingthrivethemes.com/api/paypal/v1/auth/token \
  -H "Authorization: Basic $BASIC"

Expected response

{
  "access_token": "180d2f22ed7d81db6216d3e8b2bec76ff114acd43522a2ebf6a7670e81d3fadf",
  "expires_in":   517785.6  // seconds remaining on the cached token (~6 days)
}

The endpoint is idempotent: if a valid token already exists, you get the same one back with the remaining TTL on expires_in. On any subsequent 401, refresh by calling this again. Store the token in a transient that expires a bit before expires_in so you never send an expired Bearer.

Bearer auth /merchant /orders/* /subscriptions/* /webhooks/test

Step 5 - Use the Bearer for everything else

Verify the connection by fetching merchant info. After this any PayPal-API-backed endpoint is available.

$client = new ThrivePaypalClient();
$bearer = thrive_paypal_get_bearer();

$info = $client->merchantInfo( $bearer );

if ( ! ( $info['payments_receivable'] ?? false ) ) {
    // PayPal account isn't ready to accept money. Tell the merchant.
    return new WP_Error( 'paypal_not_ready', 'PayPal account is not yet able to receive payments.' );
}

$capabilities = wp_list_pluck( $info['capabilities'], 'status', 'name' );
// $capabilities = [ 'CUSTOM_CARD_PROCESSING' => 'ACTIVE', 'APPLE_PAY' => 'ACTIVE', ... ]

update_option( 'thrive_paypal_capabilities', $capabilities );

Expected response

{
  "env":                     "sandbox",                    // or "live" - Thrive-added field
  "merchant_id":             "HF5SN9RKCTNJ2",              // PayPal merchant id
  "tracking_id":             "76f3fb5ddc551f04b5c5ebf2c031074a",  // your `secret` from onboarding, echoed back
  "legal_name":              "Test Store",                 // merchant's legal name on PayPal
  "primary_email_confirmed": true,                          // merchant verified their PayPal email
  "payments_receivable":     true,                          // merchant can accept money (block onboarding if false)
  "capabilities": [
    { "name": "CUSTOM_CARD_PROCESSING", "status": "ACTIVE" },
    { "name": "APPLE_PAY",              "status": "ACTIVE" },
    { "name": "VENMO_PAY_PROCESSING",   "status": "ACTIVE" },
    { "name": "ADVANCED_VAULTING",      "status": "ACTIVE" }
    // ... 20+ capability entries total
  ],
  "products": [
    { "name": "PPCP_CUSTOM",     "status": "ACTIVE", "capabilities": ["CUSTOM_CARD_PROCESSING", "..."] },
    { "name": "PAYMENT_METHODS", "status": "SUBSCRIBED", "capabilities": ["APPLE_PAY", "..."] }
  ],
  "oauth_integrations":      [ /* internal PayPal partner records, usually safe to ignore */ ]
}

Fields the plugin typically uses: env drives the mode badge, payments_receivable + primary_email_confirmed gate showing the gateway in the buyer checkout, and capabilities[] enables/disables per-method UI (Apple Pay button, Venmo button, etc.). tracking_id is the same secret you generated at onboarding - PayPal echoes it back so you can verify the merchant on this end matches the one you onboarded.

From here, the same Bearer drives orders (/orders, /orders/{id}/capture, /captures/{id}/refund), vault subscriptions (/subscriptions/*), configuration updates (PATCH /merchant), and webhook tests (/webhooks/test). Identical code regardless of which PayPal environment the Product API points at.

Order lifecycle at a glance

A one-off PayPal payment is a six-step dance involving your plugin, the Product API, PayPal, and the buyer's browser. The first three are synchronous (buyer-driven). The next two complete the payment server-side. The last is the asynchronous webhook receipt that confirms the same event over a separate channel.

┌──────────┐   ┌──────────────────┐   ┌─────────────┐   ┌────────┐
│  Plugin  │   │ Thrive PayPal    │   │ PayPal      │   │ Buyer  │
│ (merch.) │   │ Product API      │   │ Hosted UI   │   │ browser│
└────┬─────┘   └────────┬─────────┘   └──────┬──────┘   └───┬────┘
     │ 1. POST /orders                       │              │
     │    { intent, purchase_units,          │              │
     │      application_context.return_url } │              │
     ├──────────────────►│                   │              │
     │   ◄── { id, links[rel=approve] }      │              │
     │                   │                   │              │
     │ 2. Redirect buyer to approve link     │              │
     ├───────────────────┼───────────────────┼─────────────►│
     │                   │   3. Buyer signs in + approves   │
     │                   │                   ├─────────────►│
     │ ◄── PayPal redirects buyer back ──────┤              │
     │     ?token=ORDER_ID&PayerID=XYZ       │              │
     │                   │                   │              │
     │ 4. POST /orders/{token}/capture       │              │
     ├──────────────────►│                   │              │
     │   ◄── { status: COMPLETED, captures[0].id, ... }     │
     │                   │                   │              │
     │ ── plugin marks order paid, grants access, etc. ──   │
     │                   │                   │              │
     │ 5. PayPal fires PAYMENT.CAPTURE.COMPLETED ───────►   │
     │   ◄── re-signed forward arrives at plugin webhook    │
     │       (X-Thrive-Webhook-Signature, body, headers)    │
     │                   │                   │              │
     │ 6. (later) POST /captures/{capture_id}/refund        │
     ├──────────────────►│                   │              │
     │   ◄── { id, status, amount }          │              │
Both 4 (return+capture) and 5 (webhook) happen for every paid order. The redirect is buyer-facing and could be skipped (buyer closes the tab); the webhook is server-to-server and reliable. Code both paths so your "mark this order paid" logic is idempotent: whichever event arrives first does the work, the second is a no-op.

Extending the PHP client

Add these methods to the ThrivePaypalClient class you built in the Onboarding section. Same transport, just different paths.

// in class ThrivePaypalClient

/** POST /orders -> { id, status, links } */
public function createOrder( string $bearer, array $payload ): array {
    return $this->request( 'POST', '/orders', [
        'Authorization' => 'Bearer ' . $bearer,
    ], [ 'data' => $payload ] );
}

/** GET /orders/{id} -> PayPal's order resource */
public function getOrder( string $bearer, string $id ): array {
    return $this->request( 'GET', '/orders/' . rawurlencode( $id ), [
        'Authorization' => 'Bearer ' . $bearer,
    ] );
}

/** POST /orders/{id}/capture -> { status: "COMPLETED", purchase_units[0].payments.captures[0].* } */
public function captureOrder( string $bearer, string $id ): array {
    return $this->request( 'POST', '/orders/' . rawurlencode( $id ) . '/capture', [
        'Authorization' => 'Bearer ' . $bearer,
    ] );
}

/** POST /captures/{capture_id}/refund -> lean { id, status, links } (breakdown arrives via PAYMENT.CAPTURE.REFUNDED webhook) */
public function refundCapture( string $bearer, string $capture_id, ?float $amount = null, ?string $currency = null, ?string $note = null ): array {
    $body = [];
    if ( $amount !== null )   $body['amount']        = $amount;
    if ( $currency !== null ) $body['currency']      = $currency;
    if ( $note !== null )     $body['note_to_payer'] = $note;
    return $this->request( 'POST', '/captures/' . rawurlencode( $capture_id ) . '/refund', [
        'Authorization' => 'Bearer ' . $bearer,
    ], $body ?: null );
}
POST /orders

Step 1 - Create an order

Anywhere the buyer triggers a checkout in your plugin (cart submit, paywall click, course purchase button, etc.) you POST the order payload. The shape mirrors PayPal's /v2/checkout/orders wrapped in { "data": {...} }.

Required fields

FieldTypeNotes
data.intentstringAlways "CAPTURE" for one-off purchases (use AUTHORIZE only if you need to capture later).
data.purchase_units[0].amount.currency_codestring3-letter ISO. The Product API forwards your value unchanged.
data.purchase_units[0].amount.valuestringAlways send as a string ("49.00"), not a float. PayPal returns money values as strings; mirror that.

Recommended fields

FieldWhy
purchase_units[0].descriptionShows up on PayPal checkout + the buyer's receipt + your sandbox account ledger. Helpful for debugging.
purchase_units[0].custom_idUp to 127 chars. Pack your own order id / user id / SKU here so the webhook payload echoes it back to you for correlation. Product API auto-trims if you exceed 127.
application_context.return_urlWhere PayPal redirects the buyer after approval. If you omit it, Product API defaults to the merchant's site_url - but always pass an explicit one with your token in the query so your return handler can recognise the redirect.
application_context.cancel_urlWhere PayPal redirects if the buyer clicks "Cancel and return to merchant". Same default behaviour.

PHP example

// In your "Buy now" handler
$client = new ThrivePaypalClient();
$bearer = thrive_paypal_get_bearer();

// Your own internal order row, created BEFORE calling PayPal so you have an id to round-trip.
$local_order_id = $wpdb->insert( 'your_orders', [
    'user_id'  => get_current_user_id(),
    'amount'   => 49.00,
    'currency' => 'USD',
    'status'   => 'pending',
    'sku'      => 'apprentice-course-42',
] );

$resp = $client->createOrder( $bearer, [
    'intent' => 'CAPTURE',
    'purchase_units' => [[
        'amount'      => [ 'currency_code' => 'USD', 'value' => '49.00' ],
        'description' => 'Apprentice course - Advanced Photography',
        'custom_id'   => (string) $local_order_id,  // echoes back in the webhook
    ]],
    'application_context' => [
        'return_url' => add_query_arg( 'thrive_paypal_return', $local_order_id, home_url( '/' ) ),
        'cancel_url' => add_query_arg( 'thrive_paypal_cancelled', $local_order_id, home_url( '/' ) ),
    ],
] );

// Persist the PayPal order id so the return handler can match.
$wpdb->update( 'your_orders', [ 'paypal_order_id' => $resp['id'] ], [ 'id' => $local_order_id ] );

// Find the approve link and redirect.
foreach ( $resp['links'] as $l ) {
    if ( $l['rel'] === 'approve' ) { wp_redirect( $l['href'] ); exit; }
}
wp_die( 'PayPal did not return an approve link.' );

Response shape (minimum)

{
  "id":     "63U60712PF952305N",
  "status": "CREATED",
  "links": [
    { "rel": "self",    "method": "GET",  "href": "https://api.sandbox.paypal.com/v2/checkout/orders/63U60712PF952305N" },
    { "rel": "approve", "method": "GET",  "href": "https://www.sandbox.paypal.com/checkoutnow?token=63U60712PF952305N" },
    { "rel": "update",  "method": "PATCH","href": "https://api.sandbox.paypal.com/v2/checkout/orders/63U60712PF952305N" },
    { "rel": "capture", "method": "POST","href":  "https://api.sandbox.paypal.com/v2/checkout/orders/63U60712PF952305N/capture" }
  ]
}
BROWSER 302 → /checkoutnow?token=…

Step 2 - Redirect the buyer

Already shown above (wp_redirect( $approve_link )) - no separate API call. The buyer's browser navigates to PayPal's hosted checkout. Your plugin's request handler returns control to the browser.

Alternative - JavaScript SDK / Smart Buttons: if you want an in-place experience (no full-page redirect, popup or iframe checkout instead), use PayPal's JS SDK with the client_id + sdk_client_token returned during onboarding. The SDK handles approval via callbacks; you skip the redirect-and-return dance entirely. Documented separately; this guide covers the redirect flow which is the simplest path to ship first.
EXTERNAL PayPal hosted checkout

Step 3 - Buyer approves on PayPal

No API call from you. The buyer signs in (or pays as guest with a card), reviews the order, and clicks Pay. PayPal redirects to your application_context.return_url with these query parameters appended:

https://merchant-site.com/?thrive_paypal_return=42
    &token=63U60712PF952305N        // = PayPal order id (use this for capture)
    &PayerID=A6HRLKGH4L89S           // = the buyer's PayPal account id (audit only)

If the buyer clicks Cancel on PayPal, they land at cancel_url instead. The cancel URL typically does NOT carry token - it just tells you the attempt was abandoned. Mark your local order as cancelled and show a friendly retry prompt.

POST /orders/{id}/capture

Step 4 - Handle return + capture

Your plugin's return handler runs server-side as soon as the buyer lands back. It must be idempotent - the buyer might hit reload, hit back, or PayPal might double-fire the redirect for any reason.

add_action( 'init', function () {
    if ( ! isset( $_GET['thrive_paypal_return'], $_GET['token'] ) ) {
        return;  // not our redirect
    }

    $local_id = (int)    $_GET['thrive_paypal_return'];
    $token    = sanitize_text_field( $_GET['token'] );    // PayPal order id
    $payer_id = sanitize_text_field( $_GET['PayerID'] ?? '' );  // audit only

    $order = $wpdb->get_row( $wpdb->prepare( 'SELECT * FROM your_orders WHERE id = %d', $local_id ) );
    if ( ! $order || $order->paypal_order_id !== $token ) {
        wp_die( 'Unknown order or token mismatch.' );
    }

    // Idempotency - already captured? Skip and show success.
    if ( $order->status === 'paid' ) {
        wp_redirect( $order->thank_you_url ?: home_url( '/thanks' ) );
        exit;
    }

    $client = new ThrivePaypalClient();
    $bearer = thrive_paypal_get_bearer();

    try {
        $resp = $client->captureOrder( $bearer, $token );
    } catch ( \Throwable $e ) {
        // Map a few common cases. PayPal returns 422 if the order is already captured (e.g. webhook
        // beat us to it), in which case treat as success and skip.
        if ( strpos( $e->getMessage(), 'ORDER_ALREADY_CAPTURED' ) !== false ) {
            $wpdb->update( 'your_orders', [ 'status' => 'paid' ], [ 'id' => $local_id ] );
            wp_redirect( $order->thank_you_url ?: home_url( '/thanks' ) );
            exit;
        }
        wp_die( 'Payment capture failed: ' . esc_html( $e->getMessage() ) );
    }

    if ( ( $resp['status'] ?? '' ) !== 'COMPLETED' ) {
        wp_die( 'Payment did not complete (status: ' . esc_html( $resp['status'] ?? '?' ) . ').' );
    }

    // Audit: confirm the payer who came back matches the one PayPal recorded.
    if ( $payer_id && ( $resp['payer']['payer_id'] ?? null ) !== $payer_id ) {
        error_log( "PayerID mismatch on order $token" );  // non-fatal; log + continue
    }

    $capture = $resp['purchase_units'][0]['payments']['captures'][0];

    $wpdb->update( 'your_orders', [
        'status'       => 'paid',
        'capture_id'   => $capture['id'],
        'gross_amount' => $capture['amount']['value'],
        'paypal_fee'   => $capture['seller_receivable_breakdown']['paypal_fee']['value'],
        'net_amount'   => $capture['seller_receivable_breakdown']['net_amount']['value'],
        'paid_at'      => current_time( 'mysql' ),
    ], [ 'id' => $local_id ] );

    // Your own hook for downstream effects: grant course access, send receipt, etc.
    do_action( 'thrive_apprentice_paypal_order_paid', $local_id, $resp );

    wp_redirect( $order->thank_you_url ?: home_url( '/thanks' ) );
    exit;
} );

Expected response

{
  "id":     "5YE7789789527652M",           // PayPal order id (same as input)
  "status": "COMPLETED",
  "payment_source": {
    "paypal": {
      "email_address":  "sb-buyer@personal.example.com",
      "account_id":     "A6HRLKGH4L89S",
      "account_status": "VERIFIED",
      "name":           { "given_name": "John", "surname": "Doe" }
    }
  },
  "purchase_units": [{
    "reference_id": "default",
    "shipping":     { /* buyer's PayPal-side shipping address */ },
    "payments": {
      "captures": [{
        "id":           "6D2913851K772370D",   // the capture id - persist for refunds + webhook correlation
        "status":       "COMPLETED",
        "amount":       { "currency_code": "USD", "value": "10.00" },
        "final_capture": true,
        "seller_receivable_breakdown": {
          "gross_amount": { "value": "10.00" },
          "paypal_fee":   { "value": "0.84" },
          "net_amount":   { "value": "9.16" }
        },
        "create_time":  "2026-06-04T11:18:19Z"
      }]
    }
  }],
  "payer": { /* same shape as payment_source.paypal but rendered as a payer entity */ },
  "links": [ /* refund, get-by-id, etc. */ ]
}
What "paypal_fee" and "net_amount" mean. PayPal charges a per-transaction fee (sandbox shows ~2.9% + $0.30). The capture response includes seller_receivable_breakdown with the gross amount, the fee, and the net amount that will land in the merchant's PayPal account. Store these for accounting.
INCOMING POST {webhooks_url}

Step 5 - Receive the webhook (server-to-server)

PayPal fires a PAYMENT.CAPTURE.COMPLETED event after capture, server-to-server, independent of the buyer's browser. The Product API verifies PayPal's signature, then re-signs the body with the merchant's webhook_secret and POSTs it to the webhooks_url you configured at onboarding.

Your plugin must register a REST route or callback at that URL, verify our HMAC, and process the event. The event will usually arrive within 10-30 seconds of capture, but may arrive seconds before Step 4 finishes - design for either order.

Headers your route receives

X-Thrive-Webhook-Signature: 3e15f3db2e5a487b33b96b5db9a700d6dfbe8d955a76fa6407634fa250e53e0b
X-Thrive-Webhook-Algorithm: HMAC-SHA256
X-Thrive-Forwarded-Merchant: HF5SN9RKCTNJ2
X-Thrive-Forwarded-By:       thrive-themes-api
Content-Type:                application/json
# PayPal's own headers are passed through for forensic logging:
Paypal-Transmission-Id:      ...
Paypal-Transmission-Time:    ...
Paypal-Auth-Algo:            SHA256withRSA
Paypal-Cert-Url:             ...
Paypal-Transmission-Sig:     ...

Body shape (minimum)

{
  "id":         "WH-7L010660D6173951W-8DV29762MW192621K",   // event id (idempotency key)
  "event_type": "PAYMENT.CAPTURE.COMPLETED",
  "create_time":"2026-06-04T08:46:46.096Z",
  "summary":    "Payment completed for $ 1.0 USD",
  "resource": {
    "id":          "2DR44140P9088745U",       // capture id
    "status":      "COMPLETED",
    "amount":      { "value": "1.00", "currency_code": "USD" },
    "payee":       { "merchant_id": "HF5SN9RKCTNJ2", ... },
    "supplementary_data": { "related_ids": { "order_id": "92274947D32070454" } },
    "seller_receivable_breakdown": { "paypal_fee": {...}, "net_amount": {...} },
    "final_capture": true,
    ...
  }
}

PHP receiver pattern

add_action( 'rest_api_init', function () {
    register_rest_route( 'thrive-apprentice/v1', '/paypal/webhook', [
        'methods'             => 'POST',
        'permission_callback' => '__return_true',  // we verify ourselves via HMAC
        'callback'            => 'thrive_apprentice_paypal_webhook',
    ] );
} );

function thrive_apprentice_paypal_webhook( WP_REST_Request $request ) {
    $body   = $request->get_body();
    $sig    = $request->get_header( 'x-thrive-webhook-signature' );
    $algo   = $request->get_header( 'x-thrive-webhook-algorithm' );
    $secret = get_option( 'thrive_paypal_webhook_secret' );

    if ( $algo !== 'HMAC-SHA256' ) return new WP_REST_Response( [ 'ok' => false ], 400 );
    $expected = hash_hmac( 'sha256', $body, $secret );
    if ( ! hash_equals( $expected, (string) $sig ) ) {
        return new WP_REST_Response( [ 'ok' => false ], 401 );
    }

    $event = json_decode( $body, true );

    // Idempotency - skip if we've already processed this event id.
    if ( $wpdb->get_var( $wpdb->prepare(
        'SELECT 1 FROM your_webhook_events WHERE event_id = %s', $event['id']
    ) ) ) {
        return new WP_REST_Response( [ 'ok' => true, 'duplicate' => true ], 200 );
    }
    $wpdb->insert( 'your_webhook_events', [ 'event_id' => $event['id'], 'received_at' => current_time( 'mysql' ) ] );

    switch ( $event['event_type'] ) {
        case 'PAYMENT.CAPTURE.COMPLETED':
            $order_id = $event['resource']['supplementary_data']['related_ids']['order_id'] ?? null;
            $local    = $wpdb->get_row( $wpdb->prepare(
                'SELECT * FROM your_orders WHERE paypal_order_id = %s', $order_id
            ) );
            if ( $local && $local->status !== 'paid' ) {
                // First to arrive -  do the work.
                $wpdb->update( 'your_orders', [ 'status' => 'paid', ... ], [ 'id' => $local->id ] );
                do_action( 'thrive_apprentice_paypal_order_paid', $local->id, $event['resource'] );
            }
            break;
        case 'PAYMENT.CAPTURE.REFUNDED':
            // see Step 6
            break;
    }

    return new WP_REST_Response( [ 'ok' => true ], 200 );
}
Always respond 2xx within a few seconds. If your handler errors or times out, the Product API will log the failure but won't retry the forward. PayPal also retries the original webhook on failure (and our middleware re-forwards each retry), so a flaky handler can result in N copies arriving. The event_id idempotency check above protects you.
POST /captures/{capture_id}/refund

Step 6 - Refund (when needed)

Refunds operate on the capture_id (from the capture response you stored in Step 4), not the order_id. PayPal supports full refunds (omit amount) and partial refunds. Refunds fire their own PAYMENT.CAPTURE.REFUNDED webhook back to your receiver.

The immediate POST response is intentionally lean. PayPal returns only { id, status, links } from POST /captures/{id}/refund - not the amount or seller_payable_breakdown. The full breakdown arrives asynchronously via the PAYMENT.CAPTURE.REFUNDED webhook (shown below). Plan your accounting writes around the webhook, not the synchronous response.

PHP example

// Full refund
$resp = $client->refundCapture( $bearer, $order->capture_id );

// Partial refund -  refund just 10 of the original 49 USD
$resp = $client->refundCapture( $bearer, $order->capture_id, 10.00, 'USD', 'Partial refund per support ticket #4242' );

// $resp is lean: { id, status, links }. Persist what you have, treat amount/breakdown as TBD.
$wpdb->insert( 'your_refunds', [
    'order_id'    => $order->id,
    'refund_id'   => $resp['id'],                  // PayPal refund id
    'capture_id'  => $order->capture_id,           // the capture this refund operates on
    'requested_amount' => 10.00,                   // what your code asked for; not echoed by PayPal
    'status'      => $resp['status'],              // PENDING | COMPLETED | CANCELLED | FAILED
    'requested_at' => current_time( 'mysql' ),
] );

// Mark the local order's refund status visually in admin -  but the canonical
// "fully refunded yes/no" decision is made when the webhook arrives with
// seller_payable_breakdown.total_refunded_amount populated.

Response shape (real PayPal, immediate POST)

{
  "id":     "4ED6440038202642J",
  "status": "COMPLETED",
  "links": [
    { "rel": "self", "method": "GET", "href": "https://api.sandbox.paypal.com/v2/payments/refunds/4ED6440038202642J" },
    { "rel": "up",   "method": "GET", "href": "https://api.sandbox.paypal.com/v2/payments/captures/45175558479195051" }
  ]
}

PAYMENT.CAPTURE.REFUNDED webhook payload (what your receiver sees)

{
  "id":           "WH-2RJ77494ST868684X-06002372G08696506",   // event id; use for idempotency
  "event_type":   "PAYMENT.CAPTURE.REFUNDED",
  "create_time":  "2026-06-04T09:33:27.833Z",
  "resource_type":"refund",
  "summary":      "A $ 0.5 USD capture payment was refunded",
  "resource": {
    "id":            "4ED6440038202642J",            // refund id (matches the POST response)
    "status":        "COMPLETED",
    "amount":        { "value": "0.50", "currency_code": "USD" },
    "note_to_payer": "Partial refund per support ticket #4242",
    "create_time":   "2026-06-04T02:33:23-07:00",
    "update_time":   "2026-06-04T02:33:23-07:00",
    "payer": {
      "merchant_id":   "HF5SN9RKCTNJ2",              // your merchant
      "email_address": "sb-647nhi51480790@business.example.com"
    },
    "seller_payable_breakdown": {
      "gross_amount":          { "value": "0.50", "currency_code": "USD" },
      "paypal_fee":            { "value": "0.00", "currency_code": "USD" },
      "net_amount":            { "value": "0.50", "currency_code": "USD" },
      "total_refunded_amount": { "value": "0.50", "currency_code": "USD" }   // cumulative across all refunds on this capture
    },
    "links": [
      { "rel": "self", "method": "GET", "href": "https://api.sandbox.paypal.com/v2/payments/refunds/4ED6440038202642J" },
      { "rel": "up",   "method": "GET", "href": "https://api.sandbox.paypal.com/v2/payments/captures/45175558479195051" }
    ]
  }
}

The resource.id in the webhook matches the id from the POST response, so your handler can look up the local refund row by that id. The seller_payable_breakdown.total_refunded_amount is cumulative - if you do multiple partial refunds, this field keeps growing across events. Compare to the order's gross to know when the order is fully refunded.

Adding a handler in your webhook receiver

// extend the switch in your webhook handler from Step 5
case 'PAYMENT.CAPTURE.REFUNDED':
    $refund_id      = $event['resource']['id'];
    $refund_amount  = $event['resource']['amount']['value'];
    $total_refunded = $event['resource']['seller_payable_breakdown']['total_refunded_amount']['value'];
    $up_capture_id  = null;
    foreach ( $event['resource']['links'] ?? [] as $l ) {
        if ( $l['rel'] === 'up' ) {
            // Extract capture_id from the 'up' href: .../v2/payments/captures/{capture_id}
            $up_capture_id = basename( parse_url( $l['href'], PHP_URL_PATH ) );
            break;
        }
    }

    $wpdb->update( 'your_refunds', [
        'status'         => $event['resource']['status'],   // COMPLETED, PENDING, etc.
        'actual_amount'  => $refund_amount,
        'paypal_fee'     => $event['resource']['seller_payable_breakdown']['paypal_fee']['value'],
        'net_amount'     => $event['resource']['seller_payable_breakdown']['net_amount']['value'],
        'completed_at'   => current_time( 'mysql' ),
    ], [ 'refund_id' => $refund_id ] );

    // Also update the local order if fully refunded
    $local = $wpdb->get_row( $wpdb->prepare( 'SELECT * FROM your_orders WHERE capture_id = %s', $up_capture_id ) );
    if ( $local && (float) $total_refunded >= (float) $local->gross_amount ) {
        $wpdb->update( 'your_orders', [ 'status' => 'refunded' ], [ 'id' => $local->id ] );
        do_action( 'thrive_apprentice_paypal_order_refunded', $local->id, $event['resource'] );
        // e.g. Thrive Apprentice listens here to revoke course access
    }
    break;
Refund status caveats: a successful PayPal refund usually returns COMPLETED immediately. In rare cases (insufficient balance on the merchant's PayPal account, etc.) it can come back PENDING and resolve later via the same webhook event being re-fired with the updated status. Always wait for a COMPLETED webhook before considering the refund final from an accounting perspective.

Recurring (Vault-Based RBM)

This is the most architecturally important section in this guide. Read it before writing any subscription / recurring-billing code, even if you've used PayPal before.

We don't use PayPal's Subscriptions API

PayPal offers a native Subscriptions API at /v1/billing/subscriptions that manages recurring billing on PayPal's side - including the "Recurring $X every month" messaging on their hosted checkout. We do not use it. Our recurring billing follows the Recurring Billing Module (RBM) pattern that PayPal scoped for our integration:

  • The first charge is a normal Orders-API order with payment_source.{method}.attributes.vault.store_in_vault: ON_SUCCESS
  • On capture, PayPal returns a vault_id - we store it server-side
  • The Product API's daily cron runs through due subscriptions, creates a fresh Orders-API order using payment_source.{method}.vault_id, and PayPal auto-captures the renewal
  • Each renewal fires a normal PAYMENT.CAPTURE.COMPLETED webhook back to your plugin (same webhook handler as a one-off order)

Why this pattern (and not the Subscriptions API)?

  • Platform fees. PayPal's Subscriptions API does not support partner platform fees on recurring charges; the Orders API does. RBM lets us stay on Orders for renewals while still getting recurring behaviour.
  • PayPal scoped it for us. The PayPal-issued Finalized Solution Scope explicitly selects "Buyer Not Present Use-Case (e.g., Subscriptions) - API only" + "Recurring Billing Module (RBM) - for vault without purchase" for our integration. Vault is the contract; Subscriptions API is not.
  • Connected Path requires it. PPCP Connected Path partners cannot inject platform fees on Subscriptions-API charges, so RBM is the only path that works for a rev-share partner integration.
Consequence for the buyer experience (important). Because PayPal sees each renewal as a separate one-off order using a stored payment method, PayPal's hosted checkout will NOT display any recurring / subscription messaging. The buyer sees "$9.99 today" + "Link PayPal to Test Store", clicks "Link and Review", and that's it. No "$9.99 every month" callout, no terms of service link, no PayPal-side cancellation UI later.

This is not a bug - it's the trade-off of choosing RBM over the Subscriptions API. The responsibility for displaying recurring terms to the buyer falls on YOUR plugin's checkout page, not PayPal. Specifically:

  • Show the full recurring schedule on your checkout: "$9.99 today, then $9.99 every month, cancel anytime"
  • Link to your refund + cancellation policy
  • Require explicit consent (a checkbox or equivalent) before the buyer can submit
  • Provide an in-plugin cancellation UI - the buyer cannot cancel from PayPal's account view because PayPal doesn't know this is recurring

Compliance frameworks (PCI, EU consumer protection, US FTC ROSCA) treat this as the merchant's obligation regardless of payment processor, but with PayPal Subscriptions API you get most of it for free via the PayPal checkout UI. With RBM you don't, so your checkout template MUST do the work.

What PayPal's checkout looks like for a vault order

For comparison, here's what the buyer sees on PayPal's hosted page during the initial vault attachment. Note the absence of any recurring language:

PayPal sandbox hosted checkout for a vault order. The Pay in full panel shows $9.99 today. The CTA button reads Link and Review. The footer caption reads Link PayPal to Test Store with no mention of recurring charges.
PayPal's hosted checkout for vault order 1FA667353R6161355 ($9.99/month plan). Nothing on this page tells the buyer they're agreeing to recurring charges - no schedule, no "monthly" label, no subscription confirmation. That disclosure is your plugin's responsibility (see above).
  • The "Pay in full" panel shows the amount as "$9.99 today" - no "every month" suffix, no recurring indicator
  • The CTA button reads "Link and Review" (not "Pay Now" or "Subscribe") because PayPal interprets this as a vault attachment + one-time charge
  • The "Link PayPal to Test Store" caption below the payment methods refers to the vault permission - "Manage anytime in PayPal" applies to the vault token, not a subscription
  • No subscription confirmation page appears after approval - PayPal just redirects back to your return_url with ?token=ORDER_ID&PayerID=…

Recurring lifecycle at a glance

Three buyer-facing phases (create + approve + activate) and then a server-only loop (cron renewal → webhook → next renewal) that continues until you cancel.

┌──────────┐   ┌──────────────────┐   ┌─────────────┐   ┌────────┐
│  Plugin  │   │ Thrive PayPal    │   │ PayPal      │   │ Buyer  │
│ (merch.) │   │ Product API      │   │ Hosted UI   │   │ browser│
└────┬─────┘   └────────┬─────────┘   └──────┬──────┘   └───┬────┘
     │ 1. POST /recurring                    │              │
     │    { intent, purchase_units, source,  │              │
     │      recurring_times, total_cycles,   │              │
     │      thrive_order_id }                │              │
     ├──────────────────►│                   │              │
     │   ◄── { id, status: PAYER_ACTION_REQUIRED,           │
     │         links[rel=payer-action] }     │              │
     │                   │                   │              │
     │ 2. Redirect buyer to payer-action link               │
     ├───────────────────┼───────────────────┼─────────────►│
     │                   │  3. Buyer signs in + clicks      │
     │                   │     "Link and Review" -  vault   │
     │                   │     attaches their payment       │
     │                   │     method                       │
     │ ◄── PayPal redirects buyer back ──────┤              │
     │     ?token=ORDER_ID&PayerID=XYZ       │              │
     │                   │                   │              │
     │ 4. POST /subscriptions/{id}/activate      │              │
     ├──────────────────►│                   │              │
     │   ◄── { status: COMPLETED,            │              │
     │         payment_source.{method}.attributes.vault.id, │
     │         captures[0].id, ... }         │              │
     │                   │                   │              │
     │ ── plugin marks sub active, grants access ──         │
     │                   │                   │              │
     │ ─ ─ ─ ─ ─ ─ ─ ─ ─ time passes ─ ─ ─ ─ ─ ─ ─ ─ ─ ─    │
     │                   │                   │              │
     │                   │ 5. Cron runs, finds due sub      │
     │                   │    POST /v2/checkout/orders w/   │
     │                   │    payment_source.{m}.vault_id   │
     │                   │ ─────────────────►│              │
     │                   │   ◄── { status: COMPLETED }     │ (auto-capture; no separate call)
     │                   │                   │              │
     │                   │ 6. PayPal fires PAYMENT.CAPTURE.COMPLETED webhook
     │ ◄── re-signed forward arrives at plugin webhook      │
     │     (with x_thrive_order_id annotation)              │
     │                   │                   │              │
     │ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ loops monthly ─ ─ ─ ─ ─ ─ ─  │
     │                   │                   │              │
     │ 7. (eventually) POST /subscriptions/{id}/cancel          │
     ├──────────────────►│                   │              │
     │   ◄── { ok: true, vault_deleted: true }              │

Extending the PHP client

Three new methods on ThrivePaypalClient. activate and cancel are body-less; the path carries the id.

// in class ThrivePaypalClient

/** POST /recurring -> { id, status: PAYER_ACTION_REQUIRED, links } */
public function createVaultSubscription( string $bearer, array $payload ): array {
    return $this->request( 'POST', '/recurring', [
        'Authorization' => 'Bearer ' . $bearer,
    ], [ 'data' => $payload ] );
}

/** POST /subscriptions/{id}/activate -> { status: COMPLETED, payment_source.{method}.attributes.vault.id, captures[0].id } */
public function activateVaultSubscription( string $bearer, string $id ): array {
    return $this->request( 'POST', '/subscriptions/' . rawurlencode( $id ) . '/activate', [
        'Authorization' => 'Bearer ' . $bearer,
    ] );
}

/** GET /subscriptions/{id} -> PayPal order resource + our merged 'interval' field */
public function getVaultSubscription( string $bearer, string $id ): array {
    return $this->request( 'GET', '/subscriptions/' . rawurlencode( $id ), [
        'Authorization' => 'Bearer ' . $bearer,
    ] );
}

/** POST /subscriptions/{id}/cancel -> { ok, deactivated, vault_deleted } */
public function cancelVaultSubscription( string $bearer, string $id ): array {
    return $this->request( 'POST', '/subscriptions/' . rawurlencode( $id ) . '/cancel', [
        'Authorization' => 'Bearer ' . $bearer,
    ] );
}
POST /recurring

Step 1 - Create a vault order

Same shape as POST /orders with four extra fields that tell the Product API this is a vault-recurring setup. The Product API auto-injects the payment_source.{source}.attributes.vault.store_in_vault: ON_SUCCESS config and records a row in paypal_subscriptions with your thrive_order_id so renewal webhooks can be correlated back to your local record.

Required extra fields (in addition to standard order fields)

FieldTypeNotes
data.source string Which payment source the vault attaches to. Allowlist: "paypal", "card", "venmo". Apple Pay support is recurring-only and follows a slightly different flow; see the Apple Pay section.
data.recurring_times string Interval string for the cron, e.g. "1 month", "1 year", "1 week". Passed to PHP's strtotime() to compute renewal_at.
data.total_cycles int 0 = infinite (e.g. monthly subscription, no end date). Any positive integer = number of cycles, after which the cron auto-deactivates the subscription. The first charge counts as cycle 1.
data.thrive_order_id int Your local subscription/order id. Echoed back as x_thrive_order_id on every renewal webhook so your handler can correlate without parsing custom_id.

PHP example

// Pre-condition: your checkout page has displayed recurring terms + got consent
// before this code runs. Compliance is on you, not PayPal.

$client = new ThrivePaypalClient();
$bearer = thrive_paypal_get_bearer();

// Create your local subscription row first so you have an id to round-trip.
$local_sub_id = $wpdb->insert( 'your_subscriptions', [
    'user_id'         => get_current_user_id(),
    'plan_id'         => 42,
    'amount'          => 9.99,
    'currency'        => 'USD',
    'interval'        => '1 month',
    'status'          => 'pending',
] );

$resp = $client->createVaultSubscription( $bearer, [
    'intent' => 'CAPTURE',
    'purchase_units' => [[
        'amount'      => [ 'currency_code' => 'USD', 'value' => '9.99' ],
        'description' => 'Apprentice Pro - Monthly subscription',
    ]],
    'application_context' => [
        'return_url' => add_query_arg( 'thrive_paypal_vault_return', $local_sub_id, home_url( '/' ) ),
        'cancel_url' => add_query_arg( 'thrive_paypal_vault_cancelled', $local_sub_id, home_url( '/' ) ),
    ],
    'source'           => 'paypal',     // or 'card', 'venmo'
    'recurring_times'  => '1 month',
    'total_cycles'     => 0,            // 0 = infinite
    'thrive_order_id'  => (int) $local_sub_id,
] );

// Save the PayPal order id; it doubles as our 'subscription_id' on subsequent calls.
$wpdb->update( 'your_subscriptions', [ 'paypal_order_id' => $resp['id'] ], [ 'id' => $local_sub_id ] );

// Redirect to the payer-action link (note the different rel than one-off orders).
foreach ( $resp['links'] as $l ) {
    if ( $l['rel'] === 'payer-action' ) { wp_redirect( $l['href'] ); exit; }
}
wp_die( 'PayPal did not return a payer-action link.' );

Response shape (minimum)

{
  "id":     "6XE605547B957144A",
  "status": "PAYER_ACTION_REQUIRED",          // NOT "CREATED" -  different from one-off orders
  "payment_source": { "paypal": [] },          // ack of the vault attribute (PayPal serialises as empty when no token yet)
  "links": [
    { "rel": "self",          "method": "GET", "href": ".../v2/checkout/orders/6XE605547B957144A" },
    { "rel": "payer-action",  "method": "GET", "href": "https://www.sandbox.paypal.com/checkoutnow?token=6XE605547B957144A" }
  ]
}
Note the differences from one-off orders: status is PAYER_ACTION_REQUIRED instead of CREATED, the buyer-facing link's rel is payer-action instead of approve. Your code that finds the buyer-redirect URL must check for both rels if you reuse it across one-off and recurring flows.
EXTERNAL PayPal hosted UI

Step 2 - Buyer approves on PayPal (what the UI looks like)

The buyer lands on PayPal's hosted checkout. As explained in the overview, this page does NOT mention recurring billing. The screenshots below are what the buyer sees for a $9.99/month vault order:

Important caveats to share with your plugin's compliance team:
  • The "$9.99 today" line does NOT say "and every month after that"
  • There is NO "by clicking Pay you agree to recurring charges" disclosure on PayPal's page
  • After approval, PayPal's account view will NOT show this as a "subscription" - it'll show as a one-time payment + linked account
  • The buyer cannot cancel from PayPal's "Manage subscriptions" view because PayPal has no subscription record - they must come back to your site
  • Your checkout page MUST present the full recurring schedule, T&Cs, and cancellation method before sending the buyer to PayPal

After the buyer clicks "Link and Review", PayPal redirects back to your application_context.return_url with the same query shape as a one-off order:

https://merchant-site.com/?
    thrive_paypal_vault_return=42
    &token=6XE605547B957144A    // PayPal order id (use for activate)
    &PayerID=A6HRLKGH4L89S       // buyer's PayPal account id
POST /subscriptions/{id}/activate

Step 3 - Activate (capture first payment + store vault)

Same idempotency rules as the one-off Step 4 - the buyer might hit reload, the webhook for the first capture might arrive first, etc.

add_action( 'init', function () {
    if ( ! isset( $_GET['thrive_paypal_vault_return'], $_GET['token'] ) ) return;

    $local_sub_id = (int) $_GET['thrive_paypal_vault_return'];
    $token        = sanitize_text_field( $_GET['token'] );

    $sub = $wpdb->get_row( $wpdb->prepare( 'SELECT * FROM your_subscriptions WHERE id = %d', $local_sub_id ) );
    if ( ! $sub || $sub->paypal_order_id !== $token ) wp_die( 'Unknown subscription or token mismatch.' );
    if ( $sub->status === 'active' ) { wp_redirect( $sub->thank_you_url ); exit; }   // idempotency

    $client = new ThrivePaypalClient();
    $bearer = thrive_paypal_get_bearer();
    $resp   = $client->activateVaultSubscription( $bearer, $token );

    if ( ( $resp['status'] ?? '' ) !== 'COMPLETED' ) {
        wp_die( 'Subscription activation failed (status: ' . esc_html( $resp['status'] ?? '?' ) . ').' );
    }

    $vault_id   = $resp['payment_source']['paypal']['attributes']['vault']['id']
                ?? $resp['payment_source']['card']['attributes']['vault']['id']
                ?? null;
    $capture_id = $resp['purchase_units'][0]['payments']['captures'][0]['id'] ?? null;

    $wpdb->update( 'your_subscriptions', [
        'status'           => 'active',
        'vault_id'         => $vault_id,         // Product API also stores this; this is your local copy
        'first_capture_id' => $capture_id,
        'next_renewal_at'  => date( 'Y-m-d H:i:s', strtotime( '+' . $sub->interval ) ),
        'activated_at'     => current_time( 'mysql' ),
    ], [ 'id' => $local_sub_id ] );

    do_action( 'thrive_apprentice_paypal_subscription_activated', $local_sub_id, $resp );
    wp_redirect( $sub->thank_you_url );
    exit;
} );

Response shape (real PayPal sandbox)

{
  "id":     "6XE605547B957144A",
  "status": "COMPLETED",
  "payment_source": {
    "paypal": {
      "email_address":  "sb-buyer@personal.example.com",
      "account_id":     "A6HRLKGH4L89S",
      "account_status": "VERIFIED",
      "attributes": {
        "vault": {
          "id":     "87v56082bb4667033",     // store this -  it's how renewals charge
          "status": "VAULTED",
          "customer": { "id": "OfliXFlMvM" },
          "links": [ ... ]
        }
      }
    }
  },
  "purchase_units": [{
    "payments": {
      "captures": [{
        "id":     "3T0775908G925462W",
        "status": "COMPLETED",
        "amount": { "value": "9.99", "currency_code": "USD" },
        ...
      }]
    }
  }],
  "payer": { ... }
}
SERVER CRON Product API only

Step 4 - Renewals (server-driven, no buyer)

You don't write code for this. The Product API's hourly cron handles renewals. On each cycle for a due subscription:

  1. Cron finds subscriptions where renewal_at <= NOW() and is_active = true
  2. For each, creates a fresh PayPal Order with payment_source.{source}.vault_id + stored_credential block (MERCHANT / RECURRING / SUBSEQUENT)
  3. PayPal auto-captures the renewal - the order comes back as COMPLETED immediately, no separate capture call needed
  4. Cron decrements cycles_left (if finite) and advances renewal_at by the interval
  5. PayPal fires CHECKOUT.ORDER.COMPLETED + PAYMENT.CAPTURE.COMPLETED to the Product API's webhook URL
  6. Product API verifies PayPal's signature, then forwards the body to your plugin's webhooks_url signed with your webhook_secret (HMAC-SHA256). The forwarded payload also carries an x_thrive_order_id annotation set to the subscription's thrive_order_id so you can correlate the renewal back to your local row without parsing custom_id.

Verified end-to-end on PayPal sandbox (2026-06-04)

A live $1.00 monthly subscription was created, approved by a sandbox buyer, activated, then renewed via the cron one minute later. The renewal capture (1UK86176JX559164B) settled COMPLETED at PayPal, the parent row's cycles_left dropped 12 → 11, and the forwarded webhook arrived at the merchant receiver with the headers below:

X-Thrive-Forwarded-By: thrive-themes-api
X-Thrive-Forwarded-Merchant: HF5SN9RKCTNJ2
X-Thrive-Webhook-Signature: 9183ac049bf8dac519d753d1a4daa2c284368a3748204fcac8031eb6d5f5d143
X-Thrive-Webhook-Algorithm: HMAC-SHA256

# PayPal's original signature headers are passed through too,
# in case you want to verify against PayPal's cert as a defence-in-depth check:
Paypal-Auth-Algo: SHA256withRSA
Paypal-Cert-Url: https://api.sandbox.paypal.com/v1/notifications/certs/CERT-...
Paypal-Transmission-Id: 25c808e2-6007-11f1-966b-d329b76cb3e3
Paypal-Transmission-Sig: DvkD2hljFAjy1B4sqhjFuiOXbUGr...

Verify X-Thrive-Webhook-Signature with hash_hmac('sha256', $raw_body, $your_webhook_secret) before doing anything else with the event.

What your plugin needs to do

Your existing PAYMENT.CAPTURE.COMPLETED webhook handler from the Orders section is the same handler that processes renewals. The discriminator is the x_thrive_order_id field that the Product API adds to the forwarded payload:

// Extend your existing PAYMENT.CAPTURE.COMPLETED handler:
case 'PAYMENT.CAPTURE.COMPLETED':
    $thrive_order_id = $event['x_thrive_order_id'] ?? null;
    $capture         = $event['resource'];

    if ( $thrive_order_id ) {
        // This is a RENEWAL of an RBM subscription. Look up the local subscription row,
        // create a renewal-cycle child record, grant/extend access.
        $sub = $wpdb->get_row( $wpdb->prepare(
            'SELECT * FROM your_subscriptions WHERE id = %d', $thrive_order_id
        ) );
        if ( $sub && ! already_processed_event( $event['id'] ) ) {
            $wpdb->insert( 'your_subscription_renewals', [
                'subscription_id'  => $sub->id,
                'capture_id'       => $capture['id'],
                'amount'           => $capture['amount']['value'],
                'paid_at'          => current_time( 'mysql' ),
            ] );
            $wpdb->update( 'your_subscriptions', [
                'last_renewed_at'  => current_time( 'mysql' ),
                'next_renewal_at'  => date( 'Y-m-d H:i:s', strtotime( '+' . $sub->interval ) ),
            ], [ 'id' => $sub->id ] );

            do_action( 'thrive_apprentice_paypal_subscription_renewed', $sub->id, $capture );
            // e.g. extend course access for another month
        }
    } else {
        // Not a renewal -  it's a one-off order or a first-cycle activation
        // (handled by your existing logic from the Orders section).
        ...
    }
    break;
Failed renewals. If a renewal capture fails (declined card, expired vault token, insufficient PayPal balance), PayPal fires PAYMENT.CAPTURE.DENIED instead of COMPLETED. The Product API has a D5 dunning policy (3-day grace + Day 3 / Day 7 retry) and will deactivate the subscription + fire a final revoke webhook if all retries fail. Your handler needs cases for both PAYMENT.CAPTURE.DENIED (transient - do nothing, retry will come) and the deactivation event (revoke access).
POST /subscriptions/{id}/cancel

Step 5 - Cancel

Cancellation MUST be initiated from your plugin's UI (the buyer can't cancel from PayPal - PayPal has no subscription record). Your in-plugin cancellation flow calls POST /subscriptions/{id}/cancel on the Product API, which:

  1. Deactivates the local paypal_subscriptions row (is_active = false)
  2. Calls PayPal's DELETE /v3/vault/payment-tokens/{vault_id} to remove the stored payment method
  3. Returns { ok: true, deactivated: true, vault_deleted: true|false }
// In your "Cancel subscription" form handler
$client = new ThrivePaypalClient();
$bearer = thrive_paypal_get_bearer();
$resp   = $client->cancelVaultSubscription( $bearer, $sub->paypal_order_id );

if ( $resp['ok'] ?? false ) {
    $wpdb->update( 'your_subscriptions', [
        'status'      => 'cancelled',
        'cancelled_at'=> current_time( 'mysql' ),
    ], [ 'id' => $sub->id ] );

    do_action( 'thrive_apprentice_paypal_subscription_cancelled', $sub->id, $resp );
    // e.g. set course access expiry to the end of the current paid period
}
What "vault_deleted" tells you: if false, PayPal's vault delete call failed (rare; usually just means the token had already expired). The subscription is still cancelled locally - the cron won't try to renew an inactive subscription regardless. Log the failure for audit but don't surface it to the buyer.

Other endpoints (reference)

Endpoints not covered by the step-by-step flows above. All require Bearer auth (use the token from Step 4 of Onboarding). Sandbox base URL: https://tpa.stagingthrivethemes.com/api/paypal/v1.

GET /merchant/credentials

Fresh PayPal SDK + client tokens for the merchant, plus the merchant's webhook_secret for HMAC verification. Call this when your cached sdk_client_token nears expiry (it's only valid for ~15 min), or when you've lost the webhook_secret you cached at onboarding. The client_id + partner_merchant_id are stable and rarely change.

{
  "client_id":                "AXCsMzCO5aAUmaBeRlm_...",   // safe to expose to browser
  "partner_merchant_id":      "HVEZ66K74VENY",
  "client_token":             "eyJicmFpbnRyZWUi...",       // nested Braintree+PayPal JWT
  "client_token_expires_in":  1321,                        // seconds remaining
  "webhook_secret":           "b6af2023...3fadf"            // 64-hex HMAC key (same value delivered at onboarding)
}
PATCH /merchant

Update mutable merchant fields. Accepts license_key and webhooks_url. Unknown fields are ignored.

// Request body:
{ "webhooks_url": "https://merchant.example/wp-json/your-plugin/paypal/webhook" }
{ "success": true }
DELETE /merchant

Disconnect the merchant. Marks the row inactive on the Product API; the bearer token stops working immediately. Active vault subscriptions enter wind-down (D17: existing renewals continue for 30 days, no new subscriptions accepted). Call this from your plugin's "Disconnect PayPal" admin button.

{ "success": true }
GET /orders/{id}

Look up an order's current state by PayPal order id. Useful as a fallback if a return or webhook is lost. Response mirrors PayPal's GET /v2/checkout/orders/{id} shape: id, status, payment_source, purchase_units (with any captures), payer, create_time, update_time, links.

GET /subscriptions/{id}

Look up a vault subscription's current state. Returns the PayPal order resource with one Thrive-added field, interval (the renewal cadence string you supplied at create, e.g. "monthly"), so the plugin can render the period without storing it locally.

{
  "id":             "0U192501NM840674P",
  "status":         "COMPLETED",
  "intent":         "CAPTURE",
  "interval":       "monthly",                  // Thrive-added field
  "payment_source": { "paypal": { ... } },      // includes the vault token under attributes.vault.id
  "purchase_units": [{ "payments": { "captures": [...] } }],
  "payer":          { ... },
  "create_time":    "2026-06-04T13:00:00Z",
  "update_time":    "2026-06-04T13:01:12Z",
  "links":          [ ... ]
}
POST /webhooks/test

Send a Thrive-signed test webhook to the merchant's webhooks_url. Useful for smoke-testing your handler without waiting on a real PayPal event. The payload has event_type: "WEBHOOK.TEST" and carries the same X-Thrive-Webhook-Signature + X-Thrive-Forwarded-Merchant headers your real handler will see.

{
  "success":       true,
  "test_id":       "TEST-D8F9C60975FF6414",     // matches the event id in the body PayPal received
  "response_code": 200                          // the HTTP code your handler returned
}
POST /webhooks PayPal calls this directly - no plugin code involved

PayPal's webhook destination. The Product API verifies PayPal's signature, then re-signs and forwards every relevant event to each connected merchant's webhooks_url. Listed here for completeness; you don't call this from the plugin. Responds { "received": true } back to PayPal as long as ingest succeeded (forwarding failures are logged but don't fail the response).

What to store on the merchant's site

Field WordPress option name Sensitivity Notes
merchant_id thrive_paypal_merchant_id Low Identifies the merchant; non-secret
secret thrive_paypal_secret High Encrypt at rest. Basic-auth password for token issuance.
access_token transient: thrive_paypal_bearer High 7-day TTL. Store as a transient so it expires cleanly.
client_id thrive_paypal_client_id Low For PayPal JS SDK init on checkout. Safe to expose to browser.
sdk_client_token thrive_paypal_sdk_token Medium Short-lived (1h); refresh via /merchant/credentials.
webhook_secret thrive_paypal_webhook_secret High Encrypt at rest. HMAC-SHA256 key for verifying X-Thrive-Webhook-Signature on every forwarded webhook. Delivered at /onboarding/complete; refreshable via /merchant/credentials.

Anything else (vault tokens, subscription state, partner credentials) lives on the Product API. Don't try to cache PayPal merchant capabilities aggressively - they can change when PayPal vets a new capability.

Error handling

Status What happened What to do
401 Bearer missing/expired, or unknown merchant on Basic auth Re-issue Bearer via /auth/token. If still 401, restart from Step 1.
422 Validation error Body has the field-level errors. Show them to the admin user.
503 Upstream PayPal error Retry once after a short delay. If persistent, surface and log.
500 Internal Product API error Log + report. Do not retry blindly.

Standard error envelope:

{
  "error":  "Upstream PayPal error",
  "status": 503,
  "body":   { /* original PayPal error JSON */ }
}

Re-onboarding a merchant

Merchants sometimes need to disconnect and reconnect - different PayPal account, new business entity, debugging. The flow is the same: call partner-referral again, walk them through PayPal, exchange credentials. The Product API handles the transition cleanly:

  • If you reuse the same secret but a different merchant_id, the old customer record is deactivated and a new one is created.
  • If you'd previously disconnected and the same (merchant_id, site_url) reappears, the old record reactivates with the new secret.
  • Old Bearer tokens become invalid - call /auth/token again to get a fresh one.

Tip: use the DELETE /merchant endpoint (Bearer auth) when the merchant intentionally severs the connection. That cleans up server-side state before they reconnect.

Testing your integration

  1. Set THRIVE_PAYPAL_API_BASE to https://tpa.stagingthrivethemes.com/api/paypal/v1.
  2. From your plugin admin, trigger the "Connect PayPal" action. Verify Steps 1-4 fire and the bearer is cached.
  3. Sign in to PayPal sandbox with a Business test account when prompted (PayPal Developer Dashboard → Sandbox → Accounts → reveal the system-generated password). Approve the partner permissions.
  4. Confirm the redirect lands at your site_url with permissionsGranted=true + consentStatus=true in the query string.
  5. Hit GET /merchant via your client and confirm the response shape matches the Product API's contract (capabilities list, payments_receivable).
  6. Try a non-auth call (e.g., omit the Authorization header) to verify your error-handling path renders a useful message.
  7. Try a deliberately wrong secret on Step 4 - expect 401. Confirm you re-prompt the admin to reconnect.
  8. Once Steps 1-4 are stable, integrate /orders + /orders/{id}/capture for one-time payments and /subscriptions/* for vault subscriptions.
Useful tools while integrating:
  • Admin test harness - drive the API by clicks without writing plugin code.
  • Interactive API reference - Try-It panels for every onboarding endpoint.
  • tail -f wp-content/debug.log - when THRIVE_PAYPAL_DEBUG=true, every request + response is logged.

Migrating from wp-payment-pal-paypal-service

Some Awesome Motive plugins were prototyped against wp-payment-pal-paypal-service (a.k.a. wpsp) - the WP Simple Pay PayPal middleware whose contract we ported. If your code is already calling those endpoints, the URLs below tell you what to change. The request bodies and response shapes are compatible unless noted; only the path and - in a few cases - the HTTP method differ.

Why we renamed: the wpsp paths are PayPal-implementation leakage (e.g. "oauth" describes how PayPal's identity flow happens; "customers" reflects how wpsp models a merchant; "/processor/" was a leftover namespace marker). The Thrive paths describe what the consumer is doing - starting onboarding, managing their merchant record, creating an order - which is what an integrator actually cares about.
Purpose wpsp (before) Thrive (now) Notes
Onboarding
Start onboarding POST /oauth/partner-referral POST /onboarding/start Same body. Same response.
Complete onboarding POST /oauth/credentials POST /onboarding/complete Same body. Response also includes sdk_client_token.
Auth tokens
Issue Bearer (Basic → Bearer) POST /oauth/access-token POST /auth/token Same Basic-auth header. Same response.
Refresh PayPal client token POST /oauth/client-token POST /auth/client-token Same response.
SDK client token (Apple Pay etc.) POST /oauth/sdk-client-token POST /auth/sdk-token Same response.
Merchant (was "customers")
Get capabilities + status GET /customers/merchant-info GET /merchant Same response.
Get client credentials GET /customers/credentials GET /merchant/credentials Same response.
Update license / webhook URL POST /customers/update PATCH /merchant Method changed (POST → PATCH). Same body.
Disconnect POST /customers/disconnect DELETE /merchant Method changed (POST → DELETE). No body.
Orders (resource id in URL)
Create order POST /orders/create POST /orders Same body. Same response.
Get order GET /orders/get?id=… GET /orders/{id} Id moved from query string to URL path.
Capture order POST /orders/capture
body: { id }
POST /orders/{id}/capture
no body
Id moved to URL path; { id } body dropped.
Refund capture POST /orders/refund
body: { id, amount?, currency? }
POST /captures/{id}/refund
body: { amount?, currency? }
Operates on capture_id, not order_id - routed under /captures to make that explicit. Id moved to URL.
Vault subscriptions ("/processor" dropped)
Create subscription POST /subscriptions/processor/create POST /subscriptions Same body. Same response.
Activate (capture first payment + vault) POST /subscriptions/processor/capture
body: { id }
POST /subscriptions/{id}/activate
no body
Renamed for what it actually does (activate, not "capture"). Id moved to URL.
Get subscription GET /subscriptions/processor/get?id=… GET /subscriptions/{id} Id moved from query string to URL path.
Cancel subscription POST /subscriptions/processor/cancel
body: { id }
POST /subscriptions/{id}/cancel
no body
Id moved to URL.
Webhooks
PayPal → Product API ingress POST /webhooks POST /webhooks Unchanged (PayPal already points here in both worlds).
Plugin-triggered test webhook POST /webhooks/test POST /webhooks/test Unchanged.

A minimal find-and-replace script

If you have a small plugin codebase, this sed sequence handles 90% of the mechanical changes. The method changes (PATCH on update, DELETE on disconnect) and the id-in-URL changes still need eyes on them - most HTTP clients require a separate code change for the verb.

sed -i '' \
  -e 's|/oauth/partner-referral|/onboarding/start|g' \
  -e 's|/oauth/credentials|/onboarding/complete|g' \
  -e 's|/oauth/access-token|/auth/token|g' \
  -e 's|/oauth/client-token|/auth/client-token|g' \
  -e 's|/oauth/sdk-client-token|/auth/sdk-token|g' \
  -e 's|/customers/merchant-info|/merchant|g' \
  -e 's|/customers/credentials|/merchant/credentials|g' \
  -e 's|/subscriptions/processor/create|/subscriptions|g' \
  -e 's|/orders/create|/orders|g' \
  $(grep -rl '/oauth/\|/customers/\|/subscriptions/processor\|/orders/create' src/)

After running the script, audit the remaining call sites:

  • customers/update - change method to PATCH, path to /merchant, keep body.
  • customers/disconnect - change method to DELETE, path to /merchant, drop body.
  • orders/capture + orders/refund + every subscriptions/processor/* mutation - move the id from the request body into the URL.
  • orders/get + subscriptions/processor/get - drop the ?id=… query string, put the id in the path.

Coming soon: separate guides for Orders, Vault subscriptions, Webhook handling, and the renewal cron lifecycle.