Payment Processing · 2026-07-17 · Payment arc
MCP Tools for Payment Processing: Auth Credential Shapes, Webhook Verification Protocols, and Test/Live Environment Models
Five payment APIs — PayPal, Square, Adyen, Lemon Squeezy, and Braintree — span a wider range of authentication complexity than almost any other API category, and they all use HMAC for webhook signing while sharing almost nothing else about how that signing works. PayPal requires OAuth2 client-credentials before the first API call — the access token endpoint uses HTTP Basic auth to issue a Bearer token that expires in 32,400 seconds and must be refreshed in the background; all subsequent calls use the Bearer token. Square uses a static access token in the standard Authorization: Bearer header but requires a mandatory Square-Version date header and — critically — every resource operation requires a location_id that must be fetched separately from GET /v2/locations; calls that omit it return 422 rather than defaulting. Adyen uses a proprietary X-API-Key header with no Authorization prefix — the most common Adyen misconfiguration is sending Authorization: Bearer {key} instead — and live environments use a merchant-specific URL prefix obtained from the Customer Area rather than a shared domain. Lemon Squeezy has the simplest credential shape: a static Bearer key that never expires, a single API URL for both test and live, and a test_mode attribute on resources that distinguishes test from live transactions. Braintree abstracts all authentication into a three-param SDK init — merchantId, publicKey, privateKey — where the SDK handles Basic auth encoding internally, and attempting to use the raw REST API without the SDK is explicitly unsupported. Across all five, webhook verification requires HMAC-SHA256, but the input to that HMAC, the encoding of the signing key, the encoding of the output, the header name carrying the signature, the body format, and the acknowledgment protocol are all different — and every deviation is a silent verification failure, not a helpful error message. This post covers all three patterns with working code for each platform.
TL;DR
Five payment APIs, three patterns. (1) Auth credential shape spectrum: PayPal — OAuth2 two-step: POST /v1/oauth2/token with Authorization: Basic base64(client_id:secret), cache access_token for up to expires_in seconds (typically 32400), then Authorization: Bearer {token} on all calls; Square — static Authorization: Bearer {accessToken} + mandatory Square-Version: 2024-12-18 header + location_id resolved from GET /v2/locations before any resource call; Adyen — proprietary X-API-Key: {apiKey} header (NOT Bearer), plus live endpoint prefix {UUID}-{merchantAccount}-checkout-live.adyenpayments.com or silently uses test environment; Lemon Squeezy — static Authorization: Bearer {apiKey}, no expiry, single URL api.lemonsqueezy.com for both test and live; Braintree — SDK init with { merchantId, publicKey, privateKey, environment }, never construct raw HTTP requests. (2) Webhook HMAC verification: PayPal — RSA-SHA256 (not HMAC) over {transmissionId}|{timestamp}|{webhookId}|CRC32(body) using a certificate fetched from a PayPal-domain URL (validate domain first); Square — HMAC-SHA256 over notificationUrl + rawBody, key is webhook signature key (not access token), base64 output in X-Square-Hmacsha256-Signature; Adyen — HMAC-SHA256 over fixed-order field join (pspRef:origRef:merchantAccount:merchantRef:value:currency:eventCode:success) with hex-decoded key, base64 output in hmacSignature inside notification body, batched delivery requires {"notificationResponse": "[accepted]"}; Lemon Squeezy — HMAC-SHA256 over raw body only, key is per-endpoint signing secret, hex output (not base64) in X-Signature, event type in X-Event-Name header (dispatch before parsing body); Braintree — SDK gateway.webhookNotification.verify(btSignature, btPayload) handles all verification, body is form-encoded (not JSON), parse with express.urlencoded(). (3) Test/live environment models: PayPal — completely separate base URLs (api-m.sandbox.paypal.com vs api-m.paypal.com) and separate credential sets AND webhook IDs; Square — separate base URLs (connect.squareupsandbox.com vs connect.squareup.com), test nonces like cnon:card-nonce-ok; Adyen — test URLs vs live-endpoint-prefix URLs, missing prefix silently routes to test with no error; Lemon Squeezy — SAME URL, test_mode: boolean attribute on resources, no environment switch; Braintree — constructor environment enum Environment.Sandbox vs Environment.Production.
Pattern 1: The Auth Credential Shape Spectrum
Payment API authentication spans every point on the credential complexity spectrum. The five platforms in this arc are not variations on the same approach — each represents a distinct authentication philosophy, and the MCP server initialization code looks fundamentally different for each.
PayPal: OAuth2 Two-Step With Token Refresh
PayPal requires machine-to-machine OAuth2 client credentials before any API call. Unlike APIs that accept a static key, PayPal forces an explicit token acquisition step: POST /v1/oauth2/token with Authorization: Basic base64(client_id:secret) returns an access_token valid for expires_in seconds (typically 32,400 — nine hours). Every subsequent API call uses Authorization: Bearer {access_token}.
The production implication: MCP servers must cache the token and refresh it before expiry. A naive implementation that re-fetches the token on every call adds latency and hits rate limits on the token endpoint. A naive implementation that doesn't refresh causes 401 failures after nine hours of uptime.
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() {
if (Date.now() < this.#expiresAt - 300_000) return this.#token; // 5-min safety margin
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 ${res.status}: ${err.error_description}`);
}
const data = await res.json();
this.#token = data.access_token;
this.#expiresAt = Date.now() + data.expires_in * 1000;
return this.#token;
}
get baseUrl() { return this.#baseUrl; }
}
The health probe for PayPal is a fresh token acquisition — it validates credentials without creating any chargeable resources. A 401 from the token endpoint signals a credential failure (not a PayPal outage) and should surface as a configuration error rather than service degradation.
Square: Static Bearer Token With Mandatory API Version and Location Scoping
Square uses a static Bearer access token — no expiry, no refresh loop. But two constraints make Square MCP integration non-trivial: the Square-Version date header must be pinned on every request (current stable: 2024-12-18), and every resource operation requires a location_id that cannot be assumed or defaulted — it must be fetched from GET /v2/locations at startup.
Omitting location_id on a payment create returns HTTP 422 with EXPECTED_LOCATION_ID. Providing a valid-format ID that belongs to a different merchant returns HTTP 404 with LOCATION_MISMATCH. Both errors look similar, but the remediation is different: the first is a missing field bug; the second is an environment mismatch (test credentials against production location ID or vice versa).
class SquareClient {
#accessToken; #baseUrl;
constructor({ accessToken, sandbox = false }) {
this.#accessToken = accessToken;
this.#baseUrl = sandbox
? 'https://connect.squareupsandbox.com'
: 'https://connect.squareup.com';
}
async request(method, path, body = null) {
const res = await fetch(`${this.#baseUrl}${path}`, {
method,
headers: {
'Authorization': `Bearer ${this.#accessToken}`,
'Square-Version': '2024-12-18',
'Content-Type': 'application/json',
},
body: body ? JSON.stringify(body) : undefined,
});
const data = await res.json();
if (!res.ok) {
const err = data.errors?.[0];
throw new Error(`Square ${method} ${path} → ${res.status} ${err?.code}: ${err?.detail}`);
}
return data;
}
}
// MCP server init: resolve location_id before accepting any tool calls
async function initSquare(client) {
const { locations } = await client.request('GET', '/v2/locations');
const active = (locations || []).filter(l => l.status === 'ACTIVE');
if (!active.length) throw new Error('No active Square locations — check merchant account');
return active[0].id; // cache this; use on every payment/order call
}
The health probe for Square is GET /v2/locations — it validates credentials AND returns the location IDs required for all subsequent operations. Two-for-one: a 200 response with at least one active location means the MCP server is operational. A 401 means invalid or expired token. A 403 means the token lacks MERCHANT_PROFILE_READ permission and requires re-authorization.
Adyen: Proprietary X-API-Key With Merchant-Specific Live URLs
Adyen's authentication is a single header — X-API-Key: {apiKey} — but that header name is the most commonly misconfigured aspect of Adyen integration. Every code generator that defaults to Authorization: Bearer produces a 401. The error message for this is identical to a wrong key value, so developers often rotate keys fruitlessly before noticing the header name.
The second constraint is the live endpoint prefix. Adyen's test environment is reachable at checkout-test.adyen.com. The live environment uses a merchant-specific prefix from Customer Area → Developers → API URLs, of the form {UUID}-{merchantAccount}-checkout-live.adyenpayments.com. If ADYEN_LIVE_ENDPOINT_PREFIX is not set in production, the MCP server silently routes all payment requests to the test environment — no error, no warning, no money movement.
class AdyenClient {
#apiKey; #merchantAccount; #checkoutBaseUrl;
constructor({ apiKey, merchantAccount, liveEndpointPrefix = null }) {
this.#apiKey = apiKey;
this.#merchantAccount = merchantAccount;
this.#checkoutBaseUrl = liveEndpointPrefix
? `https://${liveEndpointPrefix}-checkout-live.adyenpayments.com/checkout/v71`
: 'https://checkout-test.adyen.com/v71';
}
async checkout(path, body) {
const res = await fetch(`${this.#checkoutBaseUrl}${path}`, {
method: 'POST',
headers: {
'X-API-Key': this.#apiKey, // NOT Authorization: Bearer
'Content-Type': 'application/json',
},
body: JSON.stringify({ merchantAccount: this.#merchantAccount, ...body }),
});
const data = await res.json();
if (!res.ok) throw new Error(`Adyen ${path} → ${res.status} [${data.errorCode}]: ${data.message}`);
return data;
}
}
// Startup validation: catch missing live prefix in production immediately
function validateAdyenConfig({ liveEndpointPrefix, nodeEnv }) {
if (nodeEnv === 'production' && !liveEndpointPrefix) {
throw new Error(
'ADYEN_LIVE_ENDPOINT_PREFIX required in production. ' +
'Without it, payments route to test environment with no error.'
);
}
}
Adyen's health probe is POST /paymentMethods with a minimal body — it validates the API key and returns available payment methods without creating a session. A 200 with an empty paymentMethods[] means credentials are valid but no payment methods are enabled in Customer Area — a configuration error distinct from an API error.
Lemon Squeezy: Static Bearer With Unified URL and test_mode Flag
Lemon Squeezy has the simplest credential shape in this group: a static API key from the dashboard, no expiry, Authorization: Bearer {key} on every call, and a single API URL — api.lemonsqueezy.com — for both test and live. The absence of a separate test URL is itself the main design pitfall: test and live resources coexist on the same endpoint, distinguished only by the test_mode: boolean attribute on each resource.
This means filtering live vs test resources is application-level work, not environment-level configuration. List endpoints return a mix of both. An MCP tool that reads orders without filtering by test_mode may count test-mode orders alongside real revenue. The API does not support ?filter[test_mode]=false on all endpoints — mode filtering must happen client-side after fetching.
class LemonSqueezyClient {
#apiKey;
#baseUrl = 'https://api.lemonsqueezy.com/v1';
constructor(apiKey) { this.#apiKey = apiKey; }
async request(method, path, { body, params } = {}) {
const url = new URL(`${this.#baseUrl}${path}`);
if (params) for (const [k, v] of Object.entries(params)) url.searchParams.set(k, String(v));
const res = await fetch(url.toString(), {
method,
headers: {
'Authorization': `Bearer ${this.#apiKey}`,
'Accept': 'application/vnd.api+json',
'Content-Type': 'application/vnd.api+json',
},
body: body ? JSON.stringify(body) : undefined,
});
const data = await res.json();
if (!res.ok) {
const err = data.errors?.[0];
throw new Error(`LemonSqueezy ${method} ${path} → ${res.status}: ${err?.detail}`);
}
return data;
}
}
// Health probe: GET /users/me (no side effects, validates key immediately)
async function checkLemonSqueezyHealth(client) {
const data = await client.request('GET', '/users/me');
return { userId: data.data?.id, email: data.data?.attributes?.email };
}
// Filter live vs test after fetch (API doesn't support filter[test_mode] uniformly)
function separateByMode(items) {
return {
live: items.filter(i => !i.attributes.test_mode),
test: items.filter(i => i.attributes.test_mode),
};
}
Braintree: Three-Param SDK Init With Hidden Basic Auth
Braintree is the only platform in this group that explicitly discourages direct HTTP access. The REST API is internal — not versioned, not stable, not documented for external use. All Braintree integration goes through the official SDK: npm install braintree for Node.js, then initialize a gateway object with merchantId, publicKey, and privateKey. The SDK encodes these as Authorization: Basic base64(publicKey:privateKey) with merchantId in the URL path — you never see the HTTP request.
The gateway object is long-lived with no token refresh — credentials don't expire in the OAuth2 sense. The environment switch is a constructor parameter: braintree.Environment.Sandbox vs braintree.Environment.Production. This is the most developer-friendly auth model of the five, but it has one sharp edge: the payment nonce model means server-side MCP tools can only charge payment methods that a browser or mobile client has already tokenized. There is no server-side path to initiate a card charge without prior client-side tokenization — this is intentional for PCI compliance.
import braintree from 'braintree';
const gateway = new braintree.BraintreeGateway({
environment: process.env.NODE_ENV === 'production'
? braintree.Environment.Production
: braintree.Environment.Sandbox,
merchantId: process.env.BRAINTREE_MERCHANT_ID,
publicKey: process.env.BRAINTREE_PUBLIC_KEY,
privateKey: process.env.BRAINTREE_PRIVATE_KEY,
});
// Health probe: client token generation validates all three credentials
// Creates no durable resources — safe to call every 60s
async function checkBraintreeHealth(gateway) {
const result = await gateway.clientToken.generate({});
if (!result.success) throw new Error(`Braintree health probe: ${result.message}`);
return { status: 'healthy' };
}
// MCP tool for charging a vault-stored payment method (no nonce required)
async function chargeStoredMethod(paymentMethodToken, amountUsd, orderId) {
const result = await gateway.transaction.sale({
amount: amountUsd, // STRING: "19.99" not 19.99
paymentMethodToken, // vault token from prior customer-facing session
orderId,
options: { submitForSettlement: true },
});
if (!result.success) {
return {
success: false,
status: result.transaction?.status, // 'processor_declined' | 'gateway_rejected'
processorResponseCode: result.transaction?.processorResponseCode,
message: result.message,
};
}
return { success: true, transactionId: result.transaction.id, status: result.transaction.status };
}
Auth Credential Shape Comparison
| Platform | Auth mechanism | Header | Credential format | Expiry / refresh |
|---|---|---|---|---|
| PayPal | OAuth2 client-credentials | Authorization: Bearer {token} |
client_id + secret → short-lived token | 32,400s — proactive background refresh |
| Square | OAuth2 access token (static for own-account) | Authorization: Bearer {token} + Square-Version: date |
Dashboard token; OAuth2 for multi-merchant | 30 days (OAuth2); no expiry (developer token) |
| Adyen | Proprietary API key | X-API-Key: {key} |
Customer Area web service user key | No automatic expiry; manual rotation |
| Lemon Squeezy | Static Bearer key | Authorization: Bearer {key} |
Dashboard API key | No expiry |
| Braintree | SDK-abstracted HTTP Basic | SDK-managed (hidden) | merchantId + publicKey + privateKey | No expiry |
Pattern 2: Webhook HMAC Verification — Five Protocols, Zero Shared Conventions
All five payment platforms sign webhook notifications using HMAC-SHA256, but the similarities end there. The HMAC input, the signing key encoding, the output encoding, the header name, the body content-type, and the acknowledgment protocol are all different. Using the wrong input construction produces a wrong HMAC — not an error, just silent verification failure. Using the wrong key encoding (UTF-8 instead of hex) produces a wrong HMAC. Using the wrong output encoding (base64 instead of hex) produces a wrong HMAC. Every deviation is a false "invalid signature" result with no indication of which step went wrong.
PayPal is the exception: it does not use HMAC at all. It uses RSA-SHA256 with a certificate fetched from a PayPal-owned URL. That distinction matters because RSA-SHA256 is asymmetric — the signing key is PayPal's private key, and you verify using a public certificate. HMAC is symmetric — both sides use the same secret. The implementation shapes are completely different.
PayPal: Two-Step Certificate Validation + RSA-SHA256
PayPal webhook verification requires two sequential steps: (1) validate that the PAYPAL-CERT-URL header points to a PayPal-owned domain before fetching it (an attacker could substitute their own certificate); (2) verify an RSA-SHA256 signature over the concatenated string {transmissionId}|{timestamp}|{webhookId}|CRC32(rawBody) using the fetched public key certificate. Skipping the domain check defeats the security model entirely — a crafted request with an attacker-controlled cert URL would verify successfully against any arbitrary payload.
import { createVerify } from 'node:crypto';
const certCache = new Map(); // cache fetched PayPal certs
async function fetchPayPalCert(certUrl) {
const url = new URL(certUrl);
// Step 1: reject cert URLs not on paypal.com — prevents certificate substitution attack
if (!url.hostname.endsWith('.paypal.com')) {
throw new Error(`Rejected PayPal cert URL: unexpected host ${url.hostname}`);
}
if (certCache.has(certUrl)) return certCache.get(certUrl);
const res = await fetch(certUrl);
if (!res.ok) throw new Error(`PayPal cert fetch ${res.status}`);
const cert = await res.text();
certCache.set(certUrl, cert);
return cert;
}
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;
for (const byte of Buffer.from(buf)) crc = table[(crc ^ byte) & 0xFF] ^ (crc >>> 8);
return (crc ^ 0xFFFFFFFF) >>> 0;
}
async function verifyPayPalWebhook(headers, rawBody, webhookId) {
const cert = await fetchPayPalCert(headers['paypal-cert-url']);
// Step 2: RSA-SHA256 over {transmissionId}|{timestamp}|{webhookId}|CRC32(body)
const message = `${headers['paypal-transmission-id']}|${headers['paypal-transmission-time']}|${webhookId}|${crc32(rawBody)}`;
const verify = createVerify('SHA256');
verify.update(message);
return verify.verify(cert, headers['paypal-transmission-sig'], 'base64');
}
The webhookId parameter is the ID of the specific webhook endpoint registered in the PayPal Developer Dashboard — different for sandbox vs live, and different from your client_id. Using the wrong webhook ID causes RSA verification to fail even when the certificate fetch and domain check succeed, because webhookId is part of the signed message.
Square: HMAC-SHA256 Over notificationUrl + rawBody
Square's HMAC input is the concatenation of the full notification URL (the URL Square is sending webhooks to) and the raw request body — no separator, no encoding, just string concatenation. The signing key is the webhook signature key from the subscription configuration in the Developer Dashboard, which is distinct from your access token and your application secret. The signature is base64-encoded and placed in the X-Square-Hmacsha256-Signature header.
The notification URL must be exact — including protocol, host, path, and any trailing slash or query parameters that Square knows about. A URL registered as https://yourserver.com/webhooks/square but verified against https://yourserver.com/webhooks/square/ (with a trailing slash) causes all signatures to fail silently.
import { createHmac, timingSafeEqual } from 'node:crypto';
function verifySquareWebhook(req, rawBody, webhookSignatureKey, notificationUrl) {
const receivedSig = req.headers['x-square-hmacsha256-signature'];
if (!receivedSig) return false;
// HMAC input: notificationUrl + rawBody — no separator
const payload = notificationUrl + rawBody.toString('utf8');
const expectedSig = createHmac('sha256', webhookSignatureKey).update(payload).digest('base64');
const received = Buffer.from(receivedSig, 'base64');
const expected = Buffer.from(expectedSig, 'base64');
if (received.length !== expected.length) return false;
return timingSafeEqual(received, expected);
}
app.post('/webhooks/square', express.raw({ type: 'application/json' }), async (req, res) => {
const isValid = verifySquareWebhook(
req,
req.body,
process.env.SQUARE_WEBHOOK_SIGNATURE_KEY,
process.env.SQUARE_WEBHOOK_URL // exact URL Square sends to
);
if (!isValid) return res.status(403).json({ error: 'Invalid signature' });
await handleSquareEvent(JSON.parse(req.body));
res.json({ success: true });
});
Adyen: HMAC-SHA256 With Hex-Decoded Key Over Fixed-Order Field Join
Adyen's webhook HMAC has two critical implementation details that break most first attempts. First, the HMAC signing key stored in Customer Area is a hex string and must be decoded from hex to raw bytes — Buffer.from(hmacKeyHex, 'hex'). Using it as UTF-8 or base64 produces a completely different key and causes all verifications to fail. Second, the HMAC input is not the raw body — it is a specific concatenation of selected fields in a fixed order: pspReference:originalReference:merchantAccountCode:merchantReference:amount.value:amount.currency:eventCode:success.
A third complication: Adyen sends webhooks as batched arrays. One HTTP POST from Adyen can contain multiple notificationItems. Your handler must iterate the array and verify each item individually. Returning HTTP 200 with {"notificationResponse": "[accepted]"} (including the brackets — that string is literal) acknowledges the entire batch. Any other response causes Adyen to retry the entire batch for up to 3 days.
import { createHmac, timingSafeEqual } from 'node:crypto';
function buildAdyenDataToSign(notification) {
const n = notification.NotificationRequestItem || notification;
return [
n.pspReference || '',
n.originalReference || '',
n.merchantAccountCode || '',
n.merchantReference || '',
String(n.amount?.value || ''),
n.amount?.currency || '',
n.eventCode || '',
n.success || '',
].join(':');
}
function verifyAdyenWebhook(notification, hmacKeyHex) {
const receivedHmac = notification.NotificationRequestItem?.additionalData?.hmacSignature;
if (!receivedHmac) return false;
// CRITICAL: hex-decode the key — Buffer.from(key, 'utf8') produces wrong key
const hmacKey = Buffer.from(hmacKeyHex, 'hex');
const expected = createHmac('sha256', hmacKey)
.update(buildAdyenDataToSign(notification), 'utf8')
.digest('base64');
const receivedBuf = Buffer.from(receivedHmac, 'base64');
const expectedBuf = Buffer.from(expected, 'base64');
if (receivedBuf.length !== expectedBuf.length) return false;
return timingSafeEqual(receivedBuf, expectedBuf);
}
app.post('/webhooks/adyen', express.json({ limit: '10mb' }), async (req, res) => {
const notifications = req.body.notificationItems || [];
for (const item of notifications) {
if (verifyAdyenWebhook(item, process.env.ADYEN_HMAC_KEY)) {
const { eventCode, success, pspReference, merchantReference } = item.NotificationRequestItem;
await handleAdyenEvent({ eventCode, success, pspReference, merchantReference });
}
}
// Exact acknowledgment string required — Adyen retries if response differs
res.json({ notificationResponse: '[accepted]' });
});
Lemon Squeezy: HMAC-SHA256 Over Raw Body With Hex Output and Pre-Parse Dispatch
Lemon Squeezy's HMAC is the most straightforward of the five: SHA256 over the raw request body, using the per-endpoint signing secret as the key, with hex output (not base64) in the X-Signature header. The most common implementation error is using .digest('base64') instead of .digest('hex'), which produces a verification mismatch on every valid webhook.
The design advantage Lemon Squeezy offers: the X-Event-Name header carries the event type as a plain string. This enables verifying the signature and dispatching to the correct handler before parsing the JSON body — a pattern unavailable in the other four platforms, where you must parse the body to determine the event type. Verify on raw bytes, dispatch by header, then parse only in the handler that needs the specific body shape.
import { createHmac, timingSafeEqual } from 'node:crypto';
function verifyLemonSqueezyWebhook(rawBody, signature, signingSecret) {
if (!signature || !signingSecret) return false;
// Output is hex — NOT base64 — the most common implementation mistake
const expected = createHmac('sha256', signingSecret).update(rawBody).digest('hex');
const expectedBuf = Buffer.from(expected, 'hex');
const receivedBuf = Buffer.from(signature, 'hex');
if (expectedBuf.length !== receivedBuf.length) return false;
return timingSafeEqual(expectedBuf, receivedBuf);
}
const handlers = {
'order_created': handleOrderCreated,
'subscription_created': handleSubscriptionCreated,
'subscription_payment_failed': handleSubscriptionPaymentFailed,
'subscription_payment_recovered': handleSubscriptionPaymentRecovered,
'subscription_expired': handleSubscriptionExpired,
};
app.post('/webhooks/lemon-squeezy', express.raw({ type: 'application/json' }), async (req, res) => {
const signature = req.headers['x-signature'];
const eventName = req.headers['x-event-name']; // available WITHOUT parsing body
const isValid = verifyLemonSqueezyWebhook(req.body, signature, process.env.LEMON_SQUEEZY_WEBHOOK_SECRET);
if (!isValid) return res.status(403).json({ error: 'Invalid signature' });
// Dispatch by header — dispatch decision made before JSON.parse()
const handler = handlers[eventName];
if (handler) await handler(JSON.parse(req.body.toString('utf8')));
res.sendStatus(200);
});
Braintree: SDK Verify With Form-Encoded Body
Braintree webhook verification is the only one that requires no custom HMAC implementation — the SDK's gateway.webhookNotification.verify(btSignature, btPayload) handles all signature parsing, HMAC verification, and payload decoding. The sharp edge: Braintree sends webhook bodies as application/x-www-form-urlencoded, not JSON. express.json() will not parse it; express.urlencoded() is required. The payload arrives in req.body.bt_signature and req.body.bt_payload.
app.post('/webhooks/braintree',
express.urlencoded({ extended: false }), // form-encoded, not JSON
async (req, res) => {
let notification;
try {
// SDK handles all verification internally
notification = await gateway.webhookNotification.verify(
req.body.bt_signature,
req.body.bt_payload,
);
} catch (err) {
return res.sendStatus(401);
}
switch (notification.kind) {
case braintree.WebhookNotification.Kind.TransactionSettled:
await handleSettled(notification.subject.transaction); break;
case braintree.WebhookNotification.Kind.SubscriptionChargedSuccessfully:
await handleSubCharge(notification.subject.subscription); break;
case braintree.WebhookNotification.Kind.DisputeOpened:
await handleDispute(notification.subject.dispute); break;
}
res.sendStatus(200);
}
);
Webhook Verification Protocol Comparison
| Platform | Algorithm | HMAC input | Key encoding | Output encoding | Signature header | Body content-type | Acknowledgment |
|---|---|---|---|---|---|---|---|
| PayPal | RSA-SHA256 (not HMAC) | {txId}|{ts}|{webhookId}|CRC32(body) |
PEM cert fetched from PayPal domain URL | base64 | PAYPAL-TRANSMISSION-SIG |
JSON | HTTP 200 |
| Square | HMAC-SHA256 | notificationUrl + rawBody |
UTF-8 (webhook signature key from dashboard) | base64 | X-Square-Hmacsha256-Signature |
JSON | HTTP 200 |
| Adyen | HMAC-SHA256 | Fixed field join (8 fields with : separator) |
hex-decoded (critical) | base64 | additionalData.hmacSignature (in body) |
JSON (batched array) | {"notificationResponse": "[accepted]"} |
| Lemon Squeezy | HMAC-SHA256 | raw body only | UTF-8 (per-endpoint signing secret) | hex (not base64) | X-Signature |
JSON | HTTP 200 |
| Braintree | HMAC-SHA256 (SDK-managed) | SDK-managed | SDK-managed | SDK-managed | bt_signature + bt_payload (form fields) |
form-encoded (not JSON) | HTTP 200 |
Pattern 3: Test/Live Environment Models — Five Completely Different Switches
Every payment API has test credentials and test environments. How that separation is implemented ranges from completely separate infrastructure with separate credentials (PayPal, Square) to a single URL with a resource attribute (Lemon Squeezy) to a constructor parameter (Braintree). The migration risk is the same for all: sending live customer payment data to a test environment, or using test credentials against a live-looking endpoint. The failure mode differs: some produce hard errors (401, URL mismatch), some produce silent wrong-environment execution (Adyen, Lemon Squeezy).
PayPal: Completely Separate Infrastructure
PayPal's test environment (sandbox) is a completely separate deployment from live. Separate base URLs, separate client credential pairs, separate webhook IDs, and separate buyer test accounts. There is no sandbox flag on a shared endpoint — using a live client_id against the sandbox URL returns a 401. Using a sandbox client_id against the live URL also returns 401. Using a live webhook ID with sandbox credentials causes RSA signature verification to fail because the webhook signing key differs between environments.
The operational implication: you need two complete sets of environment variables (PAYPAL_CLIENT_ID_SANDBOX, PAYPAL_CLIENT_ID_LIVE, PAYPAL_WEBHOOK_ID_SANDBOX, PAYPAL_WEBHOOK_ID_LIVE). Mixing any pair across environments produces either immediate 401s or silent verification failures.
Square: Separate Base URLs, Hardcoded Test Nonces
Square's sandbox is at connect.squareupsandbox.com; live is at connect.squareup.com. Developer access tokens from the sandbox application are valid only against the sandbox URL. In sandbox, card nonces are hardcoded test values — cnon:card-nonce-ok produces a successful COMPLETED payment, cnon:card-nonce-declined simulates a declined payment, cnon:card-nonce-delay-1m keeps the payment in PENDING for 60 seconds. These nonces do not work in the live environment.
// Square test nonces — sandbox only
// cnon:card-nonce-ok → COMPLETED
// cnon:card-nonce-declined → FAILED (processor_declined)
// cnon:card-nonce-avs-no-match → FAILED (avs mismatch)
// cnon:card-nonce-requires-cvv → FAILED (cvv required)
// cnon:card-nonce-delay-1m → PENDING for ~60s then COMPLETED
async function createTestPayment(client, locationId) {
return client.request('POST', '/v2/payments', {
source_id: 'cnon:card-nonce-ok', // sandbox test nonce
idempotency_key: crypto.randomUUID(),
amount_money: { amount: 1999, currency: 'USD' }, // $19.99 in cents
location_id: locationId,
});
}
Adyen: Silent Misconfiguration Risk
Adyen's test environment is explicit: the test base URL (checkout-test.adyen.com) is used when no live endpoint prefix is configured. The live environment requires setting the prefix. The silent failure mode: deploying to production without ADYEN_LIVE_ENDPOINT_PREFIX makes all API calls succeed against the test environment with HTTP 200 responses — valid-looking pspReference values, real-looking response shapes, no money movement, no error. This is one of the most expensive configuration bugs in payment integration: it can go undetected for days until reconciliation reveals zero captured funds.
The mitigation is a startup validation that throws at application boot if the prefix is absent in production. No graceful degradation, no warning — hard failure at startup is the correct behavior here, because silent wrong-environment operation is worse than an application that refuses to start.
Lemon Squeezy: Single URL, Resource-Level test_mode Flag
Lemon Squeezy uses one API URL for both test and live. The environment of a resource is determined at creation time based on the store's mode setting in the dashboard. Once created, resources carry a permanent test_mode: boolean attribute. There is no per-request environment override.
The risk is more subtle than the Adyen case: list responses mix test and live resources without warning. An MCP tool that counts orders to compute revenue may count test orders. An MCP tool that reads the most recent subscription to determine a user's plan tier may read a test subscription. These bugs are silent and produce incorrect application behavior rather than HTTP errors.
// Safe order revenue calculation — exclude test mode resources
async function getLiveOrderRevenue(client, storeId) {
const data = await client.request('GET', '/orders', {
params: { 'filter[store_id]': storeId, 'page[size]': 100 },
});
const liveOrders = (data.data || []).filter(o => !o.attributes.test_mode);
return liveOrders.reduce((sum, o) => sum + (o.attributes.total || 0), 0); // cents
}
Braintree: Constructor Environment Enum
Braintree's environment selection is a constructor parameter: braintree.Environment.Sandbox or braintree.Environment.Production. The SDK routes requests to the appropriate infrastructure. Test nonces — "fake-valid-nonce", "fake-processor-declined-visa-nonce", "fake-gateway-rejected-nonce" — work only in the sandbox environment and return an error in production.
The explicit constructor approach means there is no silent misconfiguration path — if you initialize with Environment.Sandbox credentials and Environment.Production, the SDK throws an authentication error immediately on the first call. No silent wrong-environment processing.
| Platform | Environment model | How to switch | Silent wrong-env risk |
|---|---|---|---|
| PayPal | Separate base URLs + separate credential sets | Base URL + client_id + secret + webhookId | Low — wrong URL produces 401 |
| Square | Separate base URLs | Base URL + access token | Low — wrong URL produces 401 |
| Adyen | URL prefix controls environment | ADYEN_LIVE_ENDPOINT_PREFIX env var |
High — missing prefix silently uses test with HTTP 200 |
| Lemon Squeezy | Single URL; test_mode attribute per resource |
Dashboard store setting at creation time | High — list responses mix test and live without warning |
| Braintree | Constructor environment enum | Environment.Sandbox vs Environment.Production |
Low — wrong env produces auth error on first call |
Idempotency: Five Different Approaches to Preventing Duplicate Charges
Payment operations that fail mid-flight — network timeout, server crash, client retry — risk creating duplicate charges if the server processed the request but the client never received the response. All five platforms offer idempotency protection, but the mechanism, scope, and enforcement varies.
PayPal uses the PayPal-Request-Id request header, a UUID you generate and store. The same UUID submitted within 24 hours returns the original order response instead of creating a duplicate. After 24 hours, the same UUID creates a new order. Store the UUID alongside the order in your database — if a retry is needed within 24 hours, use the same UUID; after 24 hours, generate a new one.
Square makes idempotency_key a required field in the request body — not a header, a body field. Omitting it causes a 400 MISSING_REQUIRED_PARAMETER. Reusing the same key with different parameters (different amount, different items) returns 409 IDEMPOTENCY_KEY_REUSED. Square's enforcement is stricter than PayPal's: the key conflict is an error, not a silent "return original response" behavior.
Adyen uses the Idempotency-Key: {uuid} header on checkout session creation. The header is optional but strongly recommended. If a request with a given key fails (HTTP 500, timeout), retrying with the same key returns the original response if Adyen processed it, or triggers a fresh attempt if it did not.
Lemon Squeezy does not have an explicit idempotency key mechanism. The platform is optimized for checkout link flows where the customer initiates each payment — server-side duplicate prevention must be handled at the application level by checking for existing orders before creating new checkouts.
Braintree handles duplicate prevention through the SDK's built-in duplicate transaction detection. The SDK compares amount, payment method, and order ID within a configurable time window (default 24 hours). MCP tools using Braintree should always set orderId on transaction calls — this is the primary duplicate detection key, and without it, the duplicate check is less reliable.
// PayPal — UUID in header, store for retry
const idempotencyKey = crypto.randomUUID();
const order = await ppFetch('/v2/checkout/orders', {
method: 'POST',
headers: { 'PayPal-Request-Id': idempotencyKey },
body: JSON.stringify({ intent: 'CAPTURE', purchase_units: [{ amount: { currency_code: 'USD', value: '19.99' } }] }),
});
await db.orders.update(order.id, { idempotencyKey }); // store for retry window
// Square — idempotency_key in body (required, not optional)
const data = await client.request('POST', '/v2/payments', {
source_id: sourceNonce,
idempotency_key: crypto.randomUUID(), // REQUIRED — 400 if missing
amount_money: { amount: 1999, currency: 'USD' },
location_id: locationId,
});
// Braintree — orderId enables duplicate detection
const result = await gateway.transaction.sale({
amount: '19.99',
paymentMethodNonce: nonce,
orderId: 'your-internal-order-uuid', // Braintree uses this for duplicate detection
options: { submitForSettlement: true },
});
Health Probe Design — Platform-Specific Non-Destructive Probes
Payment API health probes must validate credentials and API reachability without creating test charges, dummy orders, or other side effects that pollute reporting dashboards or trigger erroneous webhooks. Each platform has a different safe probe endpoint.
PayPal: Token acquisition — POST /v1/oauth2/token. Validates client_id + secret without creating any resource. A 401 means credential failure; a 5xx means PayPal infrastructure issue.
Square: GET /v2/locations. Read-only, validates the access token, and returns the location IDs needed for all subsequent operations. A 200 with active locations means fully operational. A 401 means invalid token. A 403 means missing OAuth scope.
Adyen: POST /paymentMethods with a minimal body (countryCode, amount, currency). Returns available payment methods without creating a session. A 200 with an empty array means credentials valid but no payment methods configured. Error code 905 means the merchant account lacks permission for this endpoint.
Lemon Squeezy: GET /v1/users/me. Returns account info, validates API key. A 401 means invalid or rotated key. Also probe GET /v1/webhooks to verify that Lemon Squeezy has not silently disabled your webhook endpoint due to repeated non-200 responses from your handler.
Braintree: gateway.clientToken.generate({}). Validates all three credentials in one call, produces no durable state. In sandbox only, gateway.webhookTesting.sampleNotification(kind, id) generates a test webhook payload to smoke-test your handler without waiting for a real event.
// Composite health probe: check all configured payment integrations
async function checkPaymentHealth({ paypal, square, adyen, lemonsqueezy, braintree }) {
const checks = await Promise.allSettled([
paypal ? paypalTokenManager.getToken().then(() => ({ provider: 'paypal', status: 'healthy' })) : null,
square ? squareClient.request('GET', '/v2/locations').then(d => ({ provider: 'square', status: 'healthy', locations: d.locations?.length })) : null,
adyen ? adyenClient.checkout('/paymentMethods', { countryCode: 'US', amount: { value: 100, currency: 'USD' }, channel: 'Web' }).then(() => ({ provider: 'adyen', status: 'healthy' })) : null,
lemonsqueezy ? lsClient.request('GET', '/users/me').then(d => ({ provider: 'lemonsqueezy', status: 'healthy', user: d.data?.attributes?.email })) : null,
braintree ? gateway.clientToken.generate({}).then(r => ({ provider: 'braintree', status: r.success ? 'healthy' : 'degraded' })) : null,
].filter(Boolean));
return checks.map(result =>
result.status === 'fulfilled'
? result.value
: { provider: 'unknown', status: 'unhealthy', error: result.reason?.message }
);
}
Transaction and Payment Status Machines
Beyond credentials and webhooks, each platform has a distinct payment lifecycle state machine with different terminal states, different field names, and different rules about what can happen after each transition.
PayPal orders: CREATED → APPROVED → COMPLETED or VOIDED. The APPROVED transition requires buyer action in a browser — no server-side skip. After COMPLETED, the payment amount resides in purchase_units[0].payments.captures[0], not in the top-level order object. Always check captures[0].status (not order status) to verify money moved — an order can be COMPLETED while a capture is PENDING (common for ACH).
Square payments: APPROVED → PENDING → COMPLETED or CANCELED or FAILED. Card declines are in payment.card_details.errors[], not in HTTP status codes. APPROVED means authorized but not yet settled; COMPLETED means settled.
Adyen uses resultCode instead of a state machine field: Authorised, Refused, Error, Pending, ChallengeShopper, Cancelled. Authorised does not mean captured — Adyen supports manual capture. The CAPTURE event code in webhooks confirms funds captured. refusalReasonCode on a Refused response identifies the specific decline reason.
Lemon Squeezy orders: pending → failed → paid → refunded. Subscription states: active → past_due → expired | cancelled. The subscription_payment_failed event moves a subscription to past_due; subscription_payment_recovered (a successful retry) moves it back to active. subscription_expired fires when the billing period ends without recovery — this is when access should be revoked.
Braintree transactions: authorized → submitted_for_settlement → settling → settled. Failures: processor_declined (issuer declined), gateway_rejected (Braintree fraud rules), settlement_declined (rare — settlement batch rejected). submitted_for_settlement is not the same as settled — reconciliation logic that treats them as equivalent will miscount settled revenue. The processorResponseCode field on declined transactions maps to specific issuer decline reasons: 2001 = insufficient funds, 2046 = generic decline.
Platform Selection Guide for MCP Tools
Choosing a payment integration to expose via MCP tools is often not a choice — existing infrastructure determines the platform. But when building a new integration, these dimensions matter.
For SaaS subscription billing targeting developers and indie makers: Lemon Squeezy's simplest credential shape (static key, no refresh, single URL) and JSON:API response format make it fast to integrate. The per-endpoint webhook signing secret means you can have different secrets for different webhook consumers without rotating a shared key. The test_mode attribute model requires careful list filtering but is conceptually clean.
For enterprise e-commerce at scale with complex payment routing: Adyen's Balance Platform handles marketplace payouts, split payments, and multi-merchant flows that other platforms cannot. The X-API-Key header and live endpoint prefix requirements have a higher learning curve, but the webhook batching (one POST for multiple events) and explicit [accepted] acknowledgment make it easier to build reliable webhook processing under high throughput.
For B2C consumer payments with PayPal as an expected checkout option: PayPal's two-step OAuth2 is more overhead than static-key platforms, but it's non-negotiable for buyer trust — many consumers won't enter card details on a checkout that lacks the PayPal button. The two-step capture model (create order → buyer approves → capture) is the correct pattern for standard checkout; skip it and you'll face disputes about unauthorized captures.
For in-person retail or multi-location businesses: Square's location_id scoping is an asset rather than a constraint — it mirrors the real-world structure of a multi-location merchant. The mandatory GET /v2/locations at init time and per-call location_id provide clean separation between location-level and merchant-level data. The Catalog API's merchant-wide item model with location-specific inventory is well-suited to inventory tracking MCP tools.
For existing PayPal/Venmo ecosystem with vault-stored payment methods: Braintree (PayPal's platform) provides the cleanest server-to-server charging model once payment methods are in the vault. The three-credential SDK init is one-time friction; recurring charges to vault tokens are then gateway.transaction.sale({ paymentMethodToken, amount, orderId }) with no client involvement needed.