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 );
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.
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
{
"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.
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.
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.
/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.
/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 } │ │
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 );
}
/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
| Field | Type | Notes |
|---|---|---|
| data.intent | string | Always "CAPTURE" for one-off purchases (use AUTHORIZE only if you need to capture later). |
| data.purchase_units[0].amount.currency_code | string | 3-letter ISO. The Product API forwards your value unchanged. |
| data.purchase_units[0].amount.value | string | Always send as a string ("49.00"), not a float. PayPal returns money values as strings; mirror that. |
Recommended fields
| Field | Why |
|---|---|
| purchase_units[0].description | Shows up on PayPal checkout + the buyer's receipt + your sandbox account ledger. Helpful for debugging. |
| purchase_units[0].custom_id | Up 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_url | Where 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_url | Where 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" }
]
}
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.
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.
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.
/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. */ ]
}
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.
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 );
}
/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.
{ 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;
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)
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.COMPLETEDwebhook 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.
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:
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_urlwith?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,
] );
}
/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)
| Field | Type | Notes |
|---|---|---|
| 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" }
]
}
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.
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:
- 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
/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": { ... }
}
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:
- Cron finds subscriptions where
renewal_at <= NOW()andis_active = true - For each, creates a fresh PayPal Order with
payment_source.{source}.vault_id+stored_credentialblock (MERCHANT / RECURRING / SUBSEQUENT) - PayPal auto-captures the renewal - the order comes back as
COMPLETEDimmediately, no separate capture call needed - Cron decrements
cycles_left(if finite) and advancesrenewal_atby the interval - PayPal fires
CHECKOUT.ORDER.COMPLETED+PAYMENT.CAPTURE.COMPLETEDto the Product API's webhook URL - Product API verifies PayPal's signature, then forwards the body to your plugin's
webhooks_urlsigned with yourwebhook_secret(HMAC-SHA256). The forwarded payload also carries anx_thrive_order_idannotation set to the subscription'sthrive_order_idso you can correlate the renewal back to your local row without parsingcustom_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;
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).
/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:
- Deactivates the local
paypal_subscriptionsrow (is_active = false) - Calls PayPal's
DELETE /v3/vault/payment-tokens/{vault_id}to remove the stored payment method - 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
}
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.
/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)
}
/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 }
/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 }
/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.
/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": [ ... ]
}
/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
}
/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
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.