Nudge Blocks · email render service

Countdown

A live ticking GIF (days / hours / minutes / seconds) to a target moment — for urgency in promo emails. Embed the URL directly with a plain <img>; the endpoint is the image, re-rendered on every open with the current remaining time.

GET /api/v1/countdown.gif animated GIF per-open · no-store
inbox preview

Time-relative, not cached

Every email open re-hits this endpoint and gets a fresh GIF for the current instant. Response carries Cache-Control: no-store, must-revalidate. The GIF then animates for ~60 seconds. There is no config-hash cache — unlike the announcement bar.

Rendered live

Sample countdowns

Each image below is a live response from the endpoint, refreshed on every page load. Exact remaining time shifts as the target approaches.

Default boxed countdown

Default — boxed, dark

Four units with tinted rounded boxes and a colon separator. The full Phase 1 default look.

style boxedsep :600×60
Branded amber-on-slate countdown

Branded — amber on slate

Two hex overrides pick up brand colors without touching layout or palette economy.

bg #111827fg #FBBF24
Plain (no chrome) countdown

Plain — no chrome

Digits sit directly on the background. Smallest of the three styles; cleanest in light themes.

style plain
Flip-style countdown

Flip — flap-clock evocation

Boxed with a thin hairline across the middle of each digit. Static, not an animated flap.

style flipbg #111827
Countdown with labels off

Labels off — bigger digits

Drop the captions for a denser, watch-face look. Digits scale up automatically.

labels off
Spanish labels

Localized labels

Comma-separated 4-tuple under each digit group. Glyph coverage is font-dependent.

labels Días,Hrs,Min,Seg
units=hms override folding days into hours

units = hms — fold days into hours

An explicit subset of dhms overrides the auto-collapse logic. Higher units fold into the largest visible unit, so a 30-day timer renders as ~720 hours instead of dropping to zero.

units hmscollapse ignored
Expired countdown

Expired — fallback text

When to is in the past, the endpoint returns a single static frame using expiredText.

to pastexpiredText SALE ENDED
HTTP / 1.1 302 Found
location: https://cdn.example.com/sale-over.png
cache-control: no-store, must-revalidate

Expired + redirect — 302 to fallback image

When expiredRedirect is set and the target has passed, the endpoint redirects the email client to the caller's fallback image instead of rendering a GIF. http(s) only.

expiredRedirect set302

Reference

Endpoint

Email <img> can only issue GETs, so config arrives as query string. The URL ends in .gif so clients treat the response as an image.

GET https://blocks-api-production.up.railway.app/api/v1/countdown.gif
GETrequest URL (query string)
https://blocks-api-production.up.railway.app/api/v1/countdown.gif?to=2026-06-01T00:00:00Z&backgroundColor=%230A0A0A&fontColor=%23FFFFFF&fontStyle=bold&style=boxed&labels=Days,Hrs,Min,Sec&collapse=auto
cURL
curl -o countdown.gif \
  "https://blocks-api-production.up.railway.app/api/v1/countdown.gif?to=2026-06-01T00:00:00Z&style=flip"

Query parameters

Parameters

Keys accept camelCase or snake_case. Unknown fields are rejected with 422.

FieldTypeDefaultRules
torequiredISO-8601Target instant. Naive = UTC; offsets accepted (...Z, +02:00). Math is absolute — recipient TZ doesn't matter.
backgroundColorstring#0A0A0AHex #RGB or #RRGGBB.
fontColorstring#FFFFFFHex #RGB or #RRGGBB.
fontStyleenumboldregular · bold · italic · serif · mono
labelsstringDays,Hrs,Min,SecComma-separated 4-tuple, or off to hide. ≤12 chars each. Glyph coverage is font-dependent.
collapseenumautoauto hides leading zero units (drops 0d once < 24h, then 0h once < 1h) · none always shows all four. Ignored when units is set.
unitsenumautoauto defers to collapse. Otherwise an explicit subset of dhms in canonical order (hms, ms, s, dhm, …). Forces those exact units and folds higher units into the largest visible one (3d → 72h).
separatorstring:Drawn between unit boxes. ≤3 chars.
styleenumboxedboxed (tinted rounded boxes) · plain (no chrome) · flip (boxed + flap-clock hairline).
widthint600120–1200. Standard email body width.
heightint6032–200.
expiredTextstring00:00:00Shown once to is in the past. ≤32 chars.
expiredRedirectURLWhen set AND to has passed, the endpoint 302s to this URL instead of rendering. http(s) only (javascript: and other schemes rejected).

200 OK / 302 Found

Response

The endpoint is the image — no JSON wrapping. Embed the URL directly in the email.

response · image/gif (200)
HTTP/1.1 200 OK
Content-Type: image/gif
Cache-Control: no-store, must-revalidate

<binary GIF89a bytes — typically 12–25 KB at default size>
response · 302 Found (when expiredRedirect is set + expired)
HTTP/1.1 302 Found
Location: https://cdn.example.com/sale-over.png
Cache-Control: no-store, must-revalidate

Behavior

  • Per open — every request renders fresh; no caching at any layer we control.
  • ~60s of ticking per fetch, then loops back for long countdowns. Next open re-renders.
  • Frame 0 correct — the first frame always shows the correct starting value (Outlook desktop guarantee).
  • Expired → single-frame text by default, or 302 if expiredRedirect is set.

Errors

  • 422 — bad hex, malformed labels, units in wrong order, non-http expiredRedirect, unknown field, missing to.

In the email

Email embed

Drop the endpoint URL straight into a plain <img>. There is no two-step (POST + embed) — the endpoint is the image. Set alt to the deadline in words so the offer survives image blocking and works for MPP-frozen recipients.

HTML
<img src="https://blocks-api-production.up.railway.app/api/v1/countdown.gif?to=2026-06-01T00:00:00Z"
     width="600"
     alt="Sale ends Sunday June 1 at midnight"
     style="display:block;border:0;">

Real-world inboxes

Email client gotchas

Animation and live-image support are wildly uneven across clients. The countdown is built to degrade gracefully in each of these cases.

Outlook desktop (Windows, 2016+)

Renders only the first frame of an animated GIF. No ticking — just the static starting value.

What we guarantee: frame 0 always shows the correct starting time. That's the contract for those recipients.

Apple Mail Privacy Protection

Apple pre-fetches images at send time via Apple's proxy and serves the cached copy on open. For MPP-enabled recipients (a large share of Apple Mail users) the countdown is effectively frozen at the send-time value.

We can't fix this server-side. Treat live accuracy as best-effort, and put the deadline in alt text.

Gmail proxy

Gmail proxies images per-message. Cache-Control: no-store pushes refetch on most opens, but rapid repeated opens within a short window may still see the cached GIF.

Mitigation: the 60s frame budget keeps the timer ticking for the duration of a typical glance.

Works as expected

Gmail web + Android/iOS app, Outlook for Mac / iOS / web, Yahoo, Fastmail, most modern webmail. These honor animation and (where they don't aggressively proxy) refetch on open.

Always test in Gmail and Outlook desktop specifically before a campaign.

Internal use only

Access & security

This service is for Nudge's backend, server-to-server. It is not a public API and should not be reachable by browsers or end users.

Current state: unauthenticated

Anyone who knows the URL can hit it. Email clients on the recipient's machine will fetch this directly when the email is opened, so the URL does end up in inboxes — but the email-render call from Nudge's backend should also be server-side only, behind your own auth.

Do

  • Generate the URL on the Nudge backend and bake it into the email at render time.
  • Encode backgroundColor / fontColor with %23 instead of #.
  • Always include a deadline in alt text (covers MPP, image blocking, Outlook).
  • Plan to add a shared X-API-Key on /api/v1/* if/when this becomes a paid endpoint.

Don't

  • Pass un-trimmed user input as labels or expiredText (size caps will reject extreme cases but trim anyway).
  • Use expiredRedirect with a URL you don't fully control — it's reachable by every recipient.
  • Publish this page on a public domain (it's marked noindex).
  • Cache the endpoint at a CDN — it must refetch per open.

Future hardening

A 1-second micro-cache keyed on (normalized_params, floor(now, 1s)) would absorb campaign-blast bursts essentially for free. Deferred until we see real traffic patterns.