Emailens
REST API

Pre-send gate

One boolean and the reasons, for CI, Zapier or a send script.

POST /api/gate answers one question: is this email safe to send?

curl -X POST https://emailens.dev/api/gate \
  -H "Authorization: Bearer ek_live_..." \
  -H "Content-Type: application/json" \
  -d '{"html":"<table>...</table>","ref":"emails/welcome.html"}'
{
  "passed": false,
  "overallScore": 62,
  "worstClient": "outlook-windows",
  "baseline": { "ref": "emails/welcome.html", "comparedTo": "2026-08-30T09:12:04.000Z" },
  "failures": [
    { "family": "compatibility", "check": "threshold",
      "clientId": "outlook-windows", "score": 43, "threshold": 70 }
  ]
}

Requires an API key on the Pro plan.

What blocks a send

familycheckFails when
compatibilitythresholda client scores below threshold (default 70)
compatibilityclient-errorsa client has one or more errors
contentlinksa link goes nowhere: empty, javascript:, or a placeholder
contentsizeGmail will clip the email
contentalt-texta real image has no alt text (tracking pixels are ignored)
contentlink-unreachablea link returned 4xx or 5xx (opt-in, see below)
regressionworse-than-baselinethis version introduced an error or warning the last passing one did not

Narrow with checks. {"checks":["compatibility"]} reproduces the GitHub Action's verdict exactly.

Link checking is static by default. We never fetch your links, so links catches an href nobody replaced, not a URL that returns 404. Set resolveLinks to check destinations for real.

{"resolveLinks": true} fetches each http(s) link and reports the ones that answer 4xx or 5xx as link-unreachable.

It is off by default because it is the only part of the gate that costs seconds and network: a gate running on every push should not fire dozens of requests per file uninvited. Expect it to add up to 10 seconds.

A link we could not check never fails the gate. Duplicates are fetched once, at most 25 distinct URLs are checked, and anything past that cap, past the time budget, or that we decline to fetch is reported as unchecked rather than broken. A gate that fails because our network was slow is a false positive, and a gate that cries wolf gets deleted from the workflow.

We send HEAD first and fall back to GET only when a server rejects it, and we never read the response body.

Regression gating and ref

ref is any stable name for this email: a file path in CI, a campaign name in a Zap. We store the last version that passed under that name and compare against it.

A run with no stored baseline always passes, because there is nothing to be worse than. So a typo in ref silently disables regression checking. Check baseline.comparedTo in the response: null means no comparison happened.

updateBaseline controls when the stored version advances:

ValueBehaviour
"on-pass"default. Only a passing run advances the baseline
"always"accept this version even if it failed
"never"pure read, store nothing. Use when tuning a threshold

The default is what makes this a ratchet. If a failing run advanced the baseline, re-running the build would clear the regression.

Only error and warning severities block. Info-level notes still appear in a normal preview, but they do not stop a send: adding one CSS property can introduce a dozen of them on clients that still score 100, and a gate that fails for those is one you will end up switching off.

Request

FieldTypeDefaultNotes
htmlstring,The email. Exactly one of html or url
urlstring,Import from a "view in browser" link instead
formatstring"html"html, jsx, mjml, maizzle
refstringnoneOmit to skip regression checking
thresholdnumber700 to 100
checksstring[]all threecompatibility, content, regression
clientsstring[]allNarrow the clients checked
failOnErrorbooleantrueWhether client errors block
updateBaselinestring"on-pass"See above
resolveLinksbooleanfalseFetch each link to check it resolves

Status codes

A failing verdict is HTTP 200 with "passed": false. The verdict is the payload, not the transport.

CodeMeaningRetry?
400bad request, or a URL we will not fetchno
401no key, or it is invalid or revokedno
403code: "plan_required", needs Prono
422the email did not compileno
429rate limited, Retry-After is always 60s or lessyes
500oursyes

429 only ever means the per-minute limiter and is always safe to retry.

Using it from Zapier

No Emailens app is needed. Use Zapier's built-in Webhooks by Zapier action:

  1. Action: Webhooks by Zapier, POST to https://emailens.dev/api/gate
  2. Headers: Authorization: Bearer ek_live_...
  3. Data: html from the previous step, and a ref that is stable for this campaign
  4. Add a Filter step: only continue if passed is true

Put whatever should not happen to a broken email after the filter.

On this page