Lemon Squeezy Black Friday Discount: Full Setup
Build a Lemon Squeezy Black Friday discount that starts and ends on time: the four timing fields, the encoded checkout link, and the kill switch.
Black Friday 2026 is Friday, November 27, and Cyber Monday is November 30. If you sell a template, a plugin, or a license key, that is four weeks away — enough time to set the discount up properly instead of doing it at 11 PM on Thanksgiving.
A promotion can start earlier than intended or remain available after the advertised deadline. Test the schedule and disable the code when necessary.
Check code validity, redemption limits, product eligibility, and subscription duration separately. A percentage alone does not define the campaign.
The four fields that actually control timing
Here is the whole timing model — smaller than it looks:
| Field | What it really does | What happens if you skip it |
|---|---|---|
starts_at | Checkout rejects the code before this moment | Code works the second you publish it |
expires_at | Checkout rejects the code after this moment | Code works forever |
status | draft or published — the on/off switch | You cannot build a code quietly |
max_redemptions | Caps total redemptions when is_limited_redemptions is true | Unlimited use, even past Cyber Monday traffic |
Only the first two are about the calendar. status is about control, and max_redemptions is about blast radius.
Be precise about what expires_at governs: whether checkout will accept the code, not what happens to orders that already happened. A customer who bought on Friday keeps their discount, their license, and their receipt after the code dies on Monday. That is why ending a sale is safe rather than something that can claw back revenue.
The official Discount object reference lists all of these attributes if you want the full surface, but those four are the ones that decide whether your Black Friday behaves.
Step 1: create the discount as a draft
Create it in advance with status set to draft. That single choice is the difference between “scheduled” and “oh no, it is live.”
Illustrative payload for a 30% discount on a one-time product; replace the IDs, schedule, and limits for your store:
curl -X POST "https://api.lemonsqueezy.com/v1/discounts" \
-H "Accept: application/vnd.api+json" \
-H "Content-Type: application/vnd.api+json" \
-H "Authorization: Bearer $LEMON_SQUEEZY_API_KEY" \
-d '{
"data": {
"type": "discounts",
"attributes": {
"store_id": YOUR_STORE_ID,
"name": "Black Friday 2026 - 30%",
"code": "BF30",
"amount": 30,
"amount_type": "percent",
"duration": "once",
"starts_at": "2026-11-27T05:00:00Z",
"expires_at": "2026-11-30T23:59:59Z",
"is_limited_redemptions": true,
"max_redemptions": 200,
"status": "draft"
}
}
}'
Three details in there are worth pausing on.
amount is 30, not 0.30. When amount_type is percent, the number is the percentage itself. Switch to fixed and the same field becomes cents, so 500 means $5 off. Mixing these up is how you accidentally give away a product for free.
max_redemptions does nothing unless is_limited_redemptions is true. The two travel together. Setting a number without the boolean leaves you with an unlimited code that looks capped in the dashboard.
Keep the API key out of your frontend. It belongs in a server environment variable, never a page bundle — and the dashboard-versus-local variable gap is what bites when it “worked yesterday.”
Step 2: put the code inside the link
Most people announce a sale by telling buyers to type a code at checkout. On a phone, that is a lost sale. The fix is a link that arrives with the discount already applied:
https://yourstore.lemonsqueezy.com/checkout/buy/PRODUCT_UUID?checkout%5Bdiscount_code%5D=BF30
The parameter is checkout[discount_code], and the brackets must be percent-encoded as %5B and %5D. This is not cosmetic. Unencoded brackets survive in some browsers and get mangled in email clients and messaging-app link previews — and when they break, checkout still loads at full price, so nobody reports a bug. They just pay more or leave.
Remkus de Vries documented the pattern after hitting the same gap in the official docs, and it is still the clearest explanation of how the two pieces combine.
Two checks for the link itself:
- One link per channel.
-email,-x,-newslettersuffixes on separate codes, so the week after the sale you can say which channel actually converted instead of guessing. - The link is the announcement. Put it in the email, the post, and the link-in-bio. If the discount lives in the code and not the link, half your traffic never sees it.
If your checkout is embedded on a static page instead, the static-site checkout setup wires the same URL through — the parameter works identically.
Step 3: end it on purpose
expires_at defines the scheduled ending. Keep a manual disable option for an early stop.
Setting the discount’s status to draft is an instant off switch. Checkout stops accepting the code immediately, and unlike deleting the discount, your redemption history survives. That history is the single most useful number you will have on December 1 — how many people actually used it, and at what average order value.
Use the disabled state when you need to stop redemption, and verify the public checkout afterward.
Avoid deleting a discount mid-sale. It works, but it takes the redemption records with it, and next year you will be starting from zero on the one question that matters: was 30% the right number, and what did it leave you after the fee?
And if you decide to extend into Cyber Monday, move expires_at on the existing code instead of creating a second one. Two live codes means a buyer holding the older link can still redeem the older price, and your dashboard can no longer tell you which offer sold what.
The subscription trap: duration is not about timing
This is the field most likely to quietly cost you money, because its name sounds like it belongs with the other date fields. It does not.
duration only applies to subscriptions, and it answers a different question: after the first payment, does the discount keep applying?
duration | Effect on a subscription | When it makes sense |
|---|---|---|
once | First payment only, full price after | Black Friday on a monthly plan |
repeating | Applies for duration_in_months, then full price | A 3-month promo you can plan around |
forever | Every payment, permanently | Almost never for a sale |
For a one-time purchase — a template, a license key, a download — there is only one payment, so duration changes nothing at all. Set it to once and move on.
For a subscription, forever is not a sale — it is a permanent price cut wearing a Black Friday costume. On a $20/month plan, 30% off with forever does not cost you 30% of one payment. It costs 30% of every payment, from every buyer who ever used the code, for as long as they stay. That is a different number by an order of magnitude, and it is invisible on the day you set it up.
Platforms differ: the Eden guide to running a Black Friday sale on digital products notes a membership discount only comes off the first payment there. Do not assume that carries over — in Lemon Squeezy the setting is explicit, and yours to get wrong.
The timezone bug that eats your last evening
starts_at and expires_at are ISO 8601 timestamps in UTC. The Z at the end is the tell.
So 2026-11-30T23:59:59Z is not “midnight at the end of Cyber Monday” in your timezone. It is 23:59 UTC. If you are in New York, that is 6:59 PM — your sale ends while people are still shopping after dinner. If you are in Berlin, it is 12:59 AM Tuesday, which is roughly what you wanted. If you are in Tokyo, it is 8:59 AM Tuesday, which is not.
Two habits fix this permanently:
- Compute the UTC value explicitly instead of typing what “midnight” feels like. Decide the real end moment in your buyers’ timezone, convert once, paste that in.
- State the end time and timezone in the announcement. “Ends Monday at 11:59 PM Eastern” is a promise a buyer can check. “Ends Monday” is a rumor. The same guide makes the point that an end time without a timezone is the most common way a real deadline starts looking fake.
There is a commercial cost beyond the annoyance. If the code dies early, buyers who were about to purchase see full price, assume the sale is over, and leave. You get no support ticket about this — just a quieter Monday and no idea why.
The Friday-morning checklist
Run this the day before, not on the day:
- Build the discount as
draftwith both timestamps set. Do not publish it yet. - Test the whole purchase in test mode — test-mode discounts are separate objects, so recreate the code there and buy with
4242 4242 4242 4242to confirm the price drops and the order lands. There is a full rehearsal walkthrough for test mode if this is your first time. - Click your own link from a phone. Confirm the discount applies and the total is what you expect, including tax.
- Confirm the webhook still fires on a discounted order. Same event, different totals — and sale-day traffic is the worst time to discover that a webhook never arrives.
- Decide the real end moment in your buyers’ timezone, convert to UTC, and write it in the announcement.
- Publish a few hours before you send the first email, never days before.
If your discounted price ends up below what the product actually costs you to support, the problem is the discount size, not the setup — how to price a developer template is the number to settle first, because a sale that loses money per buyer scales in the wrong direction.
What I’d do next
Set the discount up this week while you still have slack, and spend the time you saved on the part that decides whether Black Friday means anything: having people to tell about it. A perfect discount code sent to nobody is a config file with ambition. If your list is thin, getting your first customers without a launch budget covers the unglamorous version that actually works.
And once orders start landing, the interesting question stops being the discount and becomes how the money reaches you — especially if Stripe is not available in your country and you are routing through a merchant of record instead.
Frequently Asked Questions
How do I create a Black Friday discount code in Lemon Squeezy?
Create it through the discounts API or the dashboard with amount_type set to percent, a code like BF30, a starts_at timestamp, and an expires_at timestamp. The two timestamps are what make it a Black Friday discount rather than a permanent price cut. Set status to draft while you build it, then flip it to published a few hours before you announce the sale.
Why did my Lemon Squeezy discount stop working early?
Almost always a timezone mistake. starts_at and expires_at are ISO 8601 timestamps in UTC, so an expiry written as 2026-11-30T23:59:59Z means 23:59 UTC — not 23:59 in your local time. If you are in UTC-5, the code dies at 6:59 PM your time on the last day of the sale, which looks like the discount broke mid-evening.
Does the duration field matter for a one-time product?
No. duration only affects subscriptions. once applies the discount to the first payment only, repeating applies it for duration_in_months, and forever applies it to every payment forever. For a one-time template or license purchase there is only one payment, so duration changes nothing — but for a subscription, forever is a permanent cut to your average revenue per user.
How do I stop a Lemon Squeezy discount immediately?
Set the discount status to draft. That is an instant off switch: checkout stops accepting the code while the discount, its redemption history, and its reporting stay intact. Deleting the discount also works, but you lose the record of how many people used it, which is exactly the number you want the week after Black Friday.
Can I test a Black Friday discount before the sale starts?
Yes, and you should. Flip test mode on, create the discount there with the same timestamps, and run a purchase with test card 4242 4242 4242 4242. Test-mode discounts are separate from live ones, so the timestamps you set in test mode do not carry over — copy the exact values into the live discount instead of retyping them by hand.
Should I extend my sale if it is going well?
Change the existing discount's expires_at rather than creating a second code. Two live codes with different discounts means a buyer who finds the older link can still use the expired price, and you cannot tell from the dashboard which link actually sold. One code, one end date, moved deliberately.
Peng Zhou
Founder of ShipToCash. I build small software products and write about the part nobody teaches you: getting your app deployed, taking payments, and understanding operational requirements. Guides include documentation references and worked examples; see the editorial standards for how they are prepared.
More about me →Related Articles
AdSense Low Value Content: Audit Before You Reapply
A practical audit after an AdSense low-value-content rejection: check originality, accuracy, navigation, and the deployed pages before requesting review.
Get Your First Developer Template Customers: A Test Plan
Find developer template customers with a focused launch experiment: verify the demo, choose one audience, and measure checkout, delivery, and feedback.
Google AdSense on Astro: Setup and Output Checks
Add the AdSense loader and ads.txt to a static Astro site, inspect the generated HTML, and separate code installation from site approval.