Turns AppSumo redemption codes into Lemon Squeezy licenses.
A buyer enters their AppSumo code on a page carrying [redeemic]. Redeemic
validates it, mints a single-use 100% discount locked to one Lemon Squeezy
variant, and hands back a $0 checkout that asks for no card. Lemon Squeezy
issues the license key when that checkout completes, and a webhook writes the
order and key back onto the code row — so you can see which codes converted,
which stalled, and which failed.
Requires: WordPress with PHP 8.0+, a Lemon Squeezy store, Node 20.10+ and npm 10.2.3+ to build the admin bundle.
Install target: formgic.com (the public marketing site, where the redeem page lives).
- Why it works this way — the AppSumo → LS impedance mismatch
- Design decisions worth knowing — hashing, the claim race, discount locking
- Admin UI — the React SPA and its build
- Setup — verify, configure, map tiers, wire the webhook, generate codes
- Activity and the silent-failure hole
- Endpoints · Code lifecycle · Scheduled jobs
- Selective reset — clearing test data without uninstalling
- Tests · Packaging · Known limits
AppSumo hands buyers redemption codes. Lemon Squeezy has no concept of one. The only way LS expresses "free" is a 100%-off discount applied at checkout, and LS has no endpoint that mints a license key directly — the license-keys API is read/update only. So the chain is:
AppSumo code -> validate -> POST /v1/discounts (100%, 1 redemption, variant-locked, expiring)
-> POST /v1/checkouts (discount pre-applied, email prefilled, custom_data attached)
-> buyer completes a $0 order
-> LS mints the license key
-> webhook writes the order + key back onto the code row
One code = one license. A buyer who stacks three codes gets three one-site
lifetime keys and uses a different key on each site. Formgic Pro needs no
changes to support this — Gate::tier() is binary and every tier grants the
same features. Only a deal where tiers differ in features rather than seat
count would force plugin work.
Checkouts are created through the API, not by appending ?checkout[discount_code]=
to a store URL. The hand-built link shows the discount in the address bar,
carries no expiry, prefills nothing, and gives the webhook no way to link the
resulting order back to an AppSumo code. POST /v1/checkouts gives all four.
Only the hash of each code is stored. Plaintext exists in the CSV handed to AppSumo and nowhere else. A stolen database is worth nothing, because redeeming needs the code itself and there is no way back from an HMAC. The trade: the CSV downloads exactly once and cannot be regenerated.
The row is claimed before Lemon Squeezy is called. The status check lives in
the WHERE clause of the claiming UPDATE, so two simultaneous submits cannot
both win — the loser sees zero affected rows. The consequence is that a failed
LS call leaves a claimed row, which the sweeper releases within ten minutes. A
customer briefly unable to retry beats a customer whose only code was silently
eaten by a network blip.
Every discount is variant-locked, single-redemption, and expiring. Without
is_limited_to_products the code buys anything in the store; without
max_redemptions a leaked code is an unlimited free-license faucet; without
expires_at it stays one forever.
The five admin screens are a React SPA in src/admin/, built with
@wordpress/scripts (webpack) — the same toolchain as formgic-pro, so the two
look like one product. React and ReactDOM come from WordPress core rather than
the bundle.
npm install
npm run build # -> assets/build/admin.js, admin.css, admin.asset.php
npm start # watch modeThe bundle is not committed while the brand is still moving — a stale build
that references renamed globals fails silently, which is worse than no build at
all. Commit assets/build/ once the naming settles, so the plugin ships without
needing npm on the target site.
If admin.asset.php is missing, the admin screens print a notice telling you to
run the build.
Tailwind preflight is disabled in tailwind.config.js and re-implemented,
scoped, in src/admin/index.css:
:where(#redeemic-root, [data-radix-popper-content-wrapper], [data-redeemic-dialog])
Both halves matter. Unscoped preflight strips the padding off wp-admin's
#adminmenu list and shifts the sidebar icons. But an ID selector like
#redeemic-root button { padding: 0 } would outrank every Tailwind utility
and silently break .px-3 throughout — hence :where(), which contributes zero
specificity. The Radix and dialog selectors are there because those components
portal to <body>, outside the root div.
Admin data comes from Redeemic_Admin_Rest (includes/class-admin-rest.php),
capability-checked on manage_options and nonce-protected. It is deliberately
separate from Redeemic_Rest, which serves the public redemption route
where the AppSumo code is the credential — the two must never share a
permission model.
Each of these invalidates the design if it fails:
- A checkout with a 100% discount completes with no payment method. AppSumo will reject a deal that asks for a card.
- Each mapped variant is a one-time payment, not a subscription. A 100% discount on a subscription renews at full price in year two and breaks the lifetime promise. The Tiers screen checks this for you.
POST /v1/discountsacceptsamount: 100, amount_type: "percent".- A license key is issued on the $0 order and
meta.custom_datasurvives into the webhook payload.
Redeemic -> Settings, or in wp-config.php (constants always win and
render the fields read-only):
define( 'REDEEMIC_LS_API_KEY', '<Lemon Squeezy API key>' );
define( 'REDEEMIC_LS_STORE_ID', '411880' );
define( 'REDEEMIC_LS_WEBHOOK_SECRET', '<LS webhook signing secret>' );
define( 'REDEEMIC_ENC_KEY', '<32+ random chars>' );Behind Cloudflare or a load balancer, and only then, name the header the
proxy sets — otherwise rate limiting uses REMOTE_ADDR, because reading
X-Forwarded-For from untrusted traffic hands an attacker a fresh bucket per
request:
define( 'REDEEMIC_TRUSTED_IP_HEADER', 'HTTP_CF_CONNECTING_IP' );Leave Test mode on until the whole flow is proven end to end.
Discount percentage defaults to 100 and 100 is the only value a real deal should use — a redemption is supposed to be free. The setting exists because Lemon Squeezy cannot always complete a genuine $0 checkout in test mode, and a 99% discount lets you drive the whole pipeline — discount, checkout, order, webhook, license key — with a real payment. It is clamped to 1–100, so a stray 0 or 500 cannot produce a discount LS rejects. Put it back to 100 before the deal goes live.
Redeemic -> Tiers. One row per AppSumo tier. Hit Validate on each:
it names the variant and warns loudly if it is a subscription.
In Lemon Squeezy, create a webhook for order_created, license_key_created,
and order_refunded:
https://formgic.com/wp-json/redeemic/v1/webhook/ls
- Redeem page:
[redeemic] - Success page:
[redeemic_success]— set its ID in Settings, since LS redirects there after purchase.
Redeemic -> Batches. Pick a tier and a quantity (AppSumo caps at
10,000 per deal; generate more than you expect to sell). The codes come back in
the response and the browser builds the CSV locally — no header row, one column,
the format AppSumo wants.
Keep that file. Only a hash is stored, so it cannot be recovered. If you close the dialog without downloading, the batch is dead; generate a new one.
A checkout link lives 14 days by default. AppSumo buyers routinely sit on a code
for longer. When the same buyer returns after their link has died, Redeemic
deletes the stale Lemon Squeezy discount and mints a fresh discount and checkout
rather than telling them the code is spent. Gated three ways: the code must be
in status redeemed, the submitted email must match the one on the row, and a
converted code is never re-issued — that buyer already has their key and a
second checkout would hand them a license they do not need.
Support has the same power: the resend button re-issues instead of failing when the link has lapsed.
When Lemon Squeezy refuses to create a discount or a checkout, the code is
released back to available and redeemer_email, claimed_at and
redeemed_at are cleared. The row then looks identical to a code nobody has
ever touched. So an expired API key, a rate limit, or a wrong store ID takes
down every redemption while the Dashboard, the Codes screen and your inbox all
look completely normal.
The redeemic_events table has always recorded those failures. Nothing read it
until the Activity tab existed, which meant the only way to discover the problem
was querying the table by hand.
- Activity lists every event newest-first, with a Failures only toggle. The Lemon Squeezy error text is printed inline, not behind an expander.
- Dashboard raises a destructive alert whenever anything failed in the last 24 hours, linking straight to the filtered feed.
- Any code row opens a drawer with its full timeline, every timestamp, and the resend / revoke actions.
Retention: events are pruned after 180 days by the daily
redeemic_prune_events job, batched at 5,000 rows a run so a backlog cannot
time the cron out. Only the timeline goes — ls_order_id and ls_license_key
live permanently on the codes row, so which license a code became is still
answerable in year two.
| Route | Auth | Purpose |
|---|---|---|
POST /wp-json/redeemic/v1/redeem |
Public; the AppSumo code is the credential | Validate a code, return a checkout URL |
POST /wp-json/redeemic/v1/webhook/ls |
HMAC-SHA256 over the raw body, X-Signature |
Record orders, keys, refunds |
The redeem route sits behind a honeypot, optional invisible reCAPTCHA v3, and
two ceilings: 5 failed attempts per IP per 15 minutes, and 40 total. Only
failures count toward the tight one, because a buyer who stacked eight codes
redeems eight times in a row and locking them out on the sixth is
indistinguishable from a broken deal. Guessing is what needs throttling, and a
guess is by definition a failure. Everything is checked before the database
lookup, so a script cannot use the route as an oracle. Every
response carries Cache-Control: private, no-store, because a CDN that caches
one buyer's checkout link and serves it to the next visitor gives away a license.
available -> claiming -> redeemed -> converted
^ | |
| v (LS call failed, v (LS refund)
+-------- swept) refunded
available -> revoked (manual, available codes only)
converted is set from the webhook, which is the only thing that proves a buyer
actually finished. Everything short of it is a redemption that may still lapse.
| Hook | Cadence | Job |
|---|---|---|
redeemic_sweep_claims |
10 min | Release stranded claims, delete orphaned discounts |
redeemic_stock_check |
daily | Warn when a tier drops below the threshold (deduped 24h) |
redeemic_nudge |
daily | Chase redemptions that never reached checkout |
redeemic_reconcile |
daily | Backfill dropped order and license-key webhooks |
Redeemic -> Settings -> Reset wipes chosen slices of the plugin's state without
uninstalling — the screen an operator needs after a test run, when the database
is full of fake redemptions and the real deal is about to start.
Six scopes, each independently selectable: codes (codes and batches),
activity (the event log), tiers, lemonsqueezy (API key, store ID),
webhook (signing secret), alerts (low-stock dedupe state).
The dialog previews counts before it deletes — how many codes exist, how many
of those are redeemed, converted or refunded — rather than asking the operator to
take "this cannot be undone" on faith. Arming it means typing RESET, checked
server-side as well as in the dialog, because a second "are you sure?" button is
muscle memory by the time anyone reaches it and typing is not.
The code pepper is never deleted here, which is the one deliberate divergence
from uninstall.php. Codes are stored only as HMACs keyed by that option, so
dropping it makes every code in the wild permanently unredeemable — and, the part
that catches people, a database restore does not bring them back, because the
pepper that produced the hashes is gone. Deleting the code rows already makes
those codes unusable, so destroying the pepper buys nothing and forecloses
recovery. Uninstall is the only place that may take it.
What was wiped and by whom is recorded in redeemic_last_reset, since the audit
log itself may be one of the things that went.
php tests/test-redeem.php296 assertions, no network and no database: the claim race, code hashing, token
signing and forgery, masked-secret handling, both LS request payloads
attribute-by-attribute, failure rollback, the two-ceiling rate limiter, IP
spoofing, event-log dedupe, the feed and timeline queries, the failure-count
window, prune's cutoff and batch limit, expired-checkout re-issue and its
ownership gates, batch deletion refusing to run once any code is spent, the configurable
discount percentage reaching both the discount payload and the tier estimate, and
selective reset's scope preview and per-scope deletes. The stub layer lives in
tests/bootstrap.php.
The obvious sweep — walk recent orders, match on custom data — cannot work:
the LS order object exposes no custom data, on first_order_item or anywhere
else. The link runs the other way. For each redeemed-but-unconfirmed row,
GET /v1/discount-redemptions?filter[discount_id]= says whether that discount
was ever spent and on which order; GET /v1/license-keys?filter[order_id]=
then fills in a key whose webhook was dropped or arrived before its order.
npm run build # produces assets/build/
./bin/build-zip.sh # produces dist/redeemic-<version>.zipdist/ is gitignored — the zip is a build output, rebuilt from a tag rather
than carried in history. The zip is an allow-list, not an exclude-list: a dev file added later defaults to
being left out rather than silently shipping. Three guards run first — the bundle
must exist, the bundle must not be older than src/, and all PHP must lint. The
staleness guard is the important one: assets/build/ is gitignored, so a zip
built from a stale bundle installs cleanly and silently runs an older admin,
which stays invisible until someone reports a missing feature.
Regenerate the translation template with python3 bin/make-pot.py (or
wp i18n make-pot . languages/redeemic.pot where wp-cli is available).
Held to WCAG 2.1 AA on both surfaces. Every text/background pair in the admin
and the redeem page passes AA; several pass AAA. Tables carry scope="col" and
aria-sort; sortable headers and row-open controls are real <button>s, so
everything is reachable without a mouse. Sort direction is shown by arrow shape,
not colour. A prefers-reduced-motion block neutralises animation, with
spinners deliberately exempt — a frozen spinner reads as a hung request, which
is worse than the motion it removed.
All PHP user-facing copy is translatable under the redeemic text domain, with
_n() for every count-driven plural and date_i18n() for dates. languages/
is ready for a .pot. The React admin has wp_set_script_translations wired but
its strings are not yet wrapped — it is operator-only, so it was deliberately
deferred.
Uninstalling the plugin drops all three tables, every option and every cron hook.
Note this destroys redeemic_code_pepper, after which every code in the wild is
permanently unredeemable even from a database backup.
- AppSumo sends no refund signal under the code-generation model. LS
order_refundedand manual revocation are the only signals, and neither revokes the license automatically — a refund on a $0 order is odd enough to deserve a human look. - The LS API key is a full-store key. It can create discounts and read orders. Encrypted at rest, never exposed to the frontend, server-side only.
- Revoke only works on unredeemed codes. Revoking a redeemed one would strand a customer whose discount already exists in LS; handle those there.
- Batch deletion refuses once any code in it is spent. Deleting a batch holding a redeemed code would orphan a paying customer's license. A batch that came up short during generation now rolls itself back automatically.
GPL-2.0-or-later. See the plugin header in redeemic.php.