Guide · Payment Processing

MCP Tools for PayPal — OAuth2 M2M auth, Orders API v2 state machine, idempotency, webhook verification, and health probes

PayPal's REST API presents three challenges that catch MCP server authors: authentication uses a two-step OAuth2 client-credentials flow — the access token is obtained from a separate token endpoint before any API call, and tokens expire after 32,400 seconds, requiring a background refresh loop; order creation and capture are separate API calls separated by a user-approval step in the browser, so an MCP tool that creates an order cannot immediately capture payment — it must expose the approval URL and then wait or listen for the webhook; and webhook verification requires two separate checks — a CRC32 certificate URL validation followed by RSA256 signature verification over the raw request body — skipping either step leaves the endpoint open to forged webhooks. The sandbox and live environments use completely different base URLs, and using a live client_id against the sandbox endpoint returns a generic 401 with no environment hint.

TL;DR

Auth: POST https://api-m.paypal.com/v1/oauth2/token with Authorization: Basic base64(client_id:secret) and body grant_type=client_credentials. Cache the access_token (expires in expires_in seconds, typically 32400). All subsequent calls: Authorization: Bearer {access_token}. Create order: POST /v2/checkout/orders with PayPal-Request-Id: {uuid} for idempotency. Capture: POST /v2/checkout/orders/{id}/capture after buyer approval. Webhook verification: check CERT_URL starts with https://api.paypal.com (CRC32 anti-substitution), then verify PAYPAL-TRANSMISSION-SIG using RSA-SHA256 over {transmission_id}|{timestamp}|{webhook_id}|CRC32({body}). Sandbox base URL: api-m.sandbox.paypal.com; live: api-m.paypal.com.

Authentication — OAuth2 client credentials and token refresh

PayPal uses OAuth2 for machine-to-machine integrations. Unlike APIs that accept a static API key, PayPal requires every MCP server to exchange a client_id + secret for a short-lived Bearer token before making any API call. The token endpoint itself uses HTTP Basic authentication with the client credentials.

Token lifetime is expires_in seconds in the response (typically 32400 — nine hours). Production MCP servers should cache the token and refresh it proactively before expiry rather than refreshing on every request. A simple in-memory cache with a 5-minute safety margin before expires_in is sufficient.

// PayPal OAuth2 token manager
class PayPalTokenManager {
  #clientId;
  #secret;
  #baseUrl;
  #token = null;
  #expiresAt = 0;

  constructor({ clientId, secret, sandbox = false }) {
    this.#clientId = clientId;
    this.#secret = secret;
    this.#baseUrl = sandbox
      ? 'https://api-m.sandbox.paypal.com'
      : 'https://api-m.paypal.com';
  }

  async getToken() {
    // Refresh 5 minutes before expiry
    if (Date.now() < this.#expiresAt - 300_000) {
      return this.#token;
    }

    const credentials = Buffer.from(
      `${this.#clientId}:${this.#secret}`
    ).toString('base64');

    const res = await fetch(`${this.#baseUrl}/v1/oauth2/token`, {
      method: 'POST',
      headers: {
        'Authorization': `Basic ${credentials}`,
        'Content-Type': 'application/x-www-form-urlencoded',
      },
      body: 'grant_type=client_credentials',
    });

    if (!res.ok) {
      const err = await res.json().catch(() => ({}));
      throw new Error(`PayPal token error ${res.status}: ${err.error_description || res.statusText}`);
    }

    const data = await res.json();
    this.#token = data.access_token;
    // expires_in is in seconds; convert to ms and subtract safety margin
    this.#expiresAt = Date.now() + data.expires_in * 1000;
    return this.#token;
  }

  get baseUrl() { return this.#baseUrl; }
}

const paypal = new PayPalTokenManager({
  clientId: process.env.PAYPAL_CLIENT_ID,
  secret: process.env.PAYPAL_SECRET,
  sandbox: process.env.NODE_ENV !== 'production',
});

// Helper for authenticated requests
async function ppFetch(path, options = {}) {
  const token = await paypal.getToken();
  const res = await fetch(`${paypal.baseUrl}${path}`, {
    ...options,
    headers: {
      'Authorization': `Bearer ${token}`,
      'Content-Type': 'application/json',
      ...options.headers,
    },
  });
  if (!res.ok) {
    const body = await res.json().catch(() => ({}));
    throw new Error(`PayPal ${options.method || 'GET'} ${path} → ${res.status}: ${JSON.stringify(body)}`);
  }
  return res.status === 204 ? null : res.json();
}

The error_description field in error responses is the most human-readable signal — "invalid_client" means wrong credentials; "invalid_grant" means the access token has expired (rare if you manage refresh properly). A 401 with no body or a generic error usually means environment mismatch (live credentials against sandbox URL or vice versa).

Orders API v2 — state machine and two-step capture

PayPal orders move through a defined state machine: CREATED (order exists, not yet approved) → APPROVED (buyer completed the PayPal checkout flow and approved) → COMPLETED (merchant captured the payment) or VOIDED (order cancelled before capture). An MCP tool that creates an order must return the approval URL to the caller — the buyer must visit it in a browser before capture is possible. There is no server-side API to skip buyer approval for credit card orders; that flow is handled through the PayPal JS SDK on a frontend.

// Create a PayPal order
async function createOrder({ amount, currency = 'USD', description, returnUrl, cancelUrl }) {
  const idempotencyKey = crypto.randomUUID();

  const order = await ppFetch('/v2/checkout/orders', {
    method: 'POST',
    headers: {
      // PayPal-Request-Id makes this call idempotent — same UUID returns
      // the original order for up to 24 hours instead of creating a duplicate
      'PayPal-Request-Id': idempotencyKey,
    },
    body: JSON.stringify({
      intent: 'CAPTURE',
      purchase_units: [{
        amount: {
          currency_code: currency,
          value: amount, // string, e.g. "19.99"
        },
        description,
      }],
      application_context: {
        return_url: returnUrl,   // buyer redirected here after approval
        cancel_url: cancelUrl,   // buyer redirected here on cancellation
        brand_name: 'Your App',
        user_action: 'PAY_NOW', // shows "Pay Now" instead of "Continue"
      },
    }),
  });

  // Find the approval URL (rel: "approve") in HATEOAS links
  const approvalLink = order.links.find(l => l.rel === 'approve');

  return {
    orderId: order.id,
    status: order.status, // 'CREATED'
    approvalUrl: approvalLink?.href,
    idempotencyKey, // store this to safely retry creation
  };
}

// Capture an approved order (call after buyer completes approval)
async function captureOrder(orderId) {
  // POST with empty body to trigger capture
  const capture = await ppFetch(`/v2/checkout/orders/${orderId}/capture`, {
    method: 'POST',
    body: '{}',
  });

  const captureUnit = capture.purchase_units?.[0]?.payments?.captures?.[0];
  return {
    orderId: capture.id,
    status: capture.status, // 'COMPLETED'
    captureId: captureUnit?.id,
    captureStatus: captureUnit?.status, // 'COMPLETED' | 'PENDING' | 'DECLINED'
    amount: captureUnit?.amount,
    finalCapture: captureUnit?.final_capture,
    sellerProtection: captureUnit?.seller_protection?.status,
  };
}

// Get order details (for polling before capture)
async function getOrder(orderId) {
  return ppFetch(`/v2/checkout/orders/${orderId}`);
  // Returns { id, status: 'CREATED'|'SAVED'|'APPROVED'|'VOIDED'|'COMPLETED', ... }
}

The purchase_units[0].payments.captures[0].status field — not order.status — is the authoritative signal for whether money moved. An order can be COMPLETED at the order level while the capture is PENDING (common with ACH or certain international cards). Always check both.

Idempotency with PayPal-Request-Id

PayPal honors idempotency for order creation and refund endpoints via the PayPal-Request-Id header. Submitting the same UUID within 24 hours returns the original response instead of creating a duplicate order. After 24 hours, the same UUID creates a new order. Store the key alongside the order in your database so retries always use the same key.

Webhook verification — CRC32 certificate check and RSA-SHA256 signature

PayPal webhook events are signed using RSA-SHA256. Verification requires two steps that many implementations skip: (1) validate that the certificate URL in the PAYPAL-CERT-URL header points to a PayPal-owned domain before fetching it — an attacker could substitute their own certificate; (2) verify the RSA-SHA256 signature over a specific concatenated string using the fetched public key certificate.

import { createVerify } from 'node:crypto';

// Cache fetched PayPal certificates to avoid repeated HTTP fetches
const certCache = new Map();

async function fetchPayPalCert(certUrl) {
  // CRITICAL: reject certificate URLs not on paypal.com domains
  const url = new URL(certUrl);
  if (!url.hostname.endsWith('.paypal.com')) {
    throw new Error(`Rejected PayPal cert URL with unexpected host: ${url.hostname}`);
  }

  if (certCache.has(certUrl)) return certCache.get(certUrl);

  const res = await fetch(certUrl);
  if (!res.ok) throw new Error(`Failed to fetch PayPal cert: ${res.status}`);
  const cert = await res.text();
  certCache.set(certUrl, cert);
  return cert;
}

// CRC32 implementation (PayPal uses it over the raw body)
function crc32(buf) {
  const table = new Uint32Array(256);
  for (let i = 0; i < 256; i++) {
    let c = i;
    for (let k = 0; k < 8; k++) c = c & 1 ? 0xEDB88320 ^ (c >>> 1) : c >>> 1;
    table[i] = c;
  }
  let crc = 0xFFFFFFFF;
  const bytes = Buffer.from(buf);
  for (const byte of bytes) crc = table[(crc ^ byte) & 0xFF] ^ (crc >>> 8);
  return (crc ^ 0xFFFFFFFF) >>> 0;
}

async function verifyPayPalWebhook(headers, rawBody, webhookId) {
  const transmissionId = headers['paypal-transmission-id'];
  const timestamp = headers['paypal-transmission-time'];
  const transmissionSig = headers['paypal-transmission-sig'];
  const certUrl = headers['paypal-cert-url'];

  if (!transmissionId || !timestamp || !transmissionSig || !certUrl) {
    return false;
  }

  const cert = await fetchPayPalCert(certUrl);

  // PayPal signature is over: transmissionId|timestamp|webhookId|CRC32(body)
  const bodyCrc32 = crc32(rawBody);
  const message = `${transmissionId}|${timestamp}|${webhookId}|${bodyCrc32}`;

  const verify = createVerify('SHA256');
  verify.update(message);
  return verify.verify(cert, transmissionSig, 'base64');
}

// Express handler example
app.post('/webhooks/paypal', express.raw({ type: 'application/json' }), async (req, res) => {
  const isValid = await verifyPayPalWebhook(
    req.headers,
    req.body, // raw Buffer — do NOT parse before verification
    process.env.PAYPAL_WEBHOOK_ID
  );

  if (!isValid) {
    return res.status(401).json({ error: 'Invalid webhook signature' });
  }

  const event = JSON.parse(req.body);
  await handlePayPalEvent(event);
  res.sendStatus(200);
});

The PAYPAL_WEBHOOK_ID is the ID of the specific webhook endpoint you registered in the PayPal developer dashboard — it is different for sandbox and live, and different from your client_id. Using the wrong webhook ID causes signature verification to fail even when all other headers are correct.

Key PayPal webhook event types

Refunds and partial captures

Refunds are issued against a specific capture ID (not the order ID). PayPal supports partial refunds by specifying an amount; omitting the amount issues a full refund of the captured amount.

// Refund a specific capture
async function refundCapture(captureId, { amount, currency, reason } = {}) {
  const body = {};
  if (amount) {
    body.amount = { value: amount, currency_code: currency || 'USD' };
  }
  if (reason) {
    body.note_to_payer = reason.slice(0, 255); // max 255 chars
  }

  const refund = await ppFetch(`/v2/payments/captures/${captureId}/refund`, {
    method: 'POST',
    headers: {
      'PayPal-Request-Id': crypto.randomUUID(), // idempotency for the refund itself
    },
    body: JSON.stringify(body),
  });

  return {
    refundId: refund.id,
    status: refund.status, // 'COMPLETED' | 'PENDING' | 'CANCELLED'
    amount: refund.amount,
    createTime: refund.create_time,
  };
}

Refund status PENDING is normal for ACH and certain international payment methods — the refund has been accepted but not yet settled to the buyer's account. Subscribe to PAYMENT.CAPTURE.REFUNDED webhooks rather than polling refund status.

Sandbox vs live environment

PayPal maintains completely separate environments with different base URLs, different credential sets, and different webhook IDs. There is no sandbox flag on a shared API endpoint — the environment is determined entirely by the base URL you connect to and the credentials you use.

ResourceSandboxLive
Base URLapi-m.sandbox.paypal.comapi-m.paypal.com
Token endpoint/v1/oauth2/token (same path)/v1/oauth2/token (same path)
Webhook cert URL domain*.paypal.com*.paypal.com
Webhook IDSeparate ID for sandbox endpointSeparate ID for live endpoint
Test cardsSpecific sandbox card numbers from developer dashboardReal cards only

Common mistake: creating a webhook endpoint in the PayPal developer dashboard creates it with a specific environment. If you test with the sandbox webhook ID but verify against the live webhook ID, signatures always fail — the signing key is different per environment.

Health probe design for PayPal MCP servers

PayPal does not have a dedicated health or ping endpoint. The safest probe is a token acquisition check — it validates credentials without creating any resources or charges. A token fetch that succeeds confirms the client_id + secret pair is valid and the PayPal API is reachable.

// Health probe: token acquisition only (no transactions created)
async function checkPayPalHealth(tokenManager) {
  const start = Date.now();
  try {
    // Force a fresh token to avoid returning a stale cached token
    const freshManager = new PayPalTokenManager({
      clientId: tokenManager.clientId,
      secret: tokenManager.secret,
      sandbox: tokenManager.sandbox,
    });
    await freshManager.getToken();
    return { status: 'healthy', latencyMs: Date.now() - start };
  } catch (err) {
    return {
      status: 'unhealthy',
      error: err.message,
      latencyMs: Date.now() - start,
    };
  }
}

// For a more thorough probe: list webhooks (read-only, non-destructive)
async function checkPayPalApiAccess() {
  try {
    const webhooks = await ppFetch('/v1/notifications/webhooks');
    return {
      status: 'healthy',
      webhookCount: webhooks.webhooks?.length ?? 0,
    };
  } catch (err) {
    return { status: 'unhealthy', error: err.message };
  }
}

For AliveMCP monitoring: expose a /health/paypal route that performs the token acquisition check. If the token endpoint returns anything other than 200 with a valid access_token, flag the MCP server as degraded. A 401 from the token endpoint is a credential failure (not a PayPal outage) and should be surfaced as a configuration error rather than a service degradation.

See MCP server health check patterns for a discussion of how to distinguish credential failures from infrastructure outages in PayPal monitoring, and webhook delivery reliability for handling PayPal's retry behavior (3 attempts over 3 days).

Error handling and rate limits

PayPal REST API errors follow a consistent schema: { name, message, details[], debug_id, links[] }. The debug_id field is essential to include in any support request — it correlates the error to PayPal's internal logs. The details array contains field-level validation errors (e.g., { field: "purchase_units[0].amount.value", issue: "INVALID_NUMBER_OF_DECIMAL_DIGITS" }).

// Parse PayPal errors into actionable messages
function parsePayPalError(statusCode, body) {
  const name = body.name || 'UNKNOWN_ERROR';
  const fieldErrors = (body.details || []).map(d =>
    `${d.field || ''}: ${d.issue || d.description || ''}`
  ).filter(Boolean);

  return {
    code: name,
    message: body.message || '',
    debugId: body.debug_id, // include in logs — useful for PayPal support
    fields: fieldErrors,
    isRetryable: statusCode === 429 || statusCode >= 500,
    // 422 UNPROCESSABLE_ENTITY means bad input — do not retry
    // 409 RESOURCE_CONFLICT means idempotency key reused with different params
  };
}

// Rate limit: PayPal uses HTTP 429 with Retry-After header (seconds)
// Default quota varies by API family — Orders API: 5000 req/hr per credential pair
async function ppFetchWithRetry(path, options = {}, maxRetries = 3) {
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    const res = await fetch(`${paypal.baseUrl}${path}`, options);

    if (res.status === 429) {
      const retryAfter = parseInt(res.headers.get('Retry-After') || '60', 10);
      if (attempt < maxRetries) {
        await new Promise(r => setTimeout(r, retryAfter * 1000));
        continue;
      }
    }

    return res;
  }
}

PayPal does not use exponential backoff headers — the Retry-After value is a fixed number of seconds to wait. For MCP tools that may process many orders in batch, implement a token bucket that stays below the relevant quota to avoid hitting rate limits at all.