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
family | check | Fails when |
|---|---|---|
compatibility | threshold | a client scores below threshold (default 70) |
compatibility | client-errors | a client has one or more errors |
content | links | a link goes nowhere: empty, javascript:, or a placeholder |
content | size | Gmail will clip the email |
content | alt-text | a real image has no alt text (tracking pixels are ignored) |
content | link-unreachable | a link returned 4xx or 5xx (opt-in, see below) |
regression | worse-than-baseline | this 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.
Checking links 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:
| Value | Behaviour |
|---|---|
"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
| Field | Type | Default | Notes |
|---|---|---|---|
html | string | , | The email. Exactly one of html or url |
url | string | , | Import from a "view in browser" link instead |
format | string | "html" | html, jsx, mjml, maizzle |
ref | string | none | Omit to skip regression checking |
threshold | number | 70 | 0 to 100 |
checks | string[] | all three | compatibility, content, regression |
clients | string[] | all | Narrow the clients checked |
failOnError | boolean | true | Whether client errors block |
updateBaseline | string | "on-pass" | See above |
resolveLinks | boolean | false | Fetch 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.
| Code | Meaning | Retry? |
|---|---|---|
| 400 | bad request, or a URL we will not fetch | no |
| 401 | no key, or it is invalid or revoked | no |
| 403 | code: "plan_required", needs Pro | no |
| 422 | the email did not compile | no |
| 429 | rate limited, Retry-After is always 60s or less | yes |
| 500 | ours | yes |
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:
- Action: Webhooks by Zapier, POST to
https://emailens.dev/api/gate - Headers:
Authorization: Bearer ek_live_... - Data:
htmlfrom the previous step, and arefthat is stable for this campaign - Add a Filter step: only continue if
passedistrue
Put whatever should not happen to a broken email after the filter.