TL;DR
The work is done; the decision is what's late. A magic link moves the decision to the decider's pocket: one text, one tap to review, one tap to approve. Tokens are single-use and expire; the database enforces it; an audit row remembers it. An LLM writes the three-line summary so the approver decides in ten seconds.
The problem this solves
Every small business has one: the approval bottleneck. Quotes, purchase orders, time-off, change orders — all queued behind a busy owner who is on a roof, in a truck, or at dinner. The usual fix (give everyone portal logins) dies because nobody remembers a password for a thing they use twice a month.
A magic-link approval removes every step except the decision itself. No login, no app, no password — and no security hand-waving either, because the token discipline below is real.
The full picture
THE END-TO-END FLOW
═══════════════════════════════════════════════════════════════════════
┌──────────────────┐ ┌──────────────────────────────┐
│ SOMETHING NEEDS │ │ LLM SUMMARIZER (optional │
│ APPROVAL │ ──────▶ │ but worth it) │
│ (PO, quote, │ │ turns the full record into │
│ change order) │ │ 3 decision-ready lines │
└──────────────────┘ └──────────────┬───────────────┘
▼
┌──────────────────────────────┐
│ TOKEN MINTED │
│ signed JWT encoding: │
│ • what (order id) │
│ • who may approve │
│ • action scope (approve PO) │
│ • expiry (72h) │
└──────────────┬───────────────┘
▼
┌──────────────────────────────┐
│ SMS TO THE APPROVER │
│ "PO-118 — 24 pr cut gloves, │
│ $312, Norwood Supply. │
│ [Review & Approve]" │
└──────────────┬───────────────┘
│ tap
▼
┌──────────────────────────────┐
│ REVIEW PAGE │
│ shows EVERYTHING: │
│ every line, every dollar │
│ [ APPROVE ] [ REJECT ] │
└──────────────┬───────────────┘
│ tap
┌─────────────────────┼─────────────────────┐
▼ ▼ ▼
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ STATE CHANGES │ │ TOKEN CONSUMED │ │ AUDIT ROW │
│ in the SAME │ │ (unique constraint│ │ who, when, what, │
│ transaction — │ │ = used exactly │ │ from which device│
│ next step fires │ │ once, ever) │ │ │
└──────────────────┘ └──────────────────┘ └──────────────────┘
═══════════════════════════════════════════════════════════════════════
What you need
| Piece | What it does | Pick |
|---|---|---|
| One serverless function + one page | Mints tokens, renders review, handles the POST | Whatever you already host (Vercel/Cloudflare/Supabase Edge) |
| JWT library | Signs and verifies tokens correctly | Any standard lib in your language — never hand-roll |
| Delivery | Gets the link to the approver | SMS beats email for field owners |
consumed_tokens table |
Enforces single-use at the DB level | One table, one unique constraint |
| LLM (optional) | Writes the 3-line decision summary | Claude Haiku — pennies |
Phase 1 — Mint the token
The token is a signed claim: this person may take this one action on this one thing, until this time.
TOKEN PAYLOAD (inside a signed JWT)
┌──────────────────────────────────────────┐
│ jti: "tok_8f3a…" ← unique token id │
│ action: "approve_po" ← ONE action only │
│ subject: "PO-118" ← ONE record only │
│ approver:"+1403…" ← who may use it │
│ exp: now + 72h ← short life │
└──────────────────────────────────────────┘
signed with a SERVER-ONLY secret
link = yourapp.com/a/<token>
Rules that are not optional:
- Scope to one action on one record. A token that approves PO-118 must be unable to approve anything else — and the server verifies that, never the page.
- Sign with a server-only secret; verify with the library's constant-time comparison. Hand-rolled string comparison is how these systems get broken.
- 72 hours for routine, 7 days for big-ticket. Expired means re-request, not resend.
Phase 2 — The LLM decision summary
The approver shouldn't decode a database row on their phone. Give the record to an LLM and send three lines a human can act on:
The summarizer prompt:
You write SMS approval summaries for {{business_name}}. Below is the full record needing approval. Write EXACTLY three short lines:
- What it is and who requested it.
- The total cost and the vendor/recipient.
- Anything unusual a careful owner would want flagged (price above the usual range, new vendor, rush request) — or "Nothing unusual." if clean. Under 280 characters total. No emoji, no sales tone.
RECORD: {{record_json}} HISTORY (last 5 similar approvals, for the unusual-check): {{history_json}}
EXAMPLE OUTPUT ON THE APPROVER'S PHONE
┌────────────────────────────────────────────┐
│ PO-118 from Dave — 24 pr cut-resistant │
│ gloves. $312 to Norwood Supply. │
│ Nothing unusual. │
│ Review & approve: yourapp.com/a/tok_8f3a… │
└────────────────────────────────────────────┘
That third line is the quiet killer feature: the LLM compares against recent history and flags the weird one — "Price is 40% above the last three glove orders." The owner's attention goes exactly where it should.
Phase 3 — The review page and the binding tap
GET /a/<token> POST /a/<token>/decide
┌─────────────────────────┐ ┌─────────────────────────────┐
│ verify signature │ │ verify signature again │
│ check expiry │ │ BEGIN TRANSACTION │
│ check not consumed │ │ insert jti into │
│ load the record │ │ consumed_tokens ← unique │
│ render EVERYTHING: │ │ constraint = the lock │
│ every line item │ │ update record state │
│ every dollar │ │ write audit row │
│ [APPROVE] [REJECT] │ │ COMMIT │
└─────────────────────────┘ │ fire the next step │
└─────────────────────────────┘
- The unique constraint on
jtiis the single-use enforcement. Not an if-statement — a database constraint inside the same transaction as the state change. Two simultaneous taps: one wins, one gets "already decided." - Approve and reject are equally easy. A rejection with a one-line note ("wrong vendor, use Apex") goes back to the requester and is worth more than a stalled approval.
- A consumed or expired link shows a friendly "this link was already used / has expired" page — never an error dump, never a second chance to approve.
Phase 4 — Close the loop
APPROVED ──▶ parent workflow continues (PO emails the vendor,
│ quote goes to the customer, time-off hits the calendar)
│
└─────▶ requester notified: "PO-118 approved by Mike, 2:14 pm"
REJECTED ──▶ back to requester with the note, state returns to draft
The approver's tap should visibly cause something. That's what builds the habit.
Data model
consumed_tokens approval_events
┌──────────────┬─────────────┐ ┌──────────────┬──────────────────────┐
│ jti │ UNIQUE ←lock│ │ id │ uuid │
│ consumed_at │ timestamp │ │ subject │ PO-118 │
│ decision │ approved/ │ │ action │ approve_po │
│ │ rejected │ │ decided_by │ +1403… │
└──────────────┴─────────────┘ │ decision │ approved | rejected │
│ note │ "wrong vendor" / null│
│ decided_at │ timestamp │
│ device_meta │ user-agent, ip │
└──────────────┴──────────────────────┘
Folder structure
one-tap-approvals/
├── CLAUDE.md ← the AI-builder brief (ships in this kit)
├── api/
│ ├── mint.ts ← create token + LLM summary + send SMS
│ └── decide.ts ← verify, consume, transition, audit — one transaction
├── pages/
│ └── a/[token].tsx ← the review page (shows everything)
├── lib/
│ ├── token.ts ← sign/verify helpers (JWT lib, never hand-rolled)
│ └── summarize.ts ← the LLM summary + history comparison
└── prompts/
└── summarize.md
Security rules (the whole list, again, because it matters)
- Single-use enforced by a database unique constraint, inside the same transaction as the state change.
- Short expiry; expired = re-request.
- One token = one action on one record, verified server-side.
- Server-only signing secret; library-provided constant-time verification.
- Send from your own domain (SPF/DKIM if email); no URL shorteners — they read as phishing.
- Log device metadata on every decision; for high-stakes approvals add a last-4-digits phone-number confirmation step.
Numbers to watch
| Metric | Healthy | Where it comes from |
|---|---|---|
| Median time-to-decision | < 10 minutes (was: hours–days) | mint → decide timestamps |
| Expired-token rate | < 10% — higher means TTL too short or asks too noisy | consumed vs expired |
| Rejection rate with notes | rejections are good — silence is the enemy | approval_events |
| "Unusual" flags caught | every true catch = the LLM summary earning rent | flag vs owner action |
Week-one checklist
- Mint → SMS arrives with a 3-line summary you'd actually act on
- Review page shows every line item and every dollar — no "check the system"
- Approve → state changes, audit row written, requester notified
- Tap the same link again → friendly "already decided" page
- Two devices, same link, simultaneous taps → exactly one wins
- Expired token → friendly expiry page, re-request path works
- Token for PO-118 cannot decide PO-119 (try it — the server must refuse)
- Reject with note → requester sees the note, record returns to draft
Troubleshooting
- Links flagged as spam: send from a domain with SPF/DKIM, keep the link on your own domain, no shorteners.
- Approver forwards the link: the token already encodes who may approve; log device metadata, and add the last-4-digits confirmation for big-ticket items.
- "It expired on the weekend": match TTL to your real cadence — 3 days routine, 7 days big-ticket.
- Double-approval bug reports: if this is possible, your single-use check is an if-statement instead of a unique constraint in the transaction. Fix the constraint.
- Approvers ignore the texts: the summary is too vague. Tighten the LLM prompt — the unusual-flag line is what earns their attention back.