Square Onboarding (Phase 1 — seller connect via OAuth)
Schools (sellers) connect their own Square accounts to the platform. Each school gets its own per-seller OAuth tokens; later phases use those tokens to charge parents so funds land directly in the school's Square balance.
This is the Square equivalent of the PayPal onboarding in ../ppp, built in the
same style (plain PHP, a lib/ client, a config.php array, JSON + DB storage,
the host-app session/permission gate). The key difference: Square issues a
per-seller access token + refresh token that we store (encrypted), whereas the
PayPal partner model needed no stored seller tokens.
Files
| File | Role |
|---|---|
config.php |
Credentials, scopes, redirect URI, API version, encryption key |
db_config.php |
No longer used. All persistence goes through the host app's procedural db_* layer (../include/database.php) — the same one the saving/ API uses — so the real credentials live in the host app, not here |
onboard.php |
The flow: connect action → Square authorize → OAuth callback → token exchange → storage + readiness check. Plus check_status / disconnect actions and a ?debug=1 endpoint |
lib/SquareClient.php |
cURL client: authorizeUrl, obtainToken, refreshToken, revokeToken, api, getSellerStatus |
lib/MerchantStore.php |
Single source of truth for reading/writing connections, encryption, token refresh (ensureFreshToken) and revocation (markAuthorizationRevoked). Shared by the web flow, the cron job, and the webhook |
lib/Secret.php |
AES-256-GCM encryption of tokens at rest |
lib/Storage.php |
Generic JSON store (carried over from PayPal; unused by the Square flow now everything is in the DB) |
helper.php |
getMerchantId, getValidAccessToken (auto-refresh), getAllMerchants — what Phase 4 payments will call |
refresh_tokens.php |
Phase 2 CLI cron job — refreshes tokens before they expire |
webhook.php |
Phase 2 Square webhook handler — verifies the HMAC signature and handles oauth.authorization.revoked |
square-checkout-widget.php |
Phase 3 drop-in "Pay with Square" widget — loads the Web Payments SDK, renders the card form, tokenizes, POSTs the token |
order-api.php |
Phase 3/4 public endpoint — validates the token, looks up the school's seller token, and charges via CreatePayment (with optional app_fee_money) |
demo-checkout.php |
Phase 3 standalone test page that embeds the widget |
schema.sql |
square_* columns on schools + the square_payments and square_pending_onboarding tables (run once) |
data/ |
Legacy JSON dir — no longer written or read by the Square flow (everything is now in the DB) |
Setup (Phase 0 is assumed done)
-
Fill
config.php:client_id/client_secret— from Developer Dashboard → your app → OAuth (use the Sandbox pair whileenv => 'sandbox').redirect_uri— set this to the EXACT URL registered as the app's "Redirect URL" in the dashboard (e.g.https://app.hotlunches.net/square/onboard.php). If you leave it blank it falls back to the auto-detectedbase_url + /onboard.php, which must still match the dashboard.token_encryption_key— generate once:Paste the 64-hex-char result (or set thephp -r "echo bin2hex(random_bytes(32));"SQUARE_TOKEN_KEYenv var). Without it, tokens are stored in plaintext (a warning is logged).
-
Register the Redirect URL in the dashboard to match
redirect_uriexactly. -
Run the migration:
mysql hotlunch < schema.sql -
Verify: open
onboard.php?debug=1— confirmclient_id set: yes,token_encryption_key: set,dataDir is_writable: yes, and that the effective redirect URI matches the dashboard.
Flow
School admin opens onboard.php (must be logged in: SuperUser / System Administrator)
→ enters school name, clicks "Connect Square Account"
→ POST action=connect: we store a pending record keyed by a random `state`,
and return Square's authorize URL
→ browser redirects to Square; the school logs into THEIR Square account and approves
→ Square redirects back to the registered Redirect URL with ?code=...&state=...
→ onboard.php validates `state`, exchanges `code` for tokens (obtainToken),
encrypts + stores them (DB + JSON), then checks readiness (getSellerStatus)
→ success page
Security notes
- Tokens are encrypted at rest (AES-256-GCM). The
?debug=1dump and thehelper.phpJSON output both redact them. - The callback is not behind the login gate (it is a top-level return from
squareup.com with no app session); it is authenticated by the random
statematched against the server-side pending store (CSRF protection). data/*.jsonis blocked from direct web access via.htaccess.
Phase 2 — Token maintenance (done)
Refresh job — refresh_tokens.php (CLI only):
php refresh_tokens.php # refresh tokens within 5 days of expiry
php refresh_tokens.php --force # refresh ALL connected schools now
php refresh_tokens.php --dry-run # report only, change nothing
php refresh_tokens.php --days=7 # custom expiry window
php refresh_tokens.php --school="Maple School"
Square recommends renewing access tokens every 7 days or less, so the
recommended cron is a weekly --force sweep, with an optional daily threshold
sweep as a safety net:
30 3 * * 0 /usr/bin/php /path/to/square/refresh_tokens.php --force >> /var/log/square_refresh.log 2>&1
15 3 * * * /usr/bin/php /path/to/square/refresh_tokens.php --days=7 >> /var/log/square_refresh.log 2>&1
The web side also self-heals: helper.php::getValidAccessToken() refreshes
lazily whenever a token is used near expiry. Both paths go through
MerchantStore::ensureFreshToken(), so the behavior is identical.
Disconnect / revoke:
- We initiate (admin clicks disconnect):
onboard.phpPOSTaction=disconnect→ revokes the token at Square, then clears our record. - School initiates (removes the app on Square's side): Square sends the
oauth.authorization.revokedwebhook →webhook.phpverifies the HMAC signature and callsmarkAuthorizationRevoked(), which nulls the tokens and flags the school so payments stop and staff know a reconnect is required.
Webhook setup: Dashboard → your App → Webhooks → add subscription pointing at
webhook.php, subscribe to oauth.authorization.revoked, then set
webhook_signature_key and webhook_url (the exact Notification URL) in
config.php. The signature is HMAC-SHA256 over webhook_url + rawBody.
Phase 3 — The "Pay with Square" button (done)
A drop-in widget renders a card form on a school's page. The browser tokenizes
the card with Square's Web Payments SDK (card data never reaches our server) and
POSTs the resulting single-use source_id to order-api.php, which validates it.
Embed it (server-side include, same pattern as the PayPal widget):
$sqCheckout = [
'school_name' => 'Maple School', // a Square-connected school
'amount' => '25.00',
'currency' => 'CAD',
'order_api_url' => '/square/order-api.php',
'success_url' => '/payment-success',
];
require_once '/path/to/square/square-checkout-widget.php';
The widget resolves server-side:
- appId =
web_payments_app_idorclient_id(the Application ID — public, safe in the browser). - locationId = the school's active Square location (cached from onboarding, or fetched live and cached).
- If the school isn't connected / ready, it renders a muted notice and stops.
Flow: SDK loads → card form attaches → buyer clicks Pay → card.tokenize()
→ source_id → POST {action, source_id, amount, currency, school_name, idempotency_key} → order-api.php validates (amount > 0, currency, school
connected + ready, location resolvable) and returns { success:true, charged:false, stage:"validated" }. The widget shows a confirmation.
Phase boundary: order-api.php deliberately stops at validation — it does
not move money. The exact spot where Phase 4 will call CreatePayment (with
the seller's token + app_fee_money) is marked PHASE 4 INSERTION POINT. When
Phase 4 returns charged:true + payment_id, the widget auto-redirects to
success_url.
Test it: open demo-checkout.php (auto-picks a connected school). In sandbox
use Square's test card 4111 1111 1111 1111, any future expiry, any CVV, postal
K1A 0B1.
Phase 4 — Charging (done)
order-api.php now charges. The handler sq_handle_create_payment():
- Validates the request + that the school is connected, ready, and has a location.
- Looks up the school's seller access token via
MerchantStore::ensureFreshToken()(auto-refreshing if near expiry). This token is what routes the money into the school's account. - Calls
SquareClient::createPayment($token, …)→POST /v2/paymentswithsource_id,idempotency_key,amount_money(integer cents, CAD),location_id,autocomplete:true, and optionalbuyer_email_address/verification_token. - On
COMPLETED/APPROVED: records the payment todata/square_payments.json(ids/amounts only, never card data) and returns{ success:true, charged:true, payment_id }→ the widget redirects tosuccess_url. - On failure: logs the real Square error, returns a parent-friendly message (card-method errors pass through Square's detail; anything else stays generic so internals aren't leaked).
Platform fee (your cut) — config.php → platform_fee (default off):
'platform_fee' => ['enabled' => true, 'percentage' => 2.0, 'fixed_cents' => 30],
fee = round(amount * percentage/100) + fixed_cents, capped at 90% of the amount
(Square's limit), added as app_fee_money so it settles to YOUR account.
⚠️ To enable fees you must also: (a) add the PAYMENTS_WRITE_ADDITIONAL_RECIPIENTS
scope in config.php, (b) have every seller re-authorize (new scope), and
(c) be approved by Square to take app fees. Without (a)+(b) Square rejects the
app_fee_money payment. The base charge (no fee) needs none of this.
Kill switch: config.php → 'payments_enabled' => false makes order-api.php
validate but not charge (returns stage:"validated") — deploy the frontend
before going live.
Idempotency: every charge sends an idempotency_key (the widget generates a
UUID per attempt), so a network retry can't double-charge.
Phase 5 — Embeddable button on the school's OWN website (done)
Phases 3–4 rendered the button via a server-side PHP require, which only works
on pages this app renders. Phase 5 lets a school drop the button onto their own
external website (WordPress/Wix/custom HTML) with one script tag.
Per-school public key. Each connected school gets a stable, unguessable
merchant_key (sqpk_…), generated lazily by MerchantStore::merchantKey() and
stored in schools.square_merchant_key. The embed references this key, never the
school name or any token.
The snippet (shown on the onboarding success page, copy-paste ready):
<script src="https://app.hotlunches.net/square/embed.js"
data-square-key="sqpk_xxxxxxxxxxxxxxxx"
data-amount="25.00"
data-currency="CAD"
async></script>
Flow: embed.js reads its own data-* + base URL → injects an <iframe>
to pay-frame.php?key=…&amount=… → pay-frame.php resolves the school by key
and renders the existing Phase 3 widget inside the frame → the frame auto-reports
its height back to embed.js (via postMessage) so it sizes with no scrollbars.
Card tokenization still happens inside Square's SDK; card data never touches the
school's site or ours. The charge POST to order-api.php now carries
merchant_key, which order-api.php resolves to the school server-side.
| File | Role |
|---|---|
embed.js |
The one script tag a school pastes; injects the iframe, auto-resizes it |
pay-frame.php |
Iframe target; resolves the key and renders the widget |
demo-embed.php |
A fake "school website" that uses the snippet — end-to-end local test |
CORS / origin security. order-api.php reflects the requesting Origin so
cross-site fetch works, and answers the OPTIONS preflight. Each school can
register the domain(s) their button lives on (allowed_origins in the merchant
record, via MerchantStore::addAllowedOrigin()); set
config.php → 'enforce_embed_origins' => true to then block charges from any
other site (anti-impersonation). When enabled, pay-frame.php also emits a CSP
frame-ancestors restricting who may frame it. Leave it off until every
connected school has registered its origin, or legitimate buttons get blocked.
Test it locally: php -S 127.0.0.1:8000, open demo-embed.php (auto-picks a
connected school, prints the live snippet). Sandbox test card 4111 1111 1111 1111.
Not in this phase
- Refunds (
RefundPayment) — straightforward to add toorder-api.phpnext. - Buyer SCA / 3DS verification (
payments.verifyBuyer→verification_token) — the backend already forwards the token if the frontend sends it; the frontend call is the remaining piece. - Webhook events
payment.updated/refund.updated— Phase 5.