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:
APPROVED→ card authorized but not yet settled (typical for card-present)PENDING→ processing in progressCOMPLETED→ payment captured and settledCANCELED→ voided before captureFAILED→ declined or error — checkpayment.card_details.errors[]
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
payment.created— payment object createdpayment.updated— status changed (APPROVED → COMPLETED, etc.)order.created/order.updated— order lifecyclerefund.created/refund.updated— refund status changescustomer.created/customer.updated— CRM events
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 nonce | Result | Notes |
|---|---|---|
cnon:card-nonce-ok | COMPLETED | Standard success |
cnon:card-nonce-declined | FAILED | Card declined by issuer |
cnon:card-nonce-avs-no-match | FAILED | Address verification failure |
cnon:card-nonce-requires-cvv | FAILED | CVV required but missing |
cnon:card-nonce-delay-1m | PENDING for 60s | Tests 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.