Flash sale deadline API
A flash sale is one deadline that has to appear in at least three places and be enforced in a fourth. The failure everyone has seen is the customer who opens the email, sees eleven minutes left, clicks through, and finds the discount already gone — because the campaign tool and the pricing logic held separate copies of the same date. Reading all four from one timer makes that impossible.
Four surfaces, one date
| Surface | What it needs | Where it comes from |
|---|---|---|
| Landing page | A live countdown | website_embed_html |
| Announcement email | A countdown that is correct when opened | email_embed_html |
| Social and support | A link to send | public_page_url |
| Pricing and checkout | A yes/no answer | GET /timers/{id}/status |
Setting up the campaign
A fixed timer, because the deadline is a moment on the calendar that is the same for everyone. Two rules: one to warn the team, one to flip pricing back.
scheduleFlashSale
// One timer for the whole campaign. Everything reads it.
export async function scheduleFlashSale(campaign) {
const timer = await call("/timers", {
method: "POST",
headers: { "Idempotency-Key": `sale_${campaign.id}` },
body: JSON.stringify({
name: campaign.name,
type: "fixed",
deadline_at: campaign.endsAt, // ISO 8601 with an offset
timezone: "America/New_York", // the zone it was planned in
publish: true,
expiry: {
behavior: "redirect",
redirect_url: "https://example.com/sale-ended",
},
metadata: { campaign_id: campaign.id, discount_code: campaign.code },
}),
});
await db.campaigns.update(campaign.id, { timerId: timer.id });
// Warn the team, then flip pricing back.
await attachRule(timer.id, "One hour left", {
field: "remaining_seconds", operator: "less_than_or_equal", value: 3600,
});
await attachRule(timer.id, "Sale ended", {
field: "status", operator: "equals", value: "ended",
});
return timer;
}expiry.behavior: "redirect" matters more than it looks. Someone always opens the email a day late, and a bare finished clock with no explanation is the one outcome nobody intends — sending them to a “sale ended” page recovers some of that traffic.
Wiring the surfaces
One outputs call, four destinations
const outputs = await call(`/timers/${campaign.timerId}/outputs`);
// The landing page
renderLandingPage({ embed: outputs.website_embed_html });
// The announcement email — rendered when each recipient opens it
await sendCampaign({
segment: "subscribers",
html: `<h1>24 hours only</h1>${outputs.email_embed_html}`,
});
// A link for social and support
share(outputs.public_page_url);
// And your own UI, if you want custom styling
fetch(outputs.json_data_url); // through your backendThe email GIF is the one worth dwelling on: it renders when the recipient opens the message, not when you sent it. A campaign sent at 9am and read at 8pm shows eleven hours less — which is the whole point of putting a countdown in an email rather than a static image of one.
outputs response rather than calling it per page view. Those URLs are stable for a published timer; it is the content behind them that updates.Enforcing the deadline
Two different reads, for two different jobs.
Pricing and checkout
// The countdown is presentation. This is the decision.
export async function priceFor(item, campaign) {
const status = await cache.remember(
`sale:${campaign.timerId}`,
5, // seconds — short enough to never be visibly wrong
() => call(`/timers/${campaign.timerId}/status`),
);
return status.ended ? item.price : item.salePrice;
}
// And at checkout, without the cache:
export async function validateDiscount(code, campaign) {
const status = await call(`/timers/${campaign.timerId}/status`);
if (status.ended) {
throw new DiscountExpiredError("This sale has ended");
}
return applyDiscount(code);
}Why the cache is short, and why checkout skips it
A five-second cache on the pricing read is invisible — nobody notices a countdown being five seconds stale — and it removes almost all the load from a page that might be getting hammered during a sale.
At checkout there is no cache, because that is the moment the deadline gates money. A customer whose browser says thirty seconds remain may be submitting a form that took them two minutes to fill in. One uncached read closes the gap.
Extending the sale
The request that arrives at 11pm on the last night. One call.
extendSale
// "Extend the sale by two hours." One call, every surface.
export async function extendSale(campaign, hours) {
const timer = await call(`/timers/${campaign.timerId}`);
const newDeadline = new Date(
new Date(timer.deadline_at).getTime() + hours * 3600_000,
);
return call(`/timers/${campaign.timerId}`, {
method: "PATCH",
headers: { "If-Match": String(timer.revision) },
body: JSON.stringify({ deadline_at: newDeadline.toISOString() }),
});
}
// The landing page embed, the hosted page, an unopened email,
// the pricing check, and both webhook rules all follow. There
// is nothing else to update and nothing to forget.Every surface follows automatically, including an email sitting unopened in someone's inbox — because the GIF has not been rendered yet. That is the concrete payoff of one source of truth, and it is the scenario where separate systems reliably go wrong.
If-Match carries the current revision, so two people extending simultaneously cannot overwrite each other — the second gets a 409 and retries against the new deadline. See update and delete.Sales that repeat
For a weekly or monthly promotion, use a recurring timer instead of creating a fresh one each cycle. One timer, one ID, one set of embeds that stay correct forever — and one against your monthly allowance rather than fifty-two.
The one thing to remember: set delivery: "repeat" on the rules. A once rule on a recurring timer fires on the first cycle and never again, which is the most common “my automation stopped working” report.
Common questions
Why not just store the sale end date in my database?
You should — it is your campaign data. The timer adds the surfaces around it: a countdown for the page, a live GIF for the email, and a callback when the sale ends. Without it those are three separate implementations of the same date, and the one that gets forgotten during an extension is the one customers see.
How do I extend a sale that is already live?
One PATCH on deadline_at. Every surface reads the same timer, so the landing page, the hosted page, any email that has not been opened yet, and the webhook rules all move together. The If-Match header prevents two people extending it simultaneously and overwriting each other.
Should I check the timer on every page view?
Cache it for a few seconds. A five-second cache is invisible on a countdown ticking in whole seconds and removes almost all the load. At the actual decision point — validating a discount code at checkout — skip the cache and read it directly.
What about customers in different timezones?
A fixed deadline is a single instant, so everyone counts to the same moment regardless of where they are. Send deadline_at with an offset or in UTC; the timezone field just records the zone the campaign was planned in, so your dashboard shows it the way the team thinks about it.
Related
Run a two-minute flash sale
Sandbox is free with any account. Create a fixed timer two minutes out, attach both rules, and watch every surface and both callbacks fire.