Guide · Payment Processing

MCP Tools for Square — location_id scoping, required idempotency keys, OAuth2 Bearer auth, HMAC-SHA256 webhook verification

Square's API design has two architectural constraints that aren't obvious from the docs: every resource is scoped to a location_id — payments, orders, customers, and inventory are all associated with a specific business location, and API calls that omit a valid location_id return a 422 INVALID_REQUEST_ERROR rather than defaulting to any location; and the idempotency_key field is a required parameter on all write operations, not an optional header — Square rejects create calls that omit it with a clear validation error, but sends HTTP 409 IDEMPOTENCY_KEY_REUSED when the same key is reused with different parameters. A health probe that doesn't account for location scoping will fail even with valid credentials because every read of meaningful data requires knowing a location ID first.

TL;DR

Auth: Authorization: Bearer {access_token} — use developer sandbox token or OAuth2 flow. All resource operations require a location_id — fetch one first with GET /v2/locations. All write operations require idempotency_key: crypto.randomUUID() in the request body (not a header — it's a body field). Webhook signature: X-Square-Hmacsha256-Signature: base64(HMAC-SHA256(webhook_signature_key, notification_url + raw_body)). Sandbox base URL: connect.squareupsandbox.com; live: connect.squareup.com. Amounts are always in smallest currency unit (cents for USD) as integers.

Authentication — Bearer token and OAuth2

Square supports two authentication patterns: a developer access token (appropriate for own-account server integrations and testing) and OAuth2 (required when acting on behalf of another merchant's account). Both result in a Bearer token used identically in the Authorization header.

For an MCP server that operates on a single merchant's Square account, use the access token from the developer dashboard directly. For multi-merchant MCP tools, implement OAuth2 to obtain per-merchant tokens. OAuth2 access tokens expire after 30 days; refresh tokens are valid for 30 days after access token expiry.

// Square API client with token management
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 options = {
      method,
      headers: {
        'Authorization': `Bearer ${this.#accessToken}`,
        'Square-Version': '2024-12-18', // pin the API version
        'Content-Type': 'application/json',
      },
    };
    if (body) options.body = JSON.stringify(body);

    const res = await fetch(`${this.#baseUrl}${path}`, options);
    const data = await res.json();

    if (!res.ok) {
      // Square always returns errors in data.errors array
      const err = data.errors?.[0];
      throw new Error(
        `Square ${method} ${path} → ${res.status} ${err?.category}: ${err?.code} — ${err?.detail || ''}`
      );
    }
    return data;
  }
}

// Square-Version header: pin to a specific date to avoid breaking changes
// Current stable version as of mid-2026: 2024-12-18
// Check https://developer.squareup.com/docs/build-basics/api-lifecycle for updates

The Square-Version header pins the API to a specific behavior version (date-versioned). Requests without this header use the version active at your application's creation date. Explicitly pin it and update deliberately — Square may change response shapes between versions.

Location scoping — fetching and caching location_id

Every Square merchant has one or more locations (physical or online stores). All resource operations — creating payments, orders, items, customers, appointments — require a location_id. There is no implicit "default" location in the API. MCP servers must call GET /v2/locations at startup to discover available locations and either let the user specify one or use the first active location.

// Fetch all locations for a merchant
async function getLocations(client) {
  const data = await client.request('GET', '/v2/locations');
  return data.locations.map(loc => ({
    id: loc.id,
    name: loc.name,
    status: loc.status, // 'ACTIVE' | 'INACTIVE'
    type: loc.type,     // 'PHYSICAL' | 'MOBILE'
    currency: loc.currency,
    country: loc.country,
    timezone: loc.timezone,
    capabilities: loc.capabilities, // e.g. ['CREDIT_CARD_PROCESSING']
  }));
}

// Initialize: get and cache the primary location
async function initSquareMcpServer(client) {
  const locations = await getLocations(client);
  const active = locations.filter(l => l.status === 'ACTIVE');

  if (active.length === 0) {
    throw new Error('No active Square locations found — check merchant account setup');
  }

  // If the merchant has multiple locations, surface the choice to the MCP caller
  // For single-location merchants, use active[0].id directly
  return {
    primaryLocationId: active[0].id,
    allLocations: active,
  };
}

// Example: list payments requires location_id
async function listPayments(client, locationId, { beginTime, endTime, limit = 100 } = {}) {
  const params = new URLSearchParams({ location_id: locationId, limit: String(limit) });
  if (beginTime) params.set('begin_time', beginTime); // ISO 8601
  if (endTime) params.set('end_time', endTime);

  const data = await client.request('GET', `/v2/payments?${params}`);
  return {
    payments: data.payments || [],
    cursor: data.cursor, // pagination cursor for next page
  };
}

Common mistake: hardcoding a location ID that works in sandbox but differs in production. Always resolve the location ID dynamically from GET /v2/locations or expose it as a required environment variable. A 422 with INVALID_REQUEST_ERROR code EXPECTED_LOCATION_ID means a location ID is missing; NOT_FOUND code LOCATION_MISMATCH means the provided ID doesn't belong to the authenticated merchant.

Required idempotency_key on all write operations

Square requires an idempotency_key field in the request body for every write operation that creates a resource (payments, orders, refunds, customers). This is a body field, not a header. Square enforces uniqueness: the same key submitted within 24 hours returns the original response; after 24 hours, the same key creates a new resource. Submitting the same key with different parameters (different amount, different items) returns HTTP 409 with IDEMPOTENCY_KEY_REUSED.

// Create a payment — idempotency_key is required in the body
async function createPayment(client, { locationId, amountCents, currency = 'USD', sourceId, note }) {
  const idempotencyKey = crypto.randomUUID();

  // Square amounts are ALWAYS in smallest currency unit (cents for USD)
  // $19.99 → amount_money: { amount: 1999, currency: 'USD' }
  const data = await client.request('POST', '/v2/payments', {
    source_id: sourceId,      // card nonce from Square Web Payments SDK
    idempotency_key: idempotencyKey, // REQUIRED — omitting causes 400 MISSING_REQUIRED_PARAMETER
    amount_money: {
      amount: amountCents,    // integer cents
      currency,
    },
    location_id: locationId,
    note,
  });

  const payment = data.payment;
  return {
    paymentId: payment.id,
    status: payment.status, // 'APPROVED' | 'PENDING' | 'COMPLETED' | 'CANCELED' | 'FAILED'
    amountMoney: payment.amount_money,
    cardDetails: payment.card_details,
    idempotencyKey, // persist alongside payment for safe retries
    receiptUrl: payment.receipt_url,
  };
}

// Create an order (for cart-based flows)
async function createOrder(client, { locationId, lineItems, note }) {
  const data = await client.request('POST', '/v2/orders', {
    order: {
      location_id: locationId,
      line_items: lineItems.map(item => ({
        name: item.name,
        quantity: String(item.quantity), // STRING, not number — "2" not 2
        base_price_money: {
          amount: item.priceCents,
          currency: 'USD',
        },
      })),
      ...(note ? { reference_id: note } : {}),
    },
    idempotency_key: crypto.randomUUID(),
  });
  return data.order;
}

The quantity field in Square order line items is a string, not a number — "2" not 2. This trips up every developer reading the API response and then writing back. Square also enforces the constraint that quantity must be a string representation of a decimal number (e.g., "1.5" is valid for variable-weight items).

Payment status machine

Square payments move through a predictable state machine. The terminal states are COMPLETED, CANCELED, and FAILED. Intermediate states require polling or webhook subscriptions:

Webhook verification — HMAC-SHA256 over notification URL + body

Square signs webhooks using HMAC-SHA256 over the concatenation of the notification URL and the raw request body. The signature is in the X-Square-Hmacsha256-Signature header as a base64-encoded string. The signing key is the webhook signature key from your subscription configuration in the developer dashboard — not your access token and not your application secret.

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

function verifySquareWebhook(req, rawBody, webhookSignatureKey, notificationUrl) {
  const receivedSig = req.headers['x-square-hmacsha256-signature'];
  if (!receivedSig) return false;

  // Square HMAC input: notification_url + raw_body (concatenated, no separator)
  // notification_url is the FULL URL of your webhook endpoint, including https://
  const payload = notificationUrl + rawBody.toString('utf8');

  const expectedSig = createHmac('sha256', webhookSignatureKey)
    .update(payload)
    .digest('base64');

  // Timing-safe comparison to prevent timing attacks
  const received = Buffer.from(receivedSig, 'base64');
  const expected = Buffer.from(expectedSig, 'base64');

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

// Fastify handler example
fastify.post('/webhooks/square',
  { config: { rawBody: true } }, // ensure raw body is available
  async (request, reply) => {
    const isValid = verifySquareWebhook(
      request.raw,
      request.rawBody,
      process.env.SQUARE_WEBHOOK_SIGNATURE_KEY,
      process.env.SQUARE_WEBHOOK_URL // full URL: https://yourserver.com/webhooks/square
    );

    if (!isValid) {
      return reply.status(403).send({ error: 'Invalid signature' });
    }

    const event = request.body;
    // event.type: 'payment.created' | 'payment.updated' | 'order.created' | etc.
    await handleSquareEvent(event);
    reply.send({ success: true });
  }
);

The notification URL used in signature verification must be the exact URL Square sends webhooks to — including protocol, host, path, and any query parameters. A mismatch between the URL you use in verification and the URL Square knows about causes all signature checks to fail silently.

Key Square webhook event types

Catalog API — items, variations, and inventory

Square's product catalog (items, item variations, categories, discounts) is managed through the Catalog API, which is location-aware but not location-scoped — catalog objects are merchant-wide, while inventory counts are location-specific. MCP tools that expose product management need to understand this distinction.

// Search catalog items
async function searchCatalogItems(client, { textFilter, locationIds, limit = 50 }) {
  const data = await client.request('POST', '/v2/catalog/search', {
    object_types: ['ITEM'],
    include_related_objects: true,
    query: {
      text_query: textFilter ? { keywords: [textFilter] } : undefined,
      item_query: locationIds
        ? { location_ids: locationIds }
        : undefined,
    },
    limit,
  });

  return data.objects?.map(obj => ({
    id: obj.id,
    name: obj.item_data?.name,
    description: obj.item_data?.description,
    variations: obj.item_data?.variations?.map(v => ({
      id: v.id,
      name: v.item_variation_data?.name,
      priceMoney: v.item_variation_data?.price_money,
      sku: v.item_variation_data?.sku,
    })),
  })) ?? [];
}

// Get inventory count for an item variation at a location
async function getInventoryCount(client, catalogObjectId, locationId) {
  const data = await client.request(
    'GET',
    `/v2/inventory/${catalogObjectId}?location_ids=${locationId}`
  );
  return data.counts?.map(c => ({
    locationId: c.location_id,
    quantity: c.quantity, // string: "10.000"
    state: c.state,       // 'IN_STOCK' | 'SOLD' | 'RETURNED_BY_CUSTOMER' | etc.
    calculatedAt: c.calculated_at,
  })) ?? [];
}

Sandbox environment and test cards

Square's sandbox mirrors the production API at connect.squareupsandbox.com. Test payments use hardcoded card nonces — cnon:card-nonce-ok produces a successful payment, cnon:card-nonce-declined produces a declined payment, and cnon:card-nonce-avs-no-match triggers an AVS mismatch failure. These nonces replace real card data in tests and are accepted only in the sandbox environment.

Test nonceResultNotes
cnon:card-nonce-okCOMPLETEDStandard success
cnon:card-nonce-declinedFAILEDCard declined by issuer
cnon:card-nonce-avs-no-matchFAILEDAddress verification failure
cnon:card-nonce-requires-cvvFAILEDCVV required but missing
cnon:card-nonce-delay-1mPENDING for 60sTests async payment polling

Health probe design for Square MCP servers

The recommended health probe for Square MCP servers is GET /v2/locations — it validates credentials, confirms API reachability, and returns the location IDs your server needs to operate. It is read-only and does not create any resources or billable events.

// Health probe: locations endpoint (validates credentials + returns location IDs)
async function checkSquareHealth(client) {
  const start = Date.now();
  try {
    const data = await client.request('GET', '/v2/locations');
    const activeLocations = (data.locations || []).filter(l => l.status === 'ACTIVE');
    return {
      status: activeLocations.length > 0 ? 'healthy' : 'degraded',
      reason: activeLocations.length === 0 ? 'No active locations found' : undefined,
      activeLocationCount: activeLocations.length,
      latencyMs: Date.now() - start,
    };
  } catch (err) {
    return {
      status: 'unhealthy',
      error: err.message,
      latencyMs: Date.now() - start,
    };
  }
}

A 401 from /v2/locations means invalid or expired access token. A 403 means the token is valid but missing the MERCHANT_PROFILE_READ OAuth permission. Distinguish these in your health probe output — a permission error requires OAuth re-authorization, while a 401 may just require token refresh. See MCP server health check patterns for details on surfacing these distinctions through AliveMCP's monitoring.