Guide · Payment Processing

MCP Tools for Adyen — X-API-Key auth, live endpoint prefix, HMAC webhook verification, balance platform vs payments

Adyen has three integration patterns that regularly break MCP server implementations: authentication uses a proprietary X-API-Key header — not Authorization: Bearer — and the key is rotatable but does not expire by default, so a missing or misnamed header silently fails with 401 rather than a helpful error; the live endpoint is not api.adyen.com — live environments use a merchant-specific prefix of the form {UUID}-{merchantAccount}-pal-live.adyenpayments.com, and using the test endpoint URL in production returns data from the test environment without warning; and webhook HMAC verification uses a hex-encoded signing key that must be decoded from hex to bytes before the HMAC computation — treating the key as UTF-8 or base64 produces verification failures even with the correct key value.

TL;DR

Auth: X-API-Key: {apiKey} (NOT Authorization: Bearer). Test base URL: https://checkout-test.adyen.com/v71. Live base URL: https://{liveEndpointPrefix}-pal-live.adyenpayments.com/pal/servlet/Payment/v68. Webhook HMAC: sort dataToSign fields alphabetically by key → join as pspReference:originalReference:merchantAccountCode:merchantReference:value:currency:eventCode:success → HMAC-SHA256 with Buffer.from(hmacKey, 'hex') → compare base64 with additionalData.hmacSignature. Idempotency: Idempotency-Key: {uuid} header on checkout sessions. Amounts always in minor units (cents).

Authentication — X-API-Key header (not Authorization)

Adyen uses a custom X-API-Key header for REST API authentication. This is distinct from the industry-standard Authorization: Bearer {token} pattern used by most payment APIs. Code generators that default to Bearer headers will produce requests that fail with 401 even with correct key values.

Adyen offers multiple API keys: one per Web Service User in your Customer Area. Each key can be scoped to specific roles (Checkout API, Management API, Balance Platform, etc.). Use the minimum-permission key for each MCP tool's API surface area.

// Adyen API client — X-API-Key authentication
class AdyenClient {
  #apiKey;
  #merchantAccount;
  #checkoutBaseUrl;
  #paymentBaseUrl;

  constructor({ apiKey, merchantAccount, liveEndpointPrefix = null }) {
    this.#apiKey = apiKey;
    this.#merchantAccount = merchantAccount;

    if (liveEndpointPrefix) {
      // Live environment: merchant-specific URL prefix
      // Format: {UUID}-{merchantAccount}-pal-live.adyenpayments.com
      // Obtain this from Customer Area → Developers → API URLs
      this.#checkoutBaseUrl =
        `https://${liveEndpointPrefix}-checkout-live.adyenpayments.com/checkout/v71`;
      this.#paymentBaseUrl =
        `https://${liveEndpointPrefix}-pal-live.adyenpayments.com/pal/servlet/Payment/v68`;
    } else {
      // Test environment
      this.#checkoutBaseUrl = 'https://checkout-test.adyen.com/v71';
      this.#paymentBaseUrl  = 'https://pal-test.adyen.com/pal/servlet/Payment/v68';
    }
  }

  async request(method, baseUrl, path, body = null) {
    const res = await fetch(`${baseUrl}${path}`, {
      method,
      headers: {
        'X-API-Key': this.#apiKey,  // NOT Authorization: Bearer
        'Content-Type': 'application/json',
        'Adyen-Library-Name': 'custom-mcp-server', // optional but good practice
        'Adyen-Library-Version': '1.0.0',
      },
      body: body ? JSON.stringify(body) : undefined,
    });

    const data = await res.json();

    if (!res.ok) {
      throw new Error(
        `Adyen ${method} ${path} → ${res.status}: ` +
        `[${data.errorCode}] ${data.message} (type: ${data.errorType})`
      );
    }
    return data;
  }

  checkout(path, body) {
    return this.request('POST', this.#checkoutBaseUrl, path, {
      merchantAccount: this.#merchantAccount,
      ...body,
    });
  }

  payment(path, body) {
    return this.request('POST', this.#paymentBaseUrl, path, {
      merchantAccount: this.#merchantAccount,
      ...body,
    });
  }

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

Adyen error responses include an errorCode (numeric), errorType ("validation", "security", "configuration", "internal"), and message. The errorCode maps to Adyen's documentation. Error 000 is a generic internal error; 803 means invalidField; 905 means the merchant account isn't allowed to use this endpoint.

Live endpoint prefix — the most common production misconfiguration

Adyen's test environment (checkout-test.adyen.com) is a completely separate infrastructure from live. When you go live, Adyen assigns a live endpoint prefix — a long string available in Customer Area under Developers → API URLs. This prefix encodes your account identity and routes requests to the correct Adyen datacenter.

If you deploy to production without setting the live endpoint prefix, your MCP server silently processes payments in the test environment — no money moves, no error is returned. This is one of the hardest bugs to diagnose because the API responds normally with valid-looking payment references.

// Configuration validation at startup — catch live/test misconfiguration early
function validateAdyenConfig({ apiKey, merchantAccount, liveEndpointPrefix, nodeEnv }) {
  if (!apiKey) throw new Error('ADYEN_API_KEY is required');
  if (!merchantAccount) throw new Error('ADYEN_MERCHANT_ACCOUNT is required');

  if (nodeEnv === 'production' && !liveEndpointPrefix) {
    throw new Error(
      'ADYEN_LIVE_ENDPOINT_PREFIX is required in production. ' +
      'Find it in Customer Area → Developers → API URLs. ' +
      'Without it, all payments go to the TEST environment.'
    );
  }

  // API keys for test contain "test_" prefix in Customer Area (convention, not enforced)
  // API keys for live start with a different pattern — validate the prefix matches env
  const isTestKey = apiKey.startsWith('AQE'); // test keys often start AQE...
  const isLiveKey = !isTestKey;

  if (nodeEnv === 'production' && isTestKey) {
    console.warn('[ADYEN] Warning: API key appears to be a test key in production environment');
  }
}

// Initialize with environment-aware config
const adyen = new AdyenClient({
  apiKey: process.env.ADYEN_API_KEY,
  merchantAccount: process.env.ADYEN_MERCHANT_ACCOUNT,
  liveEndpointPrefix: process.env.ADYEN_LIVE_ENDPOINT_PREFIX || null,
});

Checkout Sessions API — creating payment sessions

For hosted payment pages (Drop-In or Components), Adyen uses a Sessions workflow: the server creates a payment session and returns a sessionId and sessionData to the client, which initializes the Adyen Drop-In UI. The actual payment processing happens client-side via the Drop-In, with webhooks notifying the server of the outcome.

// Create a checkout session (for Drop-In or Components integration)
async function createCheckoutSession(adyen, {
  amountCents,
  currency = 'EUR',
  merchantReference,
  returnUrl,
  countryCode = 'NL',
  shopperReference,
  shopperEmail,
}) {
  return adyen.checkout('/sessions', {
    amount: {
      value: amountCents, // integer minor units (cents) — EUR 10.99 = 1099
      currency,
    },
    merchantReference, // your internal order/payment ID
    returnUrl, // redirect after payment — HTTPS required in live
    countryCode,
    ...(shopperReference ? {
      shopperReference, // persistent shopper ID (enables stored payment methods)
      shopperEmail,
      storePaymentMethodMode: 'askForConsent',
    } : {}),
    // Idempotency-Key header prevents duplicate sessions on retry
    // Pass via options.headers in a real implementation
  });
  // Returns: { id, sessionData, amount, expiresAt, ... }
  // Client initializes Adyen Drop-In with { id, sessionData }
}

// Retrieve payment details for a completed session
async function getPaymentDetails(adyen, pspReference) {
  return adyen.payment('/getPaymentDetails', {
    paymentReference: pspReference,
  });
}

Payment states in Adyen

Adyen payment results use resultCode instead of a generic status field. The most important values:

Webhook HMAC verification — hex-decoded signing key

Adyen webhook notifications are signed using HMAC-SHA256. The critical detail that breaks most implementations: the HMAC signing key from the Customer Area is stored as a hex string, and it must be decoded from hex to raw bytes before use in the HMAC computation. Treating it as UTF-8 or base64 produces a different key and causes verification to fail.

import { createHmac, timingSafeEqual } from 'node:crypto';

// Build the dataToSign string from webhook notification fields
// Fields are joined in a specific fixed order with ':' separator
function buildAdyenDataToSign(notification) {
  const n = notification.NotificationRequestItem || notification;

  // The canonical field order — MUST be exact
  const fields = [
    n.pspReference           || '',
    n.originalReference      || '',
    n.merchantAccountCode    || '',
    n.merchantReference      || '',
    String(n.amount?.value   || ''),
    n.amount?.currency       || '',
    n.eventCode              || '',
    n.success                || '',
  ];

  return fields.join(':');
}

function verifyAdyenWebhook(notification, hmacKeyHex) {
  const receivedHmac = notification.NotificationRequestItem?.additionalData?.hmacSignature
    || notification.additionalData?.hmacSignature;

  if (!receivedHmac) {
    // No HMAC in notification — either webhook not configured for HMAC
    // or malformed request. Treat as invalid.
    return false;
  }

  // CRITICAL: hmacKeyHex is a hex string — decode to bytes first
  // Using Buffer.from(hmacKeyHex, 'utf8') or 'base64' produces wrong key
  const hmacKey = Buffer.from(hmacKeyHex, 'hex');

  const dataToSign = buildAdyenDataToSign(notification);

  const expectedHmac = createHmac('sha256', hmacKey)
    .update(dataToSign, 'utf8')
    .digest('base64');

  // Timing-safe comparison
  const received = Buffer.from(receivedHmac, 'base64');
  const expected = Buffer.from(expectedHmac, 'base64');

  if (received.length !== expected.length) return false;
  return timingSafeEqual(received, expected);
}

// Express webhook handler
app.post('/webhooks/adyen',
  express.json({ limit: '10mb' }),
  async (req, res) => {
    const notifications = req.body.notificationItems || [];

    for (const item of notifications) {
      const isValid = verifyAdyenWebhook(item, process.env.ADYEN_HMAC_KEY);
      if (!isValid) {
        console.error('Adyen webhook HMAC verification failed', item);
        // Per Adyen docs: return [accepted] even for invalid items
        // to prevent Adyen from retrying indefinitely
        continue;
      }

      const { eventCode, success, pspReference, merchantReference } =
        item.NotificationRequestItem;

      await handleAdyenEvent({ eventCode, success, pspReference, merchantReference });
    }

    // Adyen requires this exact acknowledgment string
    res.json({ notificationResponse: '[accepted]' });
  }
);

Adyen sends webhook notifications in batches — one HTTP POST can contain multiple notificationItems. Your handler must iterate the array and process each item. Returning HTTP 200 with {"notificationResponse": "[accepted]"} acknowledges receipt; any other response causes Adyen to retry the entire batch for up to 3 days.

Key Adyen webhook event codes

Balance Platform vs Payments Platform

Adyen exposes two distinct API surfaces depending on whether you're building a standard e-commerce payment integration or a financial platform (marketplace, embedded finance). MCP server authors frequently mix these up because the documentation and credentials look similar.

DimensionPayments PlatformBalance Platform
Use caseAccept payments on your own site/appMarketplace / embedded finance / payfac
Base URLcheckout-test.adyen.combalanceplatform-test.adyen.com
AuthX-API-KeyX-API-Key (different key, Balance-scoped)
Key resourcePaymentSession, PaymentResponseAccountHolder, BalanceAccount, Transfer
Webhook authHMAC as aboveUsername + password Basic auth header
SplitsVia splits[] on payment requestVia Transfer API

If you're building a marketplace where sub-merchants receive payouts, you need the Balance Platform API and a separate onboarding flow for each sub-merchant. The Payments Platform API alone cannot issue payouts to third-party bank accounts.

Health probe for Adyen MCP servers

Adyen provides an explicit healthcheck endpoint at GET /checkout-test.adyen.com/v71/paymentMethods (POST) — providing your merchant account and a minimal amount returns the available payment methods without creating a payment session. This validates credentials and connectivity in one call.

// Health probe: payment methods endpoint (validates credentials + API reachability)
async function checkAdyenHealth(adyen) {
  const start = Date.now();
  try {
    const result = await adyen.checkout('/paymentMethods', {
      countryCode: 'US',
      shopperLocale: 'en-US',
      amount: { value: 1000, currency: 'USD' },
      channel: 'Web',
    });

    return {
      status: 'healthy',
      paymentMethodCount: result.paymentMethods?.length ?? 0,
      latencyMs: Date.now() - start,
    };
  } catch (err) {
    const isCredentialError = err.message.includes('errorCode');
    return {
      status: 'unhealthy',
      error: err.message,
      isCredentialError,
      latencyMs: Date.now() - start,
    };
  }
}

If the probe returns no payment methods but the request succeeds, the merchant account may not have any payment methods enabled in the Customer Area — a configuration issue distinct from an API connectivity issue. Expose both in your health probe output. See MCP server downtime alerting for how AliveMCP distinguishes configuration errors from infrastructure failures.