← Back to blog

May 20, 2026 · 8 min read

Why I Didn't Just Use Square's Official Plugin

Square publishes a perfectly capable WooCommerce gateway. I wrote a different one anyway, because the kitchen iPad needs orders in a specific shape and I'd rather not ask restaurant owners to type Square credentials in two places.

In production at Ramen Nagomi and Padi Los Angeles.

Square publishes their own WooCommerce plugin. It’s a perfectly capable general-purpose gateway. When RestoPick Pro needed Square payments at checkout, the reasonable thing would have been to point everyone at Square’s plugin and move on.

I wrote a different one. Three reasons, roughly in order of how much they mattered.

Same disclaimer as the Pro write-up: this gateway isn’t for sale either. It runs on the two sites I maintain, and that’s the whole install base.

1. The kitchen iPad cares about shape

Pro defines a Square Order builder that formats line items, taxes, tips, and the PICKUP fulfillment in a specific shape: modifiers like “no onions” joined into line_item.note, WC fees mapped to service charges, tips routed through Payment.tip_money instead of the generic bucket. That shape is what makes a WC pickup order look like a walk-in order on the kitchen iPad, down to the modifier formatting and where the tip sits.

The official Square plugin uses its own shape. Fine for general e-commerce, subtly different on the POS side. For a restaurant that’s been training kitchen staff on one format for months, “subtly different” is a feature regression nobody asked for.

This gateway reuses Pro’s builder. Every Square Order it creates from a WC sale lands in POS looking identical to walk-in orders. The kitchen only ever sees one format.

2. Two places to keep credentials is one too many

Pro already has a Square Connection sub-page where the restaurant enters Application ID, Access Token, and Location ID for sandbox and production. The gateway needs the same values at checkout time.

The lazy option is to make the gateway ask for them again. Two settings pages, two sets of fields, an admin trying to remember which one to update when Square rotates an access token. I’ve watched that drift on other plugins enough times to know how it ends.

So this gateway just reads from Pro’s Square Connection. One place to enter credentials, one place to switch between sandbox and production. The gateway’s own settings page only carries what’s actually gateway-specific: the master enable toggle, the title and description shown at checkout, per-method toggles for Card, Google Pay, and Apple Pay, and the Apple Pay domain verification button.

3. A smaller surface is easier to keep working

The official gateway supports many use cases I don’t need. A focused gateway that only does the few things a restaurant actually uses ships faster, breaks less, and is something I can confidently maintain alone.

What customers see at checkout

Three methods, each with an admin toggle:

  • A credit card form rendered as a Square-hosted iframe. Card data never touches the WordPress server; the iframe tokenises it locally and the WP side only ever sees an opaque source_id.
  • A Google Pay button in browsers that support it. Hides itself elsewhere, which I shipped as a feature and later had to walk back. More on that below.
  • An Apple Pay button in Safari on iOS/macOS, once the site’s domain has been verified with Square.

If no method is enabled the gateway hides itself entirely. No greyed-out stub.

The card flow, and what 3-D Secure taught me

Card payments go through two browser steps before the server sees anything sensitive:

  1. card.tokenize() turns the card details into a single-use source_id inside Square’s iframe.
  2. payments.verifyBuyer() runs 3-D Secure and SCA against the buyer with the live cart amount, returning a verification_token on success.

Both get written to hidden inputs and posted with the checkout form. The server reads them, builds the Square Order via Pro’s builder, then creates the Payment:

$order_body = Restopick_Pro_Square_POS::build_order_body(
    $order,
    $creds['location_id'],
    $wc_currency,
    true
);

$created = $client->create_order(
    $order_body,
    'restopick-sq-pay-order-' . $order->get_id()
);

The idempotency key is derived from the WooCommerce order id, so a customer who double-clicks Place Order or whose connection drops mid-submit doesn’t get charged twice. Square’s idempotency layer returns the already-created order instead of making a new one. Same idea on the Payment step.

The thing I underestimated was how many failure modes 3-D Secure has on the buyer side. Challenge failed, challenge cancelled, browser blocked the popup, card not enrolled. Each one looks identical at the UI layer if you don’t handle it explicitly. The gateway surfaces a clear notice for each, and writes the Square error payload to the order admin so I can debug from the order page without DMing the merchant for screenshots.

Google Pay, Apple Pay, and the Apple Pay domain dance

Wallets share the same server code path as card. They skip verifyBuyer because the wallet itself authenticates the buyer (FaceID, TouchID, device PIN), so the server just notices which method was used and conditionally omits verification_token.

When the payment settles, Square returns wallet_details describing which wallet was used and the underlying card. The gateway writes both into the WC order note, e.g. “Apple Pay (Visa ****1111)”, so the merchant sees at a glance what was used.

Apple Pay on the web requires Square to register your domain with Apple before the button can render. The flow:

  1. Download apple-developer-merchantid-domain-association from the Square Developer Dashboard.
  2. Drop it into /.well-known/ on your site root.
  3. Click “Verify domain” in the gateway settings.
  4. The plugin posts to Square’s /v2/apple-pay/domains with the current host; Square fetches the well-known file and (if it matches) marks the domain verified.

Sandbox and production are tracked separately. The settings page shows a warning pill if the verified domain doesn’t match the current site host, so a migration or domain change doesn’t silently break Apple Pay weeks later. That pill exists because I hit all three of those edges during testing: a typo, a stale sandbox verification carried into production, and a CDN rewriting the .well-known response. None of them were interesting on their own, but the pill catches all of them.

The wallet buttons that vanished

Both wallet buttons are conditional. Google Pay only renders where the browser supports it; Apple Pay only in Safari on a verified domain. I shipped that as graceful degradation: if the wallet can’t work, don’t show a broken button.

What I actually shipped was a checkout where a payment method sometimes silently did not exist, with no way for anyone to find out why.

The failure modes are not exotic. Browser doesn’t support the wallet. Buyer has no card in their Wallet. Domain verification doesn’t match the current host. Safari that can’t do Apple Pay. Every one of those produces the same observable result: empty space where a button should be. A restaurant owner testing their own checkout sees nothing, and the only honest answer I could give them was “it works on mine”.

1.2.1 fixed it in the least clever way available. The mount path now logs a specific diagnostic for each failure reason to the console, and Apple Pay pre-checks window.ApplePaySession and ApplePaySession.canMakePayments() before calling payments.applePay() at all, so unsupported browsers skip the mount cleanly instead of failing inside Square’s SDK.

No user-visible change. It just meant that the next time someone asked why the button was missing, the answer took thirty seconds instead of an afternoon.

Refunds

Standard WooCommerce admin refund flow. Open the order, click Refund, enter an amount, choose this gateway. The plugin calls Square’s /v2/refunds with the captured payment_id, and both full and partial refunds are supported. Refund IDs are stored on the order meta so subsequent refunds against the same payment don’t collide on idempotency keys.

The mode (sandbox or production) is stamped on the order at payment time, and the refund flow resolves credentials by that stamp rather than whatever mode Pro’s Square Connection currently has selected. So an order paid in sandbox six months ago still refunds against sandbox credentials, even if the connection has been flipped to production since.

No webhook listener (yet). Refund status updates synchronously when the admin triggers one. For restaurants this is fine, since refunds are almost always operator-initiated. If you need out-of-band reconciliation (chargebacks, automated disputes), Square’s official gateway is the better fit for that workflow.

What I haven’t built

Some of this is deliberate scoping and some is just unfinished, so it’s worth separating the two:

  • No Cash App Pay. Unfinished. No reason beyond not having got to it.
  • No saved cards / Card-on-File. Deliberate. Restaurant checkout is overwhelmingly guest checkout, and I’d rather not own that data path if I don’t have to.
  • No webhooks. Unfinished. See the refund note above.

The short version

The gateway plus Pro produces exactly one Square Order per WooCommerce sale, in the same shape Pro uses everywhere else. Card, Google Pay, and Apple Pay all converge on one flow I can debug from the order page.

For the two sites running it, that was the entire goal: payments and kitchen workflow on one platform, and one place to look when something goes wrong.