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.
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
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.
/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"}'
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 );
} );
/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.
/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.
/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
secretbut a differentmerchant_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/tokenagain 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
- Set
THRIVE_PAYPAL_API_BASEtohttps://tpa.stagingthrivethemes.com/api/paypal/v1. - From your plugin admin, trigger the "Connect PayPal" action. Verify Steps 1-4 fire and the bearer is cached.
- 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.
- Confirm the redirect lands at your
site_urlwithpermissionsGranted=true+consentStatus=truein the query string. - Hit
GET /merchantvia your client and confirm the response shape matches the Product API's contract (capabilities list,payments_receivable). - Try a non-auth call (e.g., omit the
Authorizationheader) to verify your error-handling path renders a useful message. - Try a deliberately wrong
secreton Step 4 - expect 401. Confirm you re-prompt the admin to reconnect. - Once Steps 1-4 are stable, integrate
/orders+/orders/{id}/capturefor one-time payments and/subscriptions/*for vault subscriptions.
- 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- whenTHRIVE_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.
/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+ everysubscriptions/processor/*mutation - move theidfrom the request body into the URL.orders/get+subscriptions/processor/get- drop the?id=…query string, put the id in the path.