EmailTimer.App REST API reference
32 endpoints across templates, assets, signed image URLs, recipient sets and usage. Bearer key authentication, two scopes, and a rate limit you can read off the response headers.
Included on Growth and Agency. Everything else in the product works without it.
Compare the plansBase URL
Every route below is relative to a workspace. The workspace id appears in the path and also comes from the key, and the two have to agree.
Images are served from a different host, which takes no API key at all because the request arrives from a subscriber's mail client:
Authentication
Create a key in your workspace settings and send it as a bearer token. A key is shown once. What is
stored is a SHA-256 digest plus a non-secret prefix of the form
et_live_9fK2mQ4x,
so a key that turns up in a log can be recognised and revoked without anybody needing the secret half.
curl https://api.emailtimer.app/api/v1/workspaces/$WORKSPACE_ID/ping \
-H "Authorization: Bearer $EMAILTIMER_API_KEY"
Scopes
A key carries read,
write, or both.
A key does not inherit the permissions of whoever created it, which is the point: an owner's key would
otherwise be able to do everything an owner can.
read
- workspace:read
- template:read
- asset:read
- recipients:read
- usage:read
write, in addition
- template:write
- template:publish
- asset:write
- recipients:write
What no key can do, whatever its scopes
- Changing or deleting the workspace itself
- Reading, inviting or removing members
- Rotating the signing secret that every delivered image depends on
- Reading or changing billing
- Creating further API keys
Rate limits
Per key, as a token bucket over a rolling 60 second window. 60 requests a minute on most routes, and 600 on the two bulk routes, because one call there covers up to 10,000 recipients.
RateLimit-Limit: 60
RateLimit-Remaining: 43
RateLimit-Policy: 60;w=60
Retry-After: 12 # only on a 429
Read the headers rather than counting requests yourself. They are on every response, including the successful ones, so you can slow down before you are refused.
Endpoints 32 in total
Keys and usage
Two calls worth making first: one proves the key works, the other says how much of your plan is gone.
Templates
A template is a draft until you publish it. Publishing appends an immutable version, so a campaign already sent against version 2 keeps rendering version 2 forever, whatever you do to the draft afterwards.
Assets
Fonts, logos and product images that templates draw. An asset a template references cannot be deleted.
Signed image URLs
Minting a URL changes nothing in your workspace, so it needs only a read key. The batch call takes up to 10,000 recipients per request, which is why it carries a higher rate limit than everything else.
Recipient sets
Upload a CSV once and export one signed URL per row. The error report is per row, so a file with three bad dates in 40,000 lines tells you which three.
The Usage screen
What the Usage screen in the dashboard shows: opens, unique recipients and the email client breakdown for the period, as JSON or as a CSV you can hand to a spreadsheet. The paths say analytics.
Minting a batch from code
POST /urls/batch is the call most integrations make: one signed image URL per recipient, up to
10,000 in a request, returned in the order they were sent. Both examples read the workspace id and a key from
the environment, split a longer list into requests of 10,000, wait out a 429 for as long as
Retry-After says, and print one CSV line per recipient for importing into a contact field.
Minting changes nothing in the workspace, so retrying a request is safe.
Replace k7mQ2pXd with your template's public id. The deadlines are examples, for a template whose
timer counts to a date from your list. The items in a request are minted or refused together, so one malformed
item refuses its whole request and the refusal names it. A deadline is not read when it is minted, so check
the dates first: one the renderer cannot read shows the template's fallback image instead of a timer.
Node
// Node 18 or later. Save it as mint.mjs, because the await at the end only runs in an ES module.
// One signed image URL per recipient, 10,000 per request.
const API = 'https://api.emailtimer.app/api/v1';
const WORKSPACE_ID = process.env.EMAILTIMER_WORKSPACE_ID;
const API_KEY = process.env.EMAILTIMER_API_KEY; // a key with read scope is enough
const TEMPLATE_PUBLIC_ID = 'k7mQ2pXd';
const BATCH_SIZE = 10_000;
async function mintBatch(items) {
for (;;) {
const response = await fetch(`${API}/workspaces/${WORKSPACE_ID}/urls/batch`, {
method: 'POST',
headers: { Authorization: `Bearer ${API_KEY}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ items })
});
if (response.status === 429) {
const seconds = Number(response.headers.get('Retry-After')) || 1;
await new Promise((resolve) => setTimeout(resolve, seconds * 1000));
continue;
}
const body = await response.json();
if (!response.ok) throw new Error(`${response.status} ${body.code}: ${body.detail}`);
return body.urls.map((result) => result.url);
}
}
async function mintUrls(recipients) {
const urls = [];
for (let start = 0; start < recipients.length; start += BATCH_SIZE) {
const items = recipients.slice(start, start + BATCH_SIZE).map((recipient) => ({
templatePublicId: TEMPLATE_PUBLIC_ID,
params: { deadline: recipient.deadline }
}));
urls.push(...(await mintBatch(items)));
}
return urls;
}
const recipients = [
{ email: 'ana@example.com', deadline: '2026-11-29T14:05:00Z' },
{ email: 'ben@example.com', deadline: '2026-11-30T09:00:00Z' }
];
const urls = await mintUrls(recipients);
recipients.forEach((recipient, i) => console.log(`${recipient.email},${urls[i]}`));
Python
# Python 3, standard library only. One signed image URL per recipient, 10,000 per request.
import json
import os
import time
import urllib.error
import urllib.request
API = "https://api.emailtimer.app/api/v1"
WORKSPACE_ID = os.environ["EMAILTIMER_WORKSPACE_ID"]
API_KEY = os.environ["EMAILTIMER_API_KEY"] # a key with read scope is enough
TEMPLATE_PUBLIC_ID = "k7mQ2pXd"
BATCH_SIZE = 10_000
def mint_batch(items):
request = urllib.request.Request(
f"{API}/workspaces/{WORKSPACE_ID}/urls/batch",
data=json.dumps({"items": items}).encode("utf-8"),
headers={
"Authorization": f"Bearer {API_KEY}",
"Content-Type": "application/json",
"User-Agent": "my-sender/1.0",
},
method="POST",
)
while True:
try:
with urllib.request.urlopen(request) as response:
return [result["url"] for result in json.load(response)["urls"]]
except urllib.error.HTTPError as error:
if error.code == 429:
time.sleep(int(error.headers.get("Retry-After") or 1))
continue
problem = json.load(error)
raise RuntimeError(f"{error.code} {problem['code']}: {problem['detail']}") from None
def mint_urls(recipients):
urls = []
for start in range(0, len(recipients), BATCH_SIZE):
items = [
{"templatePublicId": TEMPLATE_PUBLIC_ID, "params": {"deadline": recipient["deadline"]}}
for recipient in recipients[start:start + BATCH_SIZE]
]
urls.extend(mint_batch(items))
return urls
recipients = [
{"email": "ana@example.com", "deadline": "2026-11-29T14:05:00Z"},
{"email": "ben@example.com", "deadline": "2026-11-30T09:00:00Z"},
]
for recipient, url in zip(recipients, mint_urls(recipients)):
print(f"{recipient['email']},{url}")
Both were run as printed against a local copy of the API on 17 September 2026, with its own address and template id in place of these. Each minted two URLs, and each URL rendered a timer.
Errors
Errors come back as
application/problem+json
with a machine-readable code,
a type URL,
the status repeated in the body, and a sentence a human can act on. A real 401 looks like this:
{
"type": "https://emailtimer.app/problems/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Send a valid EmailTimer.App API key as \"Authorization: Bearer et_live_...\".",
"code": "unauthorized"
}
The image endpoint is the exception and never appears in this table. It answers HTTP 200 with a valid image whatever is wrong, because an error there is a broken image in a message that has already been delivered. That rule is described in how it works.
Machine-readable definition
An OpenAPI 3.1 document is served, live, at
https://api.emailtimer.app/api/v1/openapi.json.
It is generated from the same route table that registers these routes and attaches their scope guards,
so a route missing from the document, or a document entry with no route behind it, fails a test rather
than reaching you.
Where to go next
- Generate email countdown timers from your own code walks through a key check, one address, a transactional deadline and a batch, in order.
- Merge tags and dynamic image personalization gives the query parameters a signed URL carries.
- Email countdown timer types explains the six deadline models a template can use.
- The platform setup guides are the path that needs no code at all, for the campaigns that do not need one.
- Countdown timers in Apple Mail matters more to accuracy than anything on this page.
API questions
Which plans include API access?
Growth at $49 a month and Agency at $149. Free and Starter do not include it. The rest of the product does not need it: the editor, the CSV upload and the ESP snippet cover the normal path, and the API exists for teams who would rather generate images from their own code.
What can a leaked key actually do?
Read and change template content, assets and recipient sets in one workspace, and mint signed image URLs for it. It cannot invite or remove members, change or delete the workspace, read billing, rotate the image signing secret, or create another key. Those permissions are refused whatever scopes the key carries, so a leaked key is a content incident rather than an account takeover, and revoking it is enough.
How are the rate limits counted?
Per key, as a token bucket over a rolling 60 second window. Most routes allow 60 requests a minute. The two bulk routes, POST /urls/batch and POST /recipient-sets/{setId}/urls, allow 600, because one call there covers up to 10,000 recipients. Every response carries RateLimit-Limit, RateLimit-Remaining and RateLimit-Policy, so you can back off before you are refused rather than after.
Does the image endpoint use the same API key?
No. Image URLs are signed rather than authenticated, because the request comes from a stranger’s mail client rather than from your server. That endpoint also never returns an error status. Whatever is wrong with a request, it answers HTTP 200 with a valid image, because the alternative is a permanently broken image in a message that has already been delivered and cannot be recalled.
Is there an OpenAPI document?
Yes, at /api/v1/openapi.json. It is generated from the same route table that registers the routes and mounts the scope guards, so a route that exists but is undocumented, or documented but not registered, fails a test rather than reaching production.
Are there webhooks?
Webhooks are a Growth and Agency feature configured in the dashboard, not something the public API registers. If you were looking for POST /v1/webhooks, it does not exist. An earlier version of this page said it did, which was wrong.
The API reference covers the endpoints, and pricing covers what each plan includes.