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 );
//
// Response (real PayPal sandbox):
//   { "url": "https://www.sandbox.paypal.com/bizsignup/partner/entry?referralToken=ODc...",
//     "expires_in": 1782985623 }

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"}'
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

{
  "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
}

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.

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"

The endpoint is idempotent: if a valid token already exists, you get the same one back with remaining TTL. On any subsequent 401, refresh by calling this again.

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 );

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 mode - Mock just returns canned data, Live hits PayPal.

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.

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.