Trial expiry countdown API

A fourteen-day trial is a deadline that differs for every customer, has to be visible in three places, and must trigger something whether or not anyone logs in. That combination is what personalized timers exist for: one timer per customer, created at signup, driving the in-app banner, the reminder emails, and the downgrade — all from a single value.

Why this is more than a database column

Most teams start with trial_ends_at on the customer row, and they should keep it — it is your data. The question is what happens around it.

What you needWith a column aloneWith a timer
Know when it expiresYesYes
Act at the deadlineA cron sweep you build and monitorA webhook at zero
Remind at 7, 3, and 1 daysThree more sweeps, or one with branchingThree rules on the same timer
Show a countdown in-appBuild it, and handle clock driftEmbed, or the status endpoint
Put a live countdown in emailBuild an image rendereremail_embed_html
Extend a trial by a weekUpdate the column, then re-derive everythingOne PATCH
The bottom row is the one that catches teams late. Once the deadline exists in a column, a cron job, an email template, and an in-app banner, extending a trial means updating four things — and the one someone forgets is the email that goes out saying the trial already ended.

Creating the timer at signup

startTrial

// One timer per customer, created at signup.
export async function startTrial(customer) {
  const timer = await call("/timers", {
    method: "POST",
    // Derived from the customer. A retried signup returns the
    // original timer instead of quietly granting 28 days.
    headers: { "Idempotency-Key": `trial_${customer.id}` },
    body: JSON.stringify({
      name: `Trial — ${customer.email}`,
      type: "personalized",
      duration_seconds: 14 * 24 * 60 * 60,
      external_user_id: customer.id,
      publish: true,
      expiry: {
        behavior: "redirect",
        redirect_url: "https://example.com/upgrade",
      },
      metadata: { plan: customer.plan, source: customer.source },
    }),
  });

  await db.customers.update(customer.id, { trialTimerId: timer.id });
  return timer;
}

external_user_id is your identifier for the customer. It comes back on every webhook delivery, which is how the handler knows who the event is about without a lookup table.

The idempotency key is the most important line here. Signup flows get retried — a timeout, a double-submitted form, a queued job re-running — and without a stable key each retry creates another fourteen-day timer. Derived from the customer ID, the original comes back instead. See idempotency.

The reminder ladder

Four rules on one timer, all pointing at one destination. Rules are unlimited on every plan, so the ladder costs nothing beyond the requests to create it.

Attaching milestones

// Four rules, one timer, one destination. Rules are unlimited
// on every plan, so a reminder ladder costs nothing extra.
const MILESTONES = [
  { name: "7 days left", seconds: 7 * 86400 },
  { name: "3 days left", seconds: 3 * 86400 },
  { name: "1 day left", seconds: 86400 },
];

for (const milestone of MILESTONES) {
  await call(`/timers/${timer.id}/webhook-rules`, {
    method: "POST",
    body: JSON.stringify({
      name: milestone.name,
      webhook_destination_id: DESTINATION_ID,
      condition: {
        field: "remaining_seconds",
        operator: "less_than_or_equal",
        value: milestone.seconds,
      },
      delivery: "once",
    }),
  });
}

await call(`/timers/${timer.id}/webhook-rules`, {
  method: "POST",
  body: JSON.stringify({
    name: "Trial ended",
    webhook_destination_id: DESTINATION_ID,
    condition: { field: "status", operator: "equals", value: "ended" },
    delivery: "once",
  }),
});

Because they are rules on a deadline rather than separately scheduled jobs, extending the trial later moves all of them at once. That is the structural advantage over scheduling four callbacks up front.

Handling the deliveries

One endpoint, branching on rule.name

// One endpoint, branching on the rule name you chose.
app.post("/webhooks/countdownshare", raw, async (req, res) => {
  if (!verify(req.body, req.headers, SECRET)) return res.sendStatus(401);
  res.sendStatus(200);

  const event = JSON.parse(req.body.toString());
  const customerId = event.timer.external_user_id;

  await queue.add(
    "trial-milestone",
    { rule: event.rule.name, customerId },
    { jobId: event.id },   // at-least-once delivery
  );
});

async function handleTrialMilestone({ rule, customerId }) {
  const customer = await db.customers.find(customerId);

  // Someone who already upgraded should not get "3 days left".
  if (!customer || customer.plan !== "trial") return;

  switch (rule) {
    case "7 days left":
    case "3 days left":
    case "1 day left":
      return sendReminder(customer, rule);
    case "Trial ended":
      return downgrade(customer);
  }
}

The plan check

if (customer.plan !== "trial") return; is doing quiet but important work. A customer who upgraded yesterday should not receive “3 days left” today, and deliveries can be in flight when the upgrade lands. Branching on your own current state rather than on the event alone makes the handler correct regardless of timing.

jobId: event.id deduplicates. Delivery is at-least-once, so a retry after a timeout on your side — or a manual replay — resends the same event. Sending the same reminder email twice is the kind of bug customers notice.

The countdown inside the email

This is the part that is genuinely hard to build and easy to get here. The GIF is rendered when the recipient opens the message, showing their own remaining time.

Reminder email

// The reminder email, with the customer's own countdown in it.
const outputs = await call(`/timers/${customer.trialTimerId}/outputs`);

await sendEmail({
  to: customer.email,
  subject: "Your trial ends soon",
  html: `
    <p>Hi ${customer.firstName},</p>
    <p>Your trial ends in:</p>
    ${outputs.email_embed_html}
    <p><a href="https://example.com/upgrade">Upgrade now</a></p>
  `,
});

// The GIF renders when they open the message — so an email sent
// this morning and read tonight shows tonight's remaining time.

A reminder sent at 9am and read at 11pm shows fourteen hours less than it would have that morning — because the image is generated at open time rather than send time. See embeds and hosted pages.

Extending a trial

Support gives someone another week. One PATCH, and every surface follows.

extendTrial

// Support extends a trial by a week. One PATCH, and every
// surface updates: the in-app banner, the hosted page, the GIF
// in an email they have not opened yet, and the expiry webhook.
export async function extendTrial(customer, extraDays) {
  const timer = await call(`/timers/${customer.trialTimerId}`);

  return call(`/timers/${customer.trialTimerId}`, {
    method: "PATCH",
    headers: { "If-Match": String(timer.revision) },
    body: JSON.stringify({
      duration_seconds: timer.duration_seconds + extraDays * 86400,
    }),
  });
}

The If-Match header carries the current revision, so two support agents extending the same trial at once cannot silently overwrite each other — the second gets a 409 and retries against the new value. See update and delete.

Enforcing access

The webhook downgrades the account at zero, which handles the normal case. For a feature check in the moment, read the status endpoint — or better, rely on the plan field your webhook already updated, and treat the timer as the thing that changes it rather than the thing you query on every request.

Common questions

Why a personalized timer rather than storing trial_ends_at?

You should store trial_ends_at too — it is your data. The timer adds three things a column cannot: a callback at the deadline and at milestones before it, a rendered countdown for the app and email that reads the same clock, and a single value to change when support extends a trial. Without it those are three separate systems that drift.

Does one timer per customer get expensive?

It is one timer per trial, and only creation is metered — reads, updates, and the reminder webhooks are free and unlimited. So the cost tracks new signups rather than usage. Check your expected signup volume against the plan allowances before building on this.

What if the customer upgrades before the trial ends?

Delete or archive the timer, and check the plan inside the webhook handler as shown above. The check matters because a delivery may already be in flight when the upgrade lands, and nobody wants a "3 days left" email an hour after paying.

Can I use this for annual renewals too?

A personalized timer maxes out at one year per timer, so a 365-day window fits but a multi-year one does not. For renewal reminders a fixed timer created a few weeks ahead of the renewal date is usually the better shape anyway — the deadline is a known calendar date rather than elapsed time.

Related

Try it with a two-minute trial

Sandbox is free with any account. Create a personalized timer with duration_seconds set to 120 and watch the whole ladder fire in two minutes instead of two weeks.