When you list a product on AppSumo, AppSumo handles the sale and nothing after it.
The buyer walks away with a single code string. Turning that string into a license is something the seller builds.
What you build is small. The spec for it, though, is spread across AppSumo's help articles and partner terms, and you have to read them in the right order to put it together.
I built this to list one macOS app, so here are the conditions that are fixed and the places I got caught.
Why I switched sides
I've bought more than 260 products on AppSumo.
For about nine years I bought almost every lifetime deal that came out.
Then things changed. More and more of what was listed there fell into the range where building it myself was faster than buying it.
So I decided to try selling as well as buying.
It's also a first step toward something I've wanted for a long time: a business that stands on its own in the English speaking market.
The four things you give AppSumo
The Redemption step of the listing form asks for exactly this:
・The URL of your redemption page
・A CSV of codes
・Redemption instructions (160 characters per step)
・A support email address
The CSV has tight rules:
・At least 1,000 codes and no more than 10,000
・No duplicates
・Random (sequential codes are rejected)
・One column, no header row
・Each code between 3 and 200 characters
This is enough to generate them:
const crypto = require('crypto')
// Leave out look-alike characters. 0 and O, 1 and I and l cause copy errors over phone and chat
const ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
function makeCode() {
const pick = (n) =>
Array.from({ length: n }, () => ALPHABET[crypto.randomInt(ALPHABET.length)]).join('')
return `${pick(2)}-${pick(5)}-${pick(5)}`
}
const codes = new Set()
while (codes.size < 1000) codes.add(makeCode())
console.log([...codes].join('\n'))
Twelve characters from a 32 character alphabet means collisions effectively never happen.
Running them through a Set anyway means you never have to rebuild the CSV because of one.
Don't make buyers create a password
The buyer's path is: buy on AppSumo, copy the code, come to your site.
Put an account signup in the middle of that and they leave on the spot.
Keep the form to two fields, the code and an email address.
Send the license key by email.
The ledger
You need three things in it:
create table appsumo_codes (
id bigserial primary key,
code_hash text not null unique, -- never store the plain code
redeemed_at timestamptz,
license_id bigint references licenses(id),
email text,
created_at timestamptz not null default now()
);
Always put a unique constraint on code_hash.
Without it, the only thing stopping the double issue I describe below is a branch in your application code, and concurrent requests will get through it.
The reason not to keep plain codes: if the ledger leaks, every unused code leaks with it as ready to use stock.
Checking a code only means hashing the incoming string and looking up code_hash, so you never need the plain text.
Assume the same code will arrive twice
This was the part that mattered most.
If the email doesn't seem to arrive, the buyer sends the same code again.
Usually it just went to spam. But if you issue a new license every time, your ledger falls apart.
The right responses are:
・Unused code: issue a license, record it, send the email
・Used code, same email address as last time: send the same license again
・Used code, different email address: refuse
If you answer the second case with "already used", a buyer whose email never arrived can never get their license.
Treat a resend as a normal operation.
const existing = await findByCodeHash(hash)
if (!existing) return reject('unknown') // no such code
if (existing.redeemed_at) {
if (existing.email !== email) return reject('used')
await resendLicenseEmail(existing.license_id) // never issue twice
return ok({ resent: true })
}
Stacking adds to a license, it doesn't add licenses
On an AppSumo lifetime deal, the same person can buy several codes to raise their limits.
For example: one code for 2 devices, two for 5, three for 10.
The key point is not to create a new license when the second code arrives.
Find the existing license for that email address and raise its device limit.
Refuse codes beyond the maximum stack.
If you don't, when a refund comes in you can no longer tell which code paid for which devices.
Brute force protection is not optional
A code is a finite string.
Leave the redemption endpoint wide open and someone can guess their way to unused codes.
・Count failures by both IP address and email address. Count only one and you miss either the attack that rotates IPs or the one that tries many emails from a single IP
・Lock after 5 failures. Increase the wait: 1 minute, 5, 15, 60
・While locked, return 429 with Retry-After
・Clear the count on success
One more thing that's easy to miss:
Make "no such code" and "already used" look the same, in both response time and wording.
If they differ, an attacker can first sort out which codes exist at all.
That shrinks the search space by orders of magnitude.
Deadlines set by the partner terms
The Partner Terms put numbers on two things:
・Buyers get at least 60 days to redeem
・After you take the listing down, you keep the license API and redemption codes working for 3 months
So you can't give codes a short expiry like 30 days from purchase.
And you can't shut the server down the day you stop selling.
Build the refund path first
AppSumo has refunds.
A refunded buyer's license has to be revoked, so build the equivalent of a /revoke endpoint, and the steps for using it, from day one.
Put it off and the day the first refund notice arrives, you'll be editing the production database by hand.
If there are two ledgers, build on the one the app actually checks
I got this wrong once.
This product had two license systems, each with its own history.
The plan said to build on the newer one (Cloudflare Workers and D1). But the app was actually checking the older one.
The newer one had become a pass through, and nothing was being written to its ledger anymore.
Worse, the app's check treated any key missing from the ledger as a forgery: it logged it and fired an alert.
Had I issued licenses from D1, every buyer who redeemed would have been rejected by the app, and each one would have fired a forgery alert.
Before you write any of this, grep the app's source for the host its license check calls.
The running code is the truth, not the design doc.
You only have four places to reach the buyer
AppSumo does not give sellers the buyer's email address.
These are the only places you can reach them:
- The success screen of the redemption page
- The license email
- AppSumo's redemption instructions (buyers read these on their Products page every time)
- Your site's footer and FAQ
If you want to invite buyers to a community or anything else, put it in all four.
Use a separate invite link for each place and you'll know where people came from.
What you can change after listing
The redemption side can be changed freely after you go live.
| What | Can you change it? |
|---|---|
| Redemption instructions, redemption URL, support contact | Any time. Buyers load this screen each time, so it reaches people who already bought |
| Listing copy and images | Yes |
| Price and features | From 30 days after listing, then every 30 days, with approval |
Since the redemption flow can be fixed later, you don't need to hold the launch to polish it.
The ledger design is the exception. Once you start issuing, you can't change it.
Summary
The thing you build is small.
A redemption page, a ledger, issuing, email, revoking. Five parts.
What caused trouble wasn't difficulty. It was these four:
・Starting to write without checking which ledger production actually uses
・Refusing a resent code as "already used"
・Answering differently for a code that doesn't exist and one that's been used
・Leaving the refund path for later
Get those right and the rest takes half a day.
Top comments (0)