Guide · Payment Processing

MCP Tools for Braintree — SDK-first pattern, payment nonce, three-credential auth, webhook verification, transaction state machine

Braintree has three architectural decisions that make it fundamentally different from other payment APIs: Braintree is designed to be used through its official SDK, not raw HTTP requests — the REST API is not public or stable, and Braintree's documentation explicitly discourages bypassing the SDK; authentication requires three separate credentialsmerchant_id, public_key, and private_key — rather than a single API key; and the payment nonce model separates client-side tokenization from server-side charging — a browser or mobile client uses Braintree's Drop-In UI to tokenize a card into a one-time-use nonce string, and the server charges the nonce rather than the raw card number, which means MCP tools that operate server-to-server can only charge payment methods that have already been tokenized by a client or stored in the Braintree vault.

TL;DR

Auth: braintree.BraintreeGateway({ environment: braintree.Environment.Sandbox, merchantId, publicKey, privateKey }). Always use the SDK — the REST API is internal. Create transaction: gateway.transaction.sale({ amount, paymentMethodNonce, options: { submitForSettlement: true } }) where paymentMethodNonce comes from client-side tokenization or the test nonce "fake-valid-nonce". Webhook verification: gateway.webhookNotification.verify(btSignature, btPayload) using Bt-Signature and Bt-Payload headers. Sandbox: Environment.Sandbox; live: Environment.Production. Transaction states: authorizedsubmitted_for_settlementsettlingsettled (or processor_declined / gateway_rejected).

Authentication — three-credential SDK initialization

Braintree authentication uses three separate values — merchant_id, public_key, and private_key — all obtained from the Braintree Control Panel under Settings → API Keys. These are used to initialize the gateway object; the SDK handles all credential encoding internally using HTTP Basic auth with base64(public_key:private_key).

The gateway object is long-lived — create one at application startup and reuse it across all requests. There are no tokens to refresh, no expiry, and no per-request overhead for credential resolution.

import braintree from 'braintree';

// Initialize once at startup — gateway is long-lived, no token refresh needed
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,
});

// Validate all three credentials are present at startup
function validateBraintreeConfig() {
  const required = ['BRAINTREE_MERCHANT_ID', 'BRAINTREE_PUBLIC_KEY', 'BRAINTREE_PRIVATE_KEY'];
  const missing = required.filter(k => !process.env[k]);
  if (missing.length) {
    throw new Error(`Missing Braintree credentials: ${missing.join(', ')}`);
  }
}

validateBraintreeConfig();

// The SDK uses HTTP Basic auth internally:
// Authorization: Basic base64(public_key:private_key)
// The merchant_id determines which account to operate on
// NEVER construct raw HTTP requests to Braintree — use the SDK methods

Braintree has both a v1 REST API (legacy) and a v4 GraphQL API. The Node.js braintree npm package wraps the legacy REST API under the hood. Braintree's newer GraphQL API at payments.braintree-api.com/graphql is available for direct use but has a different authentication scheme (same credentials, different encoding). Use the SDK unless you specifically need GraphQL mutations not covered by the SDK.

Payment nonce model — client tokenization and server charging

Braintree's security model separates card data handling into two steps: the browser/mobile client tokenizes card details using Braintree's Drop-In UI (which communicates directly with Braintree's servers and never sends raw card data to your server), and your server receives a one-time-use nonce — a string like "tokencc_b_p5234c_..." — which it uses to charge the payment method.

For MCP tools that operate in a server-to-server context (no browser interaction), you need either: (1) a stored payment method vault token (from a prior customer-facing transaction), or (2) a test nonce in sandbox ("fake-valid-nonce"). You cannot generate production nonces from server-side code — that would defeat the purpose of client-side tokenization.

// Generate a client token for frontend initialization (server-side)
async function generateClientToken(customerId = null) {
  const params = {};
  if (customerId) {
    // Providing customerId loads the customer's stored payment methods
    // into the Drop-In UI automatically
    params.customerId = customerId;
  }

  const result = await gateway.clientToken.generate(params);
  if (!result.success) {
    throw new Error(`Failed to generate client token: ${result.message}`);
  }
  return result.clientToken; // send this to the browser to initialize Drop-In
}

// Charge a payment nonce (received from client-side Drop-In tokenization)
async function saleTransaction({
  amountUsd,        // string: "19.99"
  nonce,            // from client Drop-In, or "fake-valid-nonce" in sandbox
  customerId,       // optional — to link to an existing vault customer
  storeInVault,     // boolean — save payment method for future charges
  orderId,          // your internal order ID (for reconciliation)
  descriptor,       // { name, phone, url } — appears on bank statement
}) {
  const result = await gateway.transaction.sale({
    amount: amountUsd,        // must be a string ("19.99" not 19.99)
    paymentMethodNonce: nonce,
    orderId,
    ...(customerId ? { customerId } : {}),
    ...(descriptor ? { descriptor } : {}),
    options: {
      // submitForSettlement: true means authorize AND capture in one step
      // false means authorize-only (requires manual capture via gateway.transaction.submitForSettlement)
      submitForSettlement: true,
      // Store payment method in vault for future charges
      ...(storeInVault ? { storeInVaultOnSuccess: true } : {}),
    },
  });

  if (!result.success) {
    const tx = result.transaction;
    // result.success is false for processor declines and gateway rejections
    return {
      success: false,
      status: tx?.status, // 'processor_declined' | 'gateway_rejected' | 'failed'
      processorResponseCode: tx?.processorResponseCode, // '2001' = insufficient funds
      processorResponseText: tx?.processorResponseText,
      gatewayRejectionReason: tx?.gatewayRejectionReason, // 'avs' | 'cvv' | 'fraud' | 'duplicate'
      message: result.message,
    };
  }

  const tx = result.transaction;
  return {
    success: true,
    transactionId: tx.id,
    status: tx.status, // 'submitted_for_settlement' or 'authorized'
    amount: tx.amount,
    paymentMethodToken: tx.creditCard?.token, // vault token if storeInVault was true
    processorAuthCode: tx.processorAuthorizationCode,
    paymentInstrumentType: tx.paymentInstrumentType,
  };
}

// Test nonces for sandbox
// "fake-valid-nonce"         → authorized, submitted_for_settlement
// "fake-processor-declined-visa-nonce" → processor_declined
// "fake-gateway-rejected-nonce"  → gateway_rejected
// "fake-valid-paypal-one-time-nonce" → PayPal payment
// "fake-venmo-account-nonce"  → Venmo payment

Transaction state machine and settlement

Braintree transactions move through a multi-step settlement process. The states are distinct from what most developers expect, and confusing submitted_for_settlement with settled is a common cause of incorrect reconciliation logic in MCP tools.

// Transaction status hierarchy
// authorized → submitted_for_settlement → settling → settled
//            ↘ voided (if voided before settlement)
//
// OR: processor_declined  (immediate — card issuer rejected)
//     gateway_rejected    (immediate — Braintree's fraud rules)
//     settlement_declined (rare — settlement batch rejected by processor)
//     failed              (network or system error)

async function getTransaction(transactionId) {
  const result = await gateway.transaction.find(transactionId);
  return {
    id: result.id,
    status: result.status,
    amount: result.amount,
    merchantAccountId: result.merchantAccountId,
    orderId: result.orderId,
    createdAt: result.createdAt,
    updatedAt: result.updatedAt,
    // Status timestamps — tells you when each state transition happened
    authorizedTransactionDetails: result.statusHistory,
    // Card details (if card payment)
    card: result.creditCard ? {
      last4: result.creditCard.last4,
      cardType: result.creditCard.cardType,
      expirationDate: result.creditCard.expirationDate,
      token: result.creditCard.token, // vault token
    } : null,
    // Settlement batch info (present only for settled transactions)
    settlementBatch: result.settlementBatchId,
  };
}

// Poll for settlement (needed when authorizing and capturing separately)
async function waitForSettlement(transactionId, { timeoutMs = 60_000, intervalMs = 5_000 } = {}) {
  const terminal = new Set(['settled', 'settlement_declined', 'voided', 'failed']);
  const deadline = Date.now() + timeoutMs;

  while (Date.now() < deadline) {
    const tx = await getTransaction(transactionId);
    if (terminal.has(tx.status)) return tx;
    await new Promise(r => setTimeout(r, intervalMs));
  }
  throw new Error(`Transaction ${transactionId} not settled within ${timeoutMs}ms`);
}

// Void a transaction (before settlement)
async function voidTransaction(transactionId) {
  const result = await gateway.transaction.void(transactionId);
  if (!result.success) throw new Error(`Void failed: ${result.message}`);
  return { transactionId: result.transaction.id, status: result.transaction.status };
}

// Refund a settled transaction
async function refundTransaction(transactionId, amountUsd = null) {
  const result = await gateway.transaction.refund(
    transactionId,
    amountUsd // null = full refund; string = partial refund ("5.00")
  );
  if (!result.success) throw new Error(`Refund failed: ${result.message}`);
  return {
    refundTransactionId: result.transaction.id,
    status: result.transaction.status, // 'submitted_for_settlement'
    amount: result.transaction.amount,
  };
}

Processor response codes

When result.success === false and status === "processor_declined", the processorResponseCode identifies the specific decline reason. Key codes: 2000 = "Do Not Honor" (generic bank decline); 2001 = "Insufficient Funds"; 2002 = "Limit Exceeded"; 2003 = "Cardholder's Activity Limit Exceeded"; 2046 = "Declined" (issuer did not provide a reason). Never expose raw processor codes to end users — map them to user-friendly messages.

Customer vault — storing payment methods

Braintree's vault stores tokenized payment methods (cards, PayPal accounts) as customer records. MCP tools that need to charge returning customers without re-tokenizing use vault tokens to reference stored payment methods.

// Create a customer with a payment method
async function createCustomer({ email, firstName, lastName, nonce, phone }) {
  const result = await gateway.customer.create({
    email,
    firstName,
    lastName,
    phone,
    paymentMethodNonce: nonce, // tokenize and store in one step
  });

  if (!result.success) throw new Error(`Customer create failed: ${result.message}`);

  const customer = result.customer;
  return {
    customerId: customer.id,
    // First payment method token — use this for future charges
    defaultPaymentMethodToken: customer.paymentMethods?.[0]?.token,
    paymentMethods: customer.paymentMethods?.map(pm => ({
      token: pm.token,
      type: pm.paymentInstrumentType,
      last4: pm.last4 || undefined,
      email: pm.email || undefined, // for PayPal
      default: pm.default,
    })),
  };
}

// Charge a stored payment method (no nonce needed)
async function chargeStoredPaymentMethod(paymentMethodToken, amountUsd, orderId) {
  const result = await gateway.transaction.sale({
    amount: amountUsd,
    paymentMethodToken, // vault token, not a nonce
    orderId,
    options: { submitForSettlement: true },
  });

  if (!result.success) {
    return { success: false, status: result.transaction?.status, message: result.message };
  }
  return { success: true, transactionId: result.transaction.id, status: result.transaction.status };
}

// Find a customer by ID
async function findCustomer(customerId) {
  try {
    return await gateway.customer.find(customerId);
  } catch (err) {
    if (err.type === 'notFoundError') return null;
    throw err;
  }
}

Webhook verification — Bt-Signature and Bt-Payload

Braintree webhooks use a two-header signature scheme. The Bt-Signature header contains a public key fingerprint and a HMAC signature joined by |. The Bt-Payload header contains the base64-encoded webhook notification body. The SDK's gateway.webhookNotification.verify() method handles verification, signature parsing, and payload decoding in one call.

// Webhook verification using the SDK
app.post('/webhooks/braintree',
  express.urlencoded({ extended: false }), // Braintree sends form-encoded body
  async (req, res) => {
    const btSignature = req.body.bt_signature;
    const btPayload   = req.body.bt_payload;

    // SDK verify() returns the parsed WebhookNotification object
    // Throws if signature is invalid — wrap in try/catch
    let notification;
    try {
      notification = await gateway.webhookNotification.verify(btSignature, btPayload);
    } catch (err) {
      console.error('Braintree webhook verification failed:', err.message);
      return res.sendStatus(401);
    }

    // notification.kind identifies the event type
    // notification.subject contains the event data
    switch (notification.kind) {
      case braintree.WebhookNotification.Kind.TransactionSettled:
        await handleTransactionSettled(notification.subject.transaction);
        break;
      case braintree.WebhookNotification.Kind.TransactionSettlementDeclined:
        await handleSettlementDeclined(notification.subject.transaction);
        break;
      case braintree.WebhookNotification.Kind.SubscriptionChargedSuccessfully:
        await handleSubscriptionChargeSuccess(notification.subject.subscription);
        break;
      case braintree.WebhookNotification.Kind.SubscriptionChargedUnsuccessfully:
        await handleSubscriptionChargeFailure(notification.subject.subscription);
        break;
      case braintree.WebhookNotification.Kind.SubMerchantAccountApproved:
        await handleSubMerchantApproved(notification.subject.merchantAccount);
        break;
      default:
        console.log(`Unhandled Braintree webhook: ${notification.kind}`);
    }

    res.sendStatus(200);
  }
);

// Key webhook kinds (from braintree.WebhookNotification.Kind)
// TransactionSettled                 — transaction.settled
// TransactionSettlementDeclined      — settlement failed after authorization
// SubscriptionChargedSuccessfully    — recurring billing succeeded
// SubscriptionChargedUnsuccessfully  — recurring billing failed
// SubscriptionCancelled              — subscription cancelled
// SubscriptionExpired                — subscription expired
// SubMerchantAccountApproved         — marketplace sub-merchant approved
// SubMerchantAccountDeclined         — marketplace sub-merchant rejected
// DisputeOpened                      — chargeback dispute opened
// DisputeWon / DisputeLost           — dispute resolution

Note: Braintree sends webhooks as application/x-www-form-urlencoded, not JSON. Your framework must parse form bodies before accessing bt_signature and bt_payload. Express's default JSON body parser will not handle this correctly; use express.urlencoded().

Subscriptions and recurring billing

Braintree's recurring billing is managed through Plans (defined in the Control Panel) and Subscriptions (created via API against a stored payment method). Each subscription is tied to a vault payment method token; Braintree charges automatically on the billing cycle and fires webhooks for each charge attempt.

// Create a subscription for a customer's stored payment method
async function createSubscription({ paymentMethodToken, planId, firstBillingDate }) {
  const params = {
    paymentMethodToken,
    planId, // defined in Braintree Control Panel → Recurring Billing → Plans
  };

  // firstBillingDate: ISO date string — defer first charge to a future date
  // Useful for trials: set to trialEnd + 1 day
  if (firstBillingDate) {
    params.firstBillingDate = firstBillingDate;
  }

  const result = await gateway.subscription.create(params);
  if (!result.success) throw new Error(`Subscription failed: ${result.message}`);

  const sub = result.subscription;
  return {
    subscriptionId: sub.id,
    status: sub.status, // 'Active' | 'Canceled' | 'Expired' | 'Past Due' | 'Pending'
    planId: sub.planId,
    price: sub.price,
    billingDayOfMonth: sub.billingDayOfMonth,
    firstBillingDate: sub.firstBillingDate,
    nextBillingDate: sub.nextBillingDate,
    paidThroughDate: sub.paidThroughDate,
    failureCount: sub.failureCount,
  };
}

// Cancel a subscription
async function cancelSubscription(subscriptionId) {
  const result = await gateway.subscription.cancel(subscriptionId);
  if (!result.success) throw new Error(`Cancel failed: ${result.message}`);
  return { status: result.subscription.status }; // 'Canceled'
}

Health probe for Braintree MCP servers

Braintree doesn't have a dedicated health endpoint. The recommended probe is to call gateway.clientToken.generate({}) — it validates all three credentials, exercises the authentication path, and does not create any durable resources. A successful token generation confirms the credentials are valid and the Braintree API is reachable.

// Health probe: client token generation (validates credentials, no transaction created)
async function checkBraintreeHealth(gateway) {
  const start = Date.now();
  try {
    const result = await gateway.clientToken.generate({});
    if (!result.success) {
      return { status: 'degraded', message: result.message, latencyMs: Date.now() - start };
    }
    return {
      status: 'healthy',
      latencyMs: Date.now() - start,
      environment: process.env.NODE_ENV === 'production' ? 'production' : 'sandbox',
    };
  } catch (err) {
    return {
      status: 'unhealthy',
      error: err.message,
      isAuthError: err.type === 'authenticationError',
      latencyMs: Date.now() - start,
    };
  }
}

// Extended probe: verify webhook endpoint registered
async function checkBraintreeWebhooks(gateway) {
  try {
    // Use a test webhook notification to verify your endpoint works
    // gateway.webhookTesting.sampleNotification(kind, id) — sandbox only
    const samplePayload = await gateway.webhookTesting.sampleNotification(
      braintree.WebhookNotification.Kind.SubscriptionCancelled,
      'test-subscription-id'
    );
    return { webhookTestAvailable: true, payload: samplePayload };
  } catch (err) {
    return { webhookTestAvailable: false, error: err.message };
  }
}

The gateway.webhookTesting.sampleNotification() method is sandbox-only — it generates a test webhook payload that you can send to your own endpoint to verify your handler works without needing a real event to trigger. Use this in integration tests and as part of your deployment smoke test. See MCP server health check patterns for composite probe strategies, and Stripe integration for a comparison with Stripe's similar but distinct webhook verification approach.