
Shipping Label Platform
Discounted-USPS-label marketplace on Cloudflare's edge.
The setup
The platform is a single Cloudflare Worker serving a Next.js 16 App Router front end with Payload CMS 3 as the backend. The whole bundle compiles to Workers through @opennextjs/cloudflare, runs against D1 for relational data, R2 for label PDFs, and Analytics Engine for event telemetry. No Postgres, no Redis, no S3, no separate API service. The thing I keep coming back to is how little infrastructure is involved — the data plane and the runtime are the same vendor, and that means the latency between “Server Action fires” and “row lands” is mostly compiler-bound, not network-bound.
The piece that took the longest to get right
Every label sale has to keep three things in lockstep: the Labels row (what the buyer asked for), the Transactions ledger (immutable record of money moving), and the user’s metric fields — currentBalance, totalSpend, totalLabels, referralBalance, and a few more. The first version had that logic in the Server Action. It worked, until I added an admin Server Action that could also create a label, and immediately the rules diverged. Same bug, twice, in two places.
The fix was moving the rules into Payload collection hooks and letting the Server Actions go back to being thin entry points whose only job is to write a row. The hook fires no matter who created the label — admin UI, customer flow, future API integration — and the rules can’t drift because there’s only one copy of them.
There are three of these hooks worth describing.
On create, charge the buyer. When a Labels row is inserted, an afterChange hook looks up the price from the Weights collection by shipping type and weight range, multiplies by recipient count, writes a Transactions row of type charge, deltas the user’s counters through a shared utility, and logs one Analytics Engine event per recipient plus one for the transaction. A single Server Action call — “create this label” — fans out into a label row, a transaction row, three counter deltas, and N+1 analytics events. The Server Action knows none of this and shouldn’t.
On awaiting, credit the referrer. Commission isn’t paid on purchase, it’s paid on the first delivery handoff. When a label transitions to status: "awaiting", a hook looks up an active referral row where the buyer is the referredUser, computes the commission as a percent of the charge, writes a Transactions row for the referrer (not the buyer), bumps the referral’s totalEarnings, and updates the referrer’s referralBalance and totalReferralEarnings. Buying-then-cancelling-before-pickup therefore never triggers a payout in the first place — that’s the business rule, encoded in one place.
On refunded, reverse everything. The refund hook finds the original charge transaction, bails out early if a refund already exists (so the hook is idempotent), writes a refund transaction, and — if a commission was paid — marks the referral transaction cancelled and decrements the same three referrer fields the credit hook incremented. The forward path and the reverse path are mirror images, and both go through the same delta utility. That symmetry is what makes the ledger trustworthy.
The counter utility
export async function updateUserMetrics(
payload: BasePayload,
userId: string,
deltas: Record<string, number>,
) {
const user = await payload.findByID({ collection: "users", id: userId, select });
const data: Record<string, number> = {};
for (const field of fields) {
const current = (user[field] as number) ?? 0;
data[field] = Math.max(0, current + deltas[field]);
}
await payload.update({ collection: "users", id: userId, data });
}
Three properties matter and they all come from staring at past production incidents. Callers pass deltas (+1, -50) rather than absolute values, so two callers can’t independently compute a new total and race each other. The Math.max(0, ...) floor means refunds and reversals can’t drive a balance negative even if the bookkeeping is wrong somewhere else — the transactions table is canon, counters are a fast read-side projection that should never embarrass you. And the whole thing lands in one payload.update, so a partial failure can’t leave half the counters touched.
Payments
| Gateway | Path | Verification |
|---|---|---|
| Stripe | POST /api/transactions/stripe/callback on checkout.session.completed | HMAC signature against webhook secret |
| Coinbase Commerce | POST /api/transactions/coinbase/callback on charge:confirmed | HMAC-SHA256 against shared secret |
| Manual (Venmo, Cash App, Zelle) | Admin-initiated Server Action | Manual review |
All three roads end the same way: a Transactions row of type: "topup", status confirmed. The afterChange hook on Transactions bumps currentBalance through the same updateUserMetrics utility the labels hooks use. Adding a fourth gateway is one webhook handler and one row insert — the rest cascades.
The collections
| Collection | Role |
|---|---|
Users | Auth, profile, referral code, all metric fields (read-only in admin) |
Labels | Core transaction entity, six-state status machine, pdf upload field |
Transactions | Immutable ledger — charge, refund, referral, topup, withdrawal |
Addresses | Saved address book with defaultAddress |
Packages | Reusable package templates |
Referrals | Referrer ↔ referred-user with expiry and commission percent |
Notifications | User-facing notifications honoring per-user preferences |
Tickets | Support threads |
ShippingTypes | Carrier catalog |
Weights | Pricing matrix — (shippingType, weightRange) → price |
Media | R2-backed file uploads |
Files and delivery
Label PDFs uploaded into the Labels.pdf field are stored in R2 through @payloadcms/storage-r2, and delivery goes through the Cloudflare Images binding so PDFs and marketing-site images are cached at the edge with format negotiation where it makes sense. There’s no separate CDN to configure because the data plane is the CDN.
Frontend
The app lives in three route groups under src/app/(frontend)/: a landing group (/, /signin, /signup), a dashboard group with the obvious pages (overview, labels, addresses, packages, transactions, referrals, tickets, settings), and Payload’s auto-generated /admin. Notably there’s no middleware.ts. Every Server Action starts with const { user } = await checkAuthorization(payload), which calls payload.auth({ headers }) and redirects to /signin on miss. Putting the auth check at the action boundary instead of in middleware means it runs in the same context as the mutation, with the same Payload instance, with no cookie-passing dance. Server Actions return { success?, error?, message? } envelopes and call revalidatePath to invalidate the Next.js cache on success.
What I’d want the next person to know
Hooks own business rules, Server Actions own entry points — and the difference matters more than it sounds like it should. The ledger is canon and the counters are a projection of it; if a counter ever looks wrong, the right answer is to rebuild it from a SUM over transactions, not to write a script that patches it. The whole stack is edge-native by choice, not by accident — every binding is a Cloudflare primitive, so there’s no extra IAM, no extra VPC, no extra latency. And the zero-floor on counter deltas is cheap insurance: most “race condition in our balances” stories end with a negative balance in production, and Math.max(0, current + delta) is a small net underneath the real fix.