Product launch countdown API

A launch date usually ends up written down in four places: the waitlist page, the announcement email, a calendar invite, and someone's head. Then it slips by two days and three of them get updated. Driving the whole launch from one timer means the countdown customers see, the reminders your team gets, and the release itself all move together.

What a launch actually needs

SurfaceWhat it doesWhere it comes from
Waitlist pageCounts down, then sends people to the productwebsite_embed_html + expiry redirect
Announcement emailA countdown correct when opened, not when sentemail_embed_html
Internal warningsSlack at one week, one day, one hourWebhook rules on remaining_seconds
The releaseFlip the flag at zeroWebhook rule on status ended
The fourth row is the one teams tend not to consider. If the feature flag is flipped by a person and the countdown is driven by a date in a CMS, launch day contains a window where the page says “live now” and the product is not.

Setting up the launch

scheduleLaunch

export async function scheduleLaunch(launch) {
  const timer = await call("/timers", {
    method: "POST",
    headers: { "Idempotency-Key": `launch_${launch.id}` },
    body: JSON.stringify({
      name: launch.name,
      type: "fixed",
      deadline_at: launch.goLiveAt,
      timezone: "America/Los_Angeles",   // the zone the team plans in
      publish: true,
      expiry: {
        behavior: "redirect",
        redirect_url: launch.productUrl,  // waitlist page -> product
      },
      metadata: { launch_id: launch.id },
    }),
  });

  await db.launches.update(launch.id, { timerId: timer.id });
  return timer;
}

The expiry redirect is doing more work than it looks. After launch, the waitlist URL keeps circulating — in old tweets, in newsletters, in someone's bookmarks — and sending that traffic to the product beats showing it a countdown at zero.

Warnings, then the launch

Four rules

// Internal warnings first, then the launch itself.
const RULES = [
  { name: "One week out", field: "remaining_seconds", value: 7 * 86400 },
  { name: "24 hours out", field: "remaining_seconds", value: 86400 },
  { name: "One hour out", field: "remaining_seconds", value: 3600 },
];

for (const rule of RULES) {
  await attachRule(timer.id, rule.name, {
    field: rule.field,
    operator: "less_than_or_equal",
    value: rule.value,
  });
}

await attachRule(timer.id, "Launch", {
  field: "status", operator: "equals", value: "ended",
});

Rules are unlimited on every plan, so there is no reason to be sparing. A launch with internal warnings at a week, a day, and an hour, plus the release itself, is four rules on one timer.

The handler

handleLaunchEvent

async function handleLaunchEvent(event) {
  const launch = await db.launches.findByTimerId(event.timer.id);
  if (!launch || launch.status === "live") return;   // already shipped

  switch (event.rule.name) {
    case "One week out":
      return slack("#launch", "One week to go. Freeze the changelog.");

    case "24 hours out":
      await slack("#launch", "24 hours. Final go/no-go in the morning.");
      return emailWaitlist(launch, "tomorrow");

    case "One hour out":
      return slack("#launch", "One hour. Someone should be watching dashboards.");

    case "Launch":
      // The actual release, fired by the deadline rather than by
      // someone remembering to press a button at 09:00.
      await db.launches.update(launch.id, { status: "live" });
      await featureFlags.enable(launch.flagKey);
      await emailWaitlist(launch, "now");
      return slack("#launch", "We are live.");
  }
}

Why the state check is first

if (launch.status === "live") return; guards against two things: shipping early and then receiving the scheduled launch event anyway, and a replayed delivery re-running the release. Delivery is at-least-once, so a handler that flips flags needs to be safe to run twice.

Firing the release from the deadline rather than from a person means the countdown your customers are watching and the moment the product appears are the same event. There is no window where the page says live and the flag is still off.

The waitlist page

Rendering the countdown

// The waitlist page. One embed, correct for every visitor.
const outputs = await call(`/timers/${launch.timerId}/outputs`);

// Before launch: a countdown. After: the redirect in expiry
// sends visitors to the product page automatically, so the
// waitlist URL keeps working after launch day.
render({
  countdown: outputs.website_embed_html,
  shareUrl: outputs.public_page_url,
});

The embed reads the server clock, so a visitor with a wrong device clock sees the correct time remaining — which matters on a launch page more than most, because it is often the first thing a new visitor ever sees from you.

When it slips

It will. This is the part that justifies the whole approach.

moveLaunch

// The launch slips by two days. One call.
export async function moveLaunch(launch, newDate) {
  const timer = await call(`/timers/${launch.timerId}`);

  return call(`/timers/${launch.timerId}`, {
    method: "PATCH",
    headers: { "If-Match": String(timer.revision) },
    body: JSON.stringify({ deadline_at: newDate.toISOString() }),
  });
}

// What follows automatically:
//   - the waitlist page countdown
//   - the hosted page and any shared link
//   - the GIF in an announcement email nobody has opened yet
//   - all four webhook rules, re-evaluated against the new date
//
// Nothing else to update. Nothing to forget.

The unopened announcement email is the detail worth noticing. Its countdown has not been rendered yet — the GIF is generated when the recipient opens it — so a message sent before the slip still shows the correct new date when it is read afterwards. No separate system would get that right.

Common questions

Why let a webhook trigger the release rather than deploying at the right time?

Because "someone presses deploy at 09:00" is a plan that depends on a person being awake, online, and not in a meeting. A rule on the deadline flips the feature flag at the moment the countdown your customers are watching reaches zero — so the page, the email, and the product all change together.

What if the launch slips?

One PATCH on deadline_at. The waitlist countdown, the hosted page, any unopened announcement email, and every webhook rule move with it. That is the main argument for a single timer over a date copied into four systems — the fourth one is always the one nobody updates.

How do I stop the launch webhook firing if we ship early?

Check your own state at the top of the handler, as the example does — if the launch is already live, return. Deleting or archiving the timer also stops future deliveries, but a delivery may already be in flight, so the state check is the reliable safeguard.

Can the waitlist page keep working after launch?

Yes, and it should. Set expiry.behavior to redirect with the product URL. Anyone who follows an old link after launch day lands on the product instead of a finished countdown, which recovers traffic that would otherwise bounce.

Related

Rehearse the launch in Sandbox

Free with any account. Set the deadline five minutes out and watch every warning and the release itself fire in order.