We gate our own blog. Scored 17 October 2026 with the same engine you get, glossary rules, balanced strictness.
Available to workspaces through the API, in shadow mode first. There is no connector for a sending tool: you call the API from your workflow and it answers. Dokeo never sends mail. It decides, and you send.
Check a sequence
Send up to 25 emails per call. Each is checked against the email rules and scored with your footer, so the unsubscribe check sees it. Strict is the default: one failing check is a FAIL. It uses one credit per email.
curl https://api.dokeo.co/api/v1/campaign/qa \
-H "Authorization: Bearer $DOKEO_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"campaign": "Q4 outbound",
"footer": "If this is not relevant, reply unsubscribe.",
"recipients": ["[email protected]", "[email protected]"],
"timing": { "recipient_timezone": "Europe/Amsterdam" },
"emails": [
{ "step": "1", "subject": "Quick question", "body": "Hi Sam, ...", "send_at": "2026-11-03T10:00:00" },
{ "step": "2", "subject": "Re: Quick question", "body": "Hi Sam, following up ...", "send_at": "2026-11-03T11:00:00" }
]
}'The answer:
{
"schema": "dokeo.campaign_qa/v1",
"mode": "shadow",
"send": true,
"action": "send",
"would_block": true,
"would_block_steps": ["2"],
"send_to": ["[email protected]"],
"suppressed": [
{ "recipient": "[email protected]", "matched": "[email protected]", "scope": "address", "reason": "asked to stop" }
],
"sequence_findings": [
{ "step": "2", "check": "timing.step_spacing", "severity": "WARNING",
"reasons": ["only 1.0 hours after step 1, under the 24 hour minimum"] }
],
"emails": [
{ "step": "1", "verdict": "WARNING", "score": 80, "blocked": false, "decision_hash": "sha256:3f9c...", "findings": [] }
]
}modeisshadowby default. Quality never holds a send, so you can compare what it would have blocked with what you really sent for a few weeks. Set"mode": "enforce"to getsend: falsewhen anything would block.actionissend,hold(enforce mode and something would block) orskip(nobody is left to send to).fail_onandmin_scorework as in the CLI.- Every email gets a
decision_hash. Replay it later withPOST /api/v1/scan/replayusing the textSubject: ..., a blank line, the body, a blank line and the footer.
A do-not-contact list that never forgets a no
Anyone on the list is dropped from send_to in every mode, shadow included, because a refusal is not a quality opinion. An address or a whole @domain can be on it, and [email protected] is covered by [email protected]. No model is involved. A second entry for the same person keeps the first one, so the earliest refusal and its wording are what you see.
# Add one refusal. The wording is kept as you give it.
curl https://api.dokeo.co/api/v1/campaign/suppressions \
-H "Authorization: Bearer $DOKEO_API_KEY" -H "Content-Type: application/json" \
-d '{ "target": "[email protected]", "reason": "reply", "source_text": "please stop emailing me" }'
# Add a whole domain with @domain.com
# Load an existing list in one call: a plain list, or a CSV with a header row.
# Safe to run twice: anyone already on the list keeps their first wording.
curl https://api.dokeo.co/api/v1/campaign/suppressions/import \
-H "Authorization: Bearer $DOKEO_API_KEY" -H "Content-Type: application/json" \
-d '{ "csv": "email,reason,text\[email protected],reply,please remove me\[email protected],domain,\n" }'
# See who is on it
curl https://api.dokeo.co/api/v1/campaign/suppressions -H "Authorization: Bearer $DOKEO_API_KEY"Only an admin can lift an entry (POST /campaign/suppressions/lift, with a reason), and the history stays. For a deletion request, an admin can anonymise an entry (POST /campaign/suppressions/anonymise): the address becomes a hash and the wording is cleared, and the person stays suppressed.
Timing and voice
Give each email a send_at and set timing to flag sends in quiet hours (default 20:00 to 07:00 in the recipient's zone), steps under 24 hours apart, and a step scheduled at or before the one before it. Dokeo also flags an email that reads like a different writer than the rest of the sequence: sentence length, contractions, exclamation marks, greeting and subject case. These come back as sequence_findings. They hold a send in enforce mode only when they reach your fail_on. The defaults are conservative starting points, not a claim that they win more replies.
What the gate did this month
Counts only: no address, subject or text is stored in the summary.
curl "https://api.dokeo.co/api/v1/campaign/summary?days=28" -H "Authorization: Bearer $DOKEO_API_KEY"
# {
# "window_days": 28, "campaigns": 3, "would_block_rate_percent": 12.4,
# "totals": { "runs": 41, "emails": 188, "would_block": 23, "suppressed_recipients": 9,
# "timing_flags": 6, "voice_flags": 4, "holds": 0, "skips": 2 },
# "by_day": [ { "day": "2026-11-03", "runs": 5, "emails": 24, "would_block": 3, "suppressed": 1 } ],
# "top_reasons": [ { "check": "email.no_unsubscribe", "count": 11 } ],
# "ledger_active": 112
# }From n8n or Zapier
Any tool that can make an HTTP request works. In n8n:
| Node | HTTP Request |
| Method and URL | POST https://api.dokeo.co/api/v1/campaign/qa |
| Authentication | Generic credential, Header Auth. Name: Authorization. Value: Bearer dk_your_key |
| Send body | On, content type JSON, using the JSON from the call above, with your own fields filled from the previous node |
| Next node | IF. Condition: {{ $json.action }} equals send |
| True branch | Your sender, using {{ $json.send_to }} as the recipient list |
| False branch | Log it, or alert yourself. action is hold or skip, and the reason is in the response |
In Zapier, use "Webhooks by Zapier", a custom request, with the same URL, header and JSON body.
Limits
- 25 emails and 1,000 recipients per call. 5,000 rows per list import.
- One credit per email checked. The ledger, the import and the summary use none.
- Campaign QA calls are not stored as scans. Each email is logged for replay by its hash; recipients are never logged.
- The full request and response shapes are in the REST API reference.