Personalized countdown timers

A personalized timer gives each person their own deadline. You supply an identifier — your user ID, a hashed email, a CRM record — and the expiry is fixed for that identity at the moment the timer is created. Someone who signs up on Tuesday and someone who signs up on Friday each get a full fourteen days, from their own start. This is the evergreen-deadline pattern, and it is the timer type that is hardest to build yourself.

How it differs from the others

A fixed countdown ends at the same instant for everyone. A duration timer counts elapsed time but has to be started and can be paused. A personalized timer starts itself, at creation, per identity — and cannot be paused, because the whole point is that the deadline is settled.

FixedDurationPersonalized
Deadline isOne shared instantElapsed time from startElapsed time, per identity
StartsImmediatelyWhen you send startAt creation
Can be pausedNoYesNo
Same for every viewerYesYesNo
Needs an identifierNoNoYes
One timer, one identity. Ten thousand customers on a fourteen-day trial means ten thousand personalized timers, and each counts against your monthly allowance. Check the plan limits against your expected signup volume before building on this.

Creating one

Both duration_seconds and external_user_id are required. The identifier is 1–255 characters and entirely yours — the API stores it and returns it, and does not interpret it.

A fourteen-day trial countdown

curl -X POST https://countdownshare.com/api/v1/timers \
  -H "Authorization: Bearer $COUNTDOWNSHARE_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: trial_customer_8f21c" \
  -d '{
    "name": "Trial ends",
    "type": "personalized",
    "duration_seconds": 1209600,
    "external_user_id": "customer_8f21c",
    "publish": true,
    "expiry": {
      "behavior": "redirect",
      "redirect_url": "https://example.com/upgrade"
    }
  }'
FieldRequiredNotes
typeYesMust be "personalized".
duration_secondsYesThe window each person gets. 1 to 31,536,000 seconds.
external_user_idYesYour identifier for the recipient. 1–255 characters.
publishNoProduction only. Required if you want a page, embed, or email GIF.
expiryNoWhat the page shows afterwards. A redirect to an upgrade page is the common choice.
metadataNoPlan, cohort, campaign — anything that helps you trace it back later.

Choosing the identifier

It has to be stable. If it changes, you lose the link between the person and their countdown — and the API cannot help you find it again.

Good identifiers

  • Your database primary key — customer_8f21c
  • A stable CRM or billing record ID
  • A hash of the email address, if you would rather not store the address here

Identifiers that will hurt

  • A raw email address, if people can change theirs
  • A session ID or cookie value — gone on the next device
  • Anything containing a timestamp
  • A sequential number reused across environments
Store the returned timer ID against your customer record. The identifier is what you sent us; the timer ID is what every endpoint takes. Keeping both means you never have to search for a countdown.

Wiring it into signup

Create the timer at the moment the window should start, and make the create idempotent against the customer. This is the single most important detail in the integration: without a stable key, a retried signup request creates a second timer and quietly hands someone a twenty-eight day trial.

Create on signup

// On signup: one timer, created once, for this customer.
//
// The idempotency key is derived from the customer, so a retry
// after a timeout returns the original timer instead of giving
// them a second, longer trial.
export async function startTrial(customer) {
  const { data: timer } = await call("/timers", {
    method: "POST",
    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,
      metadata: { plan: customer.plan, source: customer.source },
    }),
  });

  // Store the ID. It is how you find this customer's countdown later.
  await db.customers.update(customer.id, { countdownTimerId: timer.id });

  return timer;
}

See idempotency for why the key must be derived from the customer rather than generated at the call site.

The part that is genuinely hard elsewhere

A personalized deadline inside an email body, rendering the recipient's own remaining time, at the moment they open it. Not a static image baked at send time — a live animated GIF.

Per-recipient countdown in an email

// The GIF URL goes straight into the email body. When the
// recipient opens it — today or in three days — the GIF is
// rendered then, showing THEIR remaining time.
const { data: outputs } = await call(`/timers/${timer.id}/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 is generated when the inbox requests it, which is why it is correct whether the email is opened immediately or three days later. It needs no JavaScript, which is what makes it work in Outlook, Gmail, and Apple Mail alike. Full detail under embeds and hosted pages.

The timer must be published in Production for outputs to be populated. Sandbox timers always return null — there is no public rendering in Sandbox, by design.

Enforcing the deadline

The countdown is a display. The decision about whether someone still has access belongs on your server, checked at the moment it matters.

Check before granting access

// Do not decide "expired" from the email or the page.
// Check it server-side at the moment it matters.
export async function canAccessTrialFeature(customer) {
  const { data } = await call(`/timers/${customer.countdownTimerId}/status`);
  return !data.ended;
}

Better still, do not poll at all — attach a webhook rule on status equals "ended" and downgrade the account when we call you. One request at signup, one callback at expiry, and nothing in between. Webhook rules.

Where this fits

Free trials

Fourteen days from signup, shown in-app and in the reminder emails, expiring on the same clock your billing uses.

Onboarding windows

"Complete setup within 7 days for a bonus" — per user, starting when they actually joined.

Email offers

A 48-hour discount that starts when the recipient opens the email rather than when you pressed send.

Affiliate and evergreen funnels

Each visitor sees a genuine deadline of their own, computed on the server rather than in a cookie they can clear.

Abandoned cart recovery

A per-customer window on a saved basket, with the countdown in the recovery email.

Invitation expiry

A team invite valid for 72 hours from the moment it was sent, per invitee.

The common thread: the deadline is real, server-computed, and the same number everywhere the customer sees it — in the app, in the email, and in whatever your backend decides. A cookie-based evergreen timer agrees with none of those the moment someone opens a second browser.

Reassigning and extending

external_user_id is editable, so a timer can be moved to a different identity — useful when an account is merged. duration_seconds is editable too, which is how you extend a trial without creating a second timer and without it counting against your allowance again.

Both need the current revision in If-Match. Extending changes the window from the original start, not from now, which is usually what “give them another week” is meant to do — check that assumption against your own intent. See update and delete.