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.
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.








location: https://cdn.example.com/sale-over.png
cache-control: no-store, must-revalidate
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.
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 -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.
| Field | Type | Default | Rules |
|---|---|---|---|
| torequired | ISO-8601 | — | Target instant. Naive = UTC; offsets accepted (...Z, +02:00). Math is absolute — recipient TZ doesn't matter. |
| backgroundColor | string | #0A0A0A | Hex #RGB or #RRGGBB. |
| fontColor | string | #FFFFFF | Hex #RGB or #RRGGBB. |
| fontStyle | enum | bold | regular · bold · italic · serif · mono |
| labels | string | Days,Hrs,Min,Sec | Comma-separated 4-tuple, or off to hide. ≤12 chars each. Glyph coverage is font-dependent. |
| collapse | enum | auto | auto hides leading zero units (drops 0d once < 24h, then 0h once < 1h) · none always shows all four. Ignored when units is set. |
| units | enum | auto | auto 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). |
| separator | string | : | Drawn between unit boxes. ≤3 chars. |
| style | enum | boxed | boxed (tinted rounded boxes) · plain (no chrome) · flip (boxed + flap-clock hairline). |
| width | int | 600 | 120–1200. Standard email body width. |
| height | int | 60 | 32–200. |
| expiredText | string | 00:00:00 | Shown once to is in the past. ≤32 chars. |
| expiredRedirect | URL | — | When 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.
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>
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
expiredRedirectis set.
Errors
- 422 — bad hex, malformed labels, units in wrong order, non-http
expiredRedirect, unknown field, missingto.
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.
<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/fontColorwith%23instead of#. - Always include a deadline in
alttext (covers MPP, image blocking, Outlook). - Plan to add a shared
X-API-Keyon/api/v1/*if/when this becomes a paid endpoint.
Don't
- Pass un-trimmed user input as
labelsorexpiredText(size caps will reject extreme cases but trim anyway). - Use
expiredRedirectwith 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.