Redeemic
WordPress plugin that redeems AppSumo codes as Lemon Squeezy licenses — validates the code, mints a single-use 100% variant-locked discount, and returns a $0 checkout that mints the license key.
Install
No release zip yet. The repository archive installs, but the folder name will carry the branch suffix and updates will not flow:
wp plugin install https://github.com/d-raj-grg/redeemic/archive/refs/heads/main.zipReadme
Redeemic
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).
Contents
- 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
Why it works this way
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.
Design decisions worth knowing
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.
Admin UI
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 mode
The 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.
Setup
0. Verify first (LS test mode, before anything else)
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.
1. Configure
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.
2. Map tiers
Redeemic -> Tiers. One row per AppSumo tier. Hit Validate on each:
it names the variant and warns loudly if it is a subscription.
3. Point the webhook at the plugin
In Lemon Squeezy, create a webhook for order_created, license_key_created,
and order_refunded:
https://formgic.com/wp-json/redeemic/v1/webhook/ls
4. Build the pages
- Redeem page:
[redeemic] - Success page:
[redeemic_success]— set its ID in Settings, since LS redirects there after purchase.
5. Generate codes
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.
Expired checkouts re-issue themselves
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.
Activity and the silent-failure hole
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.
Endpoints
| 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.
Code lifecycle
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.
Scheduled jobs
| 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 |
Selective reset
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.