Development

Implementing Square API Using Best Practices

This fall we built online ticketing for a small independent screening room, and the tickets are paid for through Square. Our last post covered how the site keeps two people from buying the same seat. This one covers the payment side: how we connected Square, what we tested before the first customer paid, and the habits that keep a payment integration trustworthy after it goes live.

The first decision was to keep card numbers away from our own code entirely. Square’s Web Payments SDK puts Square’s card form on the checkout page, and the browser sends the card details straight to Square, which hands back a one-time token. Our server only ever sees that token and uses it to ask Square for the charge. The site never sees or stores a card number, so the most sensitive data in the whole system never touches anything we run.

Square gives every app two separate environments, sandbox and production, and each has its own access token, its own location and its own webhook signature key. We built everything against the sandbox first. The settings that differ between the two live together in the site’s configuration, and the token and signing key are stored as encrypted Worker secrets, so switching environments is a single deploy that changes all of them in the same version. That rules out a half-switched site running production settings with a sandbox token.

The timing of the switch mattered too. The sandbox accepts Square’s published test card numbers, and anyone can look those up. So the night the site moved to its permanent domain, we moved it to production in the same session, because a public ticket page still in sandbox mode would have handed out a free ticket to anyone who typed in a test card.

Every charge carries an idempotency key, which our last post covered, so a retried request can’t charge the card twice. The harder case is when a request goes out and no clear answer comes back, such as a network error or a server error from Square. At that point we don’t know whether the card was charged. The checkout retries once with the same idempotency key, and Square answers with the result of the original attempt instead of charging again. If the outcome is still unknown, the buyer sees a message asking them not to pay again, and the case is logged for a person to check. We also allow only one charge in flight per seat hold, so a buyer who sees an error and reaches for a second card is stopped until the first attempt has a definite answer.

Refunds come back to the site through Square’s webhooks, and every webhook is checked before the site believes it. Square signs each notification with an HMAC-SHA256 of the notification URL followed by the raw request body, using a signing key only Square and our server know. The check runs on the raw body before anything parses it:

// Square signs each webhook as base64(HMAC-SHA256(key, url + body)).
export async function verifySquareSignature(
  signatureKey: string, notificationUrl: string,
  rawBody: string, signature: string | null,
) {
  if (!signature || !signatureKey) return false;
  const enc = new TextEncoder();
  const key = await crypto.subtle.importKey('raw', enc.encode(signatureKey),
    { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
  const mac = new Uint8Array(await crypto.subtle.sign(
    'HMAC', key, enc.encode(notificationUrl + rawBody)));
  const expected = btoa(String.fromCharCode(...mac));
  // constant-time comparison, trimmed for space
  return timingSafeEqual(expected, signature);
}

The URL in that calculation has to match the one registered with Square exactly. When a site moves to a new domain, the webhook subscription needs the new address too, or every notification fails the check. A request that fails gets a 401 and changes nothing.

Square retries a webhook that doesn’t get a success response, and a single refund can arrive as two events, one when it’s created and another when it updates. So the site records every event ID before acting on it, and a repeat gets a 200 response and is ignored. Refund IDs are recorded the same way, so the same refund is never counted twice. If processing fails partway through, the site forgets that it saw the event, so Square’s next retry runs it again from the start:

if (!(await d.firstSighting(ev.event_id))) {
  return { status: 200, note: 'duplicate' };
}

The webhook handler also catches a rarer problem. If Square reports a completed payment that has no order behind it, which could only happen if saving the order failed after the charge, that gets logged loudly so a person can sort it out.

All of this is tested at two levels. The payment and webhook code takes its outside connections as parameters, so the unit tests can hand it a fake Square and a fake database and walk through the awkward cases on demand: a declined card, a timeout with an unknown outcome, a webhook delivered twice, a refund announced twice, a hold that expires in the middle of a charge. The project has a couple hundred unit tests in all. The second level is an end-to-end purchase: a script opens the live ticket page in a browser, picks a free seat, enters Square’s sandbox test card and pays. Refunding that order from the Square dashboard proves the other half, because the webhook should void the ticket and put the seat back on sale with nobody touching the database.

That purchase script is also how the testing continues after launch. Before it does anything, it asks the site which Square environment it’s running in, and it refuses to run against production unless someone explicitly tells it to:

const health = await (await fetch(`${BASE}/health`)).json();
if (health.square !== 'sandbox' && !process.env.REAL) {
  console.log(`Square is "${health.square}". Sandbox only unless REAL=1.`);
  process.exit(1);
}

During a test run two days before the first showing, we ran it once on purpose against the live site with an actual card typed in by hand instead of a test number, then refunded that purchase from the dashboard. That refund caught something the sandbox hadn’t. Square keeps a card refund PENDING until the card network settles it, which can take hours, and the site had been waiting for COMPLETED before putting the seat back on sale. We changed it that night to count a refund as soon as Square accepts it, so the seat goes back on the live map when the webhook arrives. Since then, the same sandbox purchase can be rerun after any change to the checkout, from a seat click to a refunded ticket, with a guard that keeps a test from ever charging a customer’s card.

If you’re adding Square or another payment processor to your site, or you have a checkout that has never been tested end to end, this is the kind of work we do at Kirby Web Works. We’re glad to look at what you have and help you get it right.