EmailTimer.App

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 plans

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

https://api.emailtimer.app/api/v1/workspaces/{workspaceId}

Images are served from a different host, which takes no API key at all because the request arrives from a subscriber's mail client:

https://img.emailtimer.app/i/{publicId}.{gif|png}?w={workspacePublicId}&v={version}&sig={hmac}

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.

Method Path Scope What it does
GET /ping read Check a key and see which workspace it opens.
GET /usage read Counts and quota for the current billing period.

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.

Method Path Scope What it does
POST /templates write Create a draft template.
GET /templates read List templates.
GET /templates/{id} read Fetch one template.
PATCH /templates/{id} write Update a draft template.
DELETE /templates/{id} write Soft delete a template.
POST /templates/{id}/archive write Archive a template.
POST /templates/{id}/restore write Restore an archived template.
POST /templates/{id}/duplicate write Duplicate a template.
POST /templates/{id}/publish write Publish a new immutable version.
POST /templates/{id}/unpublish write Stop new URL generation without touching versions already published.
GET /templates/{id}/versions read List published versions.
GET /templates/{id}/versions/{version} read Fetch one published version.

Assets

Fonts, logos and product images that templates draw. An asset a template references cannot be deleted.

Method Path Scope What it does
GET /assets read List uploaded assets.
GET /assets/{id} read Fetch one asset record.
GET /assets/{id}/content read Download an asset.
POST /assets write Upload an asset as raw bytes.
DELETE /assets/{id} write Delete an unreferenced asset.

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.

Method Path Scope What it does
POST /urls read Mint one signed image URL.
POST /urls/batch read Mint up to 10,000 signed image URLs in one call.

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.

Method Path Scope What it does
POST /recipient-sets write Create a recipient set.
GET /recipient-sets read List recipient sets.
GET /recipient-sets/{setId} read Fetch one recipient set.
POST /recipient-sets/{setId}/upload write Upload a CSV of recipients.
GET /recipient-sets/{setId}/rows read Page through the rows of a set.
GET /recipient-sets/{setId}/errors read Download the per-row import error report.
DELETE /recipient-sets/{setId} write Delete a recipient set.
GET /recipient-tokens/{token} read Look a recipient up by their token.
POST /recipient-sets/{setId}/urls read Stream one signed URL per recipient as CSV.

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.

Method Path Scope What it does
GET /analytics read Fetch the usage summary.
GET /analytics.csv read Download the usage summary as CSV.

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"
}
Status Code When
400 bad_request A field is missing or malformed, or a date is not a valid instant.
401 unauthorized No Authorization header, or a key that is unknown, revoked or expired.
402 payment_required The key is valid, but this workspace is not on a plan that includes the API. Growth and Agency are.
403 insufficient_scope The key is valid but its scopes do not cover this route.
404 not_found The resource does not exist, or the workspace in the path is not the workspace this key opens. A key aimed at somebody else’s workspace gets a 404 rather than a 403, because a 403 would confirm that the workspace exists.
409 conflict The change collides with the current state, such as publishing a template with no draft changes.
413 payload_too_large An upload or a batch exceeds its limit.
415 unsupported_media_type The Content-Type is not one this route accepts.
422 unprocessable The request parsed but the document it describes is not valid, such as a template layer referencing an asset that is gone.
429 rate_limited Over the per-key limit for this route. Retry-After says how long to wait.
503 unavailable A dependency is down. Safe to retry with backoff.

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

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.