Skip to content

Case study

Zeepler

A JSON-to-ZPL label API with accounts, API keys, usage metering and Square billing, run in two AWS regions.

Client
Bates Solutions
Dates
2025 to now
Live
zeepler.com
merged PRs
207
AWS regions, each customer's account and usage in one
2
junk sign-ups found and removed
3,073

The problem

Thermal label printers in hospitals, warehouses and evidence rooms speak ZPL, Zebra’s printer language: a stream of caret commands with coordinates in printer dots. Every team that prints labels from its own software ends up hand-writing ZPL strings, and the bugs only show up on paper.

Zeepler takes the label as JSON (a size, the data and where each field goes) and returns ZPL ready to send to the printer. It’s a Bates Solutions product: an API with accounts, keys, metering and billing, so it has to be run like a business, not a script.

My role

I designed and built all of it, alone: the API and its contract, the Next.js site and dashboard, the AWS infrastructure in Terraform, billing with Square, and running it in production.

What I built

  • The generator. Text, QR codes and Code 128, Code 93 and EAN-13 barcodes, placed on 3×1 and 4×2 inch labels at 203 dpi. Values containing ZPL’s control characters are hex-escaped, so a name on a label can’t close its field and run printer commands.
  • Accounts and keys. Self-service sign-up through Cognito. API keys are random, shown once, and stored only as a SHA-256 hash. Each key has its own rate limit and each account a spending limit.
  • Metering and billing. Every generated label writes a usage row, and a rejected request is never billed. On the 1st of each month a scheduled job bills the previous month through Square, charging a saved card or sending an invoice, with every call keyed for idempotency. A signature-checked webhook marks invoices paid, and an hourly poller is the backstop.
  • Two regions. Customers choose the US or Canada at sign-up. Their Cognito account, API keys, usage and billing records live only in that region, and nothing is replicated. Square, Cloudflare Turnstile and the site’s CDN are shared.
  • Operations. Terraform owns production, and CI deploys on merge through GitHub OIDC, Canada first as a canary. The site is checked against WCAG 2.1 AA with axe in a real browser on every change. Nothing from a request body is logged, because labels carry names and case numbers.

Architecture

The site is a static Next.js export on S3 and CloudFront, shared by both regions. Each region is a copy of one Terraform module: its own API hostname, Cognito pool, Lambdas, DynamoDB tables, mail identity and alarms. There’s no geo-routing: a key belongs to one region, and the other region answers it with a 401.

Zeepler architectureCustomer software calls one of two regional APIs, US (api.zeepler.com) or Canada (api.ca.zeepler.com), chosen at sign-up; the static site on zeepler.com is shared. Each region has its own HTTP API with a Cognito JWT authorizer, a Cognito pool whose sign-up is gated by Turnstile, Lambdas for label generation, keys and billing, and DynamoDB tables for users, keys, usage and billing. Both regions bill through one Square account in Canadian dollars.Customer softwareJSON label in, ZPL outzeepler.comstatic Next.js · S3 + CloudFront · sharedus-east-1 · api.zeepler.comHTTP APICognito JWT · per-route throttlesCognitoTurnstile-gated sign-upLambdaslabels · keys · billingDynamoDBusers · keys · usage · billingca-central-1 · api.ca.zeepler.comHTTP APICognito JWT · per-route throttlesCognitoTurnstile-gated sign-upLambdaslabels · keys · billingDynamoDBusers · keys · usage · billingSquare (CAD)monthly invoices · webhooksone region per customer, chosen at sign-up
One shared site, two self-contained regions, one Square account. A customer's account, keys, usage and billing records stay in their region.

The hard part

The sign-ups that weren’t customers

An AWS account audit in August 2026 showed 3,082 users in Zeepler’s user pool and 15 API requests in 30 days. Those two numbers can’t both be real. They weren’t: 3,073 of the users were unconfirmed junk.

From December 2025 until it stopped in early August, a bot had called Cognito’s public sign-up API directly, about once every 33 minutes, around the clock, with no bursts. It never touched the site or the API, so nothing that watched traffic would have seen it, and nothing was watching.

The accounts were harmless. The mail wasn’t. Every sign-up made Cognito send a confirmation code to a scraped address, and the bounces and complaints counted against the AWS account’s email reputation, which every product on the account shares. The complaint rate had reached 0.13%, over Amazon’s 0.1% limit.

The fix ran in order:

  1. Closed self-registration, and replaced the sign-up form with a waitlist that never mails the address it’s given.
  2. Turned on the account’s bounce and complaint suppression list, and added alarms for bounces, complaints, mail volume and sign-ups.
  3. Backed up all 3,082 users, deleted the 3,073 junk ones, and confirmed no orphaned rows were left behind.
  4. Reopened sign-up behind a Cognito pre-sign-up check that verifies a Cloudflare Turnstile token and fails closed.

The last step is the one that matters. The browser calls Cognito directly, so there’s no API Gateway in front of sign-up to put a check in. The pre-sign-up trigger is the one place that runs before Cognito sends any mail. A rate limit wouldn’t have helped either: one sign-up every half hour is far below any threshold. The sign-up alarm catches bursts, and its own comment says it wouldn’t have caught this one, which is why the gate had to be the real fix.

Outcome

  • Billing proven with real money, not a sandbox: on 13 September 2026 the monthly job raised a $0.01 Square invoice, it was paid, and the webhook marked it paid three seconds later.
  • Canada went live in September 2026. A key from a Canadian sign-up returns ZPL from the Canadian API and a 401 from the US one.
  • Sign-up is open again behind the Turnstile gate, with the account’s mail reputation protected.
  • Zeepler is in production and deliberately not marketed yet, while the last plans before launch are finished.

Tell me what you're building