Key takeaways
- Hash the raw bytes. Parsing JSON and writing it back out changes the HMAC even when the secret is right.
- The idempotency key is the envelope id. It starts with evt_ and stays the same on every retry. x-mailcheckup-delivery changes every try. Use it as a log label, not as a key.
- Return 2xx only after that id is stored. Anything outside 200–299, including a redirect, fails the delivery. Redirects are not followed.
- Eight seconds is the budget for the HTTP call. Slow work runs after the response.
- Retries use a fixed try count and a fixed gap. The gap does not grow. After the last try, this contract does not describe an automatic replay.
- An address already sent to that endpoint is not sent again. Plan on one email event per address per URL.
- email.unknown is free and is not a delete. Its codes include unknown, retry_later, greylisted, mx_unreachable, provider_blocked, and system_error.
- If you select no events, the subscription is list.completed only.
Who this is for, and which pages it does not replace
You own the process that answers POST. You can keep a raw body buffer and a unique constraint on an id column. Members cannot add or edit endpoints. An API key cannot create one either. This is not the page for someone who only wanted a CSV.
The contract page holds the field list, the signature samples, and the setup order. The docs note says to verify the raw body, store the evt_ id, return 2xx, and read that page for names and retries. Neither tells you what to do when the ack is late, when the same id arrives twice, or when email.unknown meets a delete job written for bounces.
A webhook does not find addresses or send the campaign. Deliverable is not inbox placement. Verification is not consent.
What arrives on the wire
MailCheckup POSTs JSON when a list starts, moves, or finishes. Content-Type is application/json. User-Agent is MailCheckUP-Webhook. That string is a label. Anyone can set it. The signature is the check.
x-mailcheckup-event names the event. x-mailcheckup-delivery identifies this try and changes on every retry. x-mailcheckup-signature is sha256= plus the hex digest.
The envelope has an id, a type, a created_at, and a data object. The id starts with evt_. The type is one of the 10 names below. created_at is present. The registry shows it as an ellipsis, not a pinned format. Store the string. Do not fail the ack because a parser disliked the shape.
No signed timestamp header is published. A captured body with a valid signature still verifies later. Your replay control is the stored evt_ id, not a window you invented.
Email events put rows on data.emails. Each item has email, code, list_id, and list_name. One POST carries up to 50 addresses. Further rows leave about 20 seconds later as another event, with another evt_ id. A batch is one id, not fifty. List events do not use that array.
One workspace secret signs every endpoint. It starts with whsec_. Keep the live value in a secret store. Disabling a URL does not rotate the secret, and this contract does not describe rotation. Each extra URL has its own event list, try count, and gap, so two URLs are two deliveries.
Register the URL before you depend on it
An owner or admin adds an HTTPS URL on the Webhook page in the app. Plain HTTP is rejected. The sender does not call, and does not retry, localhost, a .local name, a .internal name, a private or link-local address, a non-http(s) URL, a URL with a username or password, or DNS that points at a private address. A public name that resolves into a private range falls in that last case. Do not put the secret in the URL.
Tries may be set from 1 to 10. The default is 6. The gap may be set from 10 seconds to 86400 seconds. The default is 60. The gap stays fixed. Six tries at 60 seconds cover a short outage, not an overnight deploy. Those are settings, not measurements from a test.
The test button sends test set to true. The public page places that flag inside data and describes sample values in the flavor of Test list and person@example.com. A test is not retried. A non-2xx means someone must press the button again. Do not import those rows into a live audience.
Four steps, in this order
Change the order and the crash changes with it. Nothing here was timed.
POST arrives
-> read raw body bytes
-> HMAC-SHA256, constant-time compare
-> if test: ack, do not import
-> insert evt_ id (unique)
-> return 2xx inside 8 seconds
-> worker applies that id once
Verify the raw body
Hash the bytes from the socket, before JSON parsing. In Node that is the buffer. In Python it is the raw bytes, not the object from a JSON load. A re-serialize with different spacing fails the compare. You then reject a genuine event, and the logs blame the secret.
Compare the header to sha256= plus the hex digest, in constant time. The documented Node sample returns false when the buffer lengths differ, then uses a constant-time compare. The documented Python sample compares the full header string the same way. Copy those samples. Do not replace them and still call the result the published check.
On a mismatch, return a status outside 200–299 and do not insert the id. The contract does not require 400 or 401. A 200 that contains an error sentence is still success, and the sender will not retry. If you were not ready, that 200 dropped the event.
Do not take a signature from a query string, and do not substitute a static bearer token. Neither is published.
If x-mailcheckup-event and the JSON type disagree, the contract does not say which wins. Park the body and alert a person. Returning 2xx without a stored id you intend to apply gives up the retry.
Store the evt_ id, then acknowledge
Insert the id under a unique constraint before you return 2xx. Store the raw body in the same commit. The worker should reload those bytes.
A winning insert owns the event. A conflict means you have this evt_ id already. Return 2xx and do not start a second apply. Returning 500 on the conflict schedules another try of a row you already hold. Two overlapping tries are normal. One commit wins. Both should still ack if the signature was valid and the row exists.
Skip audience writes when data.test is true. You may store the test id as a drill record if the worker cannot import it. A test is not retried, so a missing test row is recoverable only by pressing the button again.
Key the accept on the evt_ id. x-mailcheckup-delivery changes every retry, so it will treat one event as many. Inside the worker, upsert each address so a partial batch can resume.
The same column stops a third party who replays a captured body. There is no timestamp header to expire it. The unique id is the no-op. That works only if the first try stored the id. A handler that verifies the signature and writes straight to a CRM will double-apply retries and replays.
Stay inside 8 seconds
Success is any status from 200 through 299. Pick 200 or 204 for a real accept. 202 is also inside that range, so it will not be retried. Return it only after the id is durable.
Use the 8 seconds for the compare, the insert, and the response. Do not call a sending platform, append a sheet, or wait on the apply worker. If the insert itself exceeds 8 seconds, the sender retries and the unique key is what saves you. A queue call that blocks until a consumer finishes is the same timeout.
A 301 or 302 fails the delivery. Redirects are not followed, even when the final hop would return 204. Register the exact HTTPS URL that terminates the request.
The delivery log keeps up to 2000 characters of your response. Keep the success body short. Do not echo the secret or the address list.
Apply after the response, and apply once
The worker loads the stored body after the HTTP call has returned. Give the row two states, stored and applied. A third state, applying, helps when two workers wake. Only one conditional update from stored to applying may succeed.
If the winner dies after writing some rows, the next run must be safe. List events should overwrite a snapshot for that list_id. Email events should upsert each address by list_id plus email and record the evt_ id that wrote it. A second pass leaves the same code.
list.progress carries totals plus in_progress. Overwrite that snapshot. If a real body shows deltas, change the worker then. After a 2xx, re-drive a failed worker from the stored row. Fifty addresses is the sender's batch size, not proof that fifty remote writes fit in 8 seconds.
When the delivery fails
A failure is anything outside 200–299, a timeout, a network error, or a redirect. The same evt_ id comes back, up to the try count on that URL. The delivery header is new. The JSON id is not.
A handler that crosses 8 seconds looks down while it may still be running. Both attempts can verify. Start the side effect only after the insert wins. Count distinct evt_ ids, not HTTP hits.
The gap does not back off. A handler that returns 500 sees the event again on that fixed cadence until the tries end or you deploy a fix. Three failures in a row email owners and admins once, then stay quiet for 24 hours. The endpoint is not deleted. A 60-second gap reaches that mail quickly. A long gap delays both the mail and the recovery. The contract does not say a later 2xx resets the failure counter. Do not page as if it did. Watch the log.
The log keeps 30 days. Filters are success, failed, pending, waiting, and sending. Clearing it does not resend. When the tries are spent, read the list in the product. No replay route is published.
Private and local URLs produce no call and no retry, so the log can stay empty. Check the host, the DNS answer, and the scheme before you debug HMAC. Then use the test button. A failed test still does not retry itself.
A missing list.started does not prove the run never happened. You may have subscribed only to list.completed, missed every try, or added the endpoint late. Whether list.started fires for a list already running at creation time is not stated. Do not block later events on a start you might never see. A missing email row is not undeliverable. It may be not_checked, it may predate the endpoint, or that event may not be selected. Absence is not a result code.
Three kinds of duplicate
A retry repeats one evt_ id. Ack the conflict. Run the worker once. Keep the delivery header in the log row so it can be matched to the 30-day log. Do not join product logic on it.
An address already sent on that endpoint is not sent again. Do not block until email.unknown turns into email.valid. That second POST is not part of the rule. The rule is per endpoint. A second URL can still receive the same address under a different evt_ id. A later recheck is not documented as another email event once the address has been sent. Read "not sent again" as written.
Two URLs are two subscriptions. Let one of them write the audience. Let the other notify. If both write, upsert on list_id plus email and keep each evt_ id as audit. Disabling the notifier does not rotate the secret the writer still uses.
A test is not a customer duplicate. Ack it, and keep sample values such as Test list and person@example.com out of the send set.
Event-action matrix
Only these 10 events exist. There is no email.risky name among them, and no email.not_checked event. not_checked is a field on list.completed. Subscribe only to branches you have coded. A fall-through of "delete unknown codes" will destroy the list.
| Event | Apply this | Do not |
|---|---|---|
| list.started | Open the run. Store list_id and jobs as the start-queue count. | Treat jobs as file size or as a percent denominator. |
| list.progress | Overwrite totals plus in_progress, and store the status string. | Add the numbers to the previous event, or page on one quiet minute. |
| list.completed | Close the run. Store every published count, including unknown and not_checked. | Delete people who never arrived as their own email event. |
| email.valid | Mark valid or deliverable. Leave permission to send on your own policy. | Claim inbox placement or consent. |
| email.undeliverable | Keep it out of the send set. Store the code you received. | Rewrite the address, or treat it as email.unknown. |
| email.disposable | Keep it out of the normal send set. Keep an audit row. | Call it broken syntax or a free unknown. |
| email.catch_all | Hold it out of the deliverable set unless your policy says otherwise. | Store it as email.valid. |
| email.role_account | Mark a shared mailbox and keep the code. | Invent a department, or merge it into valid. |
| email.mailbox_full | Hold it as full at the time of the check. | Mark it dead, or wait for a second POST. |
| email.unknown | Park it with the code. Leave it in the audience. | Delete it, suppress it as undeliverable, or bill it. |
list.started
Fires once when a run starts. data has list_id and jobs. jobs is how many addresses were queued then, not the file size. More addresses can join afterward, and you still get one list.started. Do not divide a later checked count by jobs. Use progress or completed for totals. If your first accepted event is progress or completed, open the run from that event. That is your fallback, not another start from the sender.
list.progress
Fires at most once a minute, and only after the checked count moves. Expect totals, in_progress, and a status such as verifying. Verifying is an example, not a closed list of status strings. Store the string you got. A quiet minute means the checked count did not move, or the gap has not elapsed. It does not, by itself, mean the run died.
list.completed
Fires once when status is completed or completed_with_warnings. Lists that finished before the endpoint existed are not replayed. data includes list_id, name, status, rows, unique, checked, credits, not_checked, deliverable, undeliverable, catch_all, disposable, role_account, mailbox_full, and unknown.
The public page says credits is how many credits that list consumed, and that unknown did not use a credit. not_checked has no finished result. rows, unique, and checked have no published formula. Store them. Do not claim rows equals jobs. completed_with_warnings has no warning catalog here. Close the run. Do not delete addresses that never arrived as email events.
email.valid
Codes valid and deliverable. One credit. The mailbox server accepted the recipient during the check. Mark that code on list_id and keep it beside any boolean a sender tool wants. The public page calls this the feed for putting the address in the send, and it says that is not inbox placement. Suppression and consent still decide whether anyone is mailed. A retried evt_ id is not a second credit.
email.undeliverable
Codes undeliverable, invalid, and dead. One credit. This is the branch that may leave the send set. Keep the specific code. data.emails carries email, code, list_id, and list_name. There is no replacement-address field. Do not invent a lookup call to fetch one.
email.disposable
Code disposable. One credit. A throwaway domain is a classification, not a syntax error and not a free unknown. Exclude it from the normal send, or follow a policy you already have. Keep the audit row. There will not be a second event on this URL to "confirm" it.
email.catch_all
Code catch_all. One credit. The domain accepts every address, so the event does not prove a mailbox and it is not email.valid. Hold it out of the deliverable set unless your own policy sends to catch-all domains. This article does not set that policy. Keep the code even if a downstream boolean would hide it.
email.role_account
Code role_account. One credit. Mark a shared mailbox. The results guide explains role addresses for people reading a report. This payload will not add a department. Do not merge the row into email.valid because the server might still accept mail. The event already classified it as a role.
email.mailbox_full
Code mailbox_full. One credit. The mailbox could not take more mail at check time. Hold it. Do not mark it dead. The address will not be sent again on this endpoint, so do not wait for email.valid to clear the hold. A later check is a new operation in the product, not a second POST you can assume.
email.unknown
Codes unknown, retry_later, greylisted, mx_unreachable, provider_blocked, and system_error. Free. Not a delete. Park the address with the code you received. The next section is the list of moves that damage the audience.
What not to do with email.unknown
Unknown is not invalid. The short statement is on unknown versus invalid. In the receiver, that means the address stays in the audience. Do not set the bounce flag you use for email.undeliverable.
Each code is a different stall. unknown had no clear yes or no. retry_later was a request to try again. greylisted was a deferral. mx_unreachable means the mail server could not be reached, not that you identified a bad person. provider_blocked means the check was blocked, not that the person is fake. system_error is in this same free event, and it is still not a delete.
A helper that maps every non-valid type to a hard bounce will drop greylist rows, blocked checks, and unfinished errors. Split it.
Do not count the row as a credit. Do not open your own SMTP session to finish the unknown. That would poke a server that just greylisted or blocked a check, on a path that must ack in 8 seconds. The POST is a notice, not a new probe.
Do not wait for another email event to unpark the row. Schedule any reminder in your own jobs. If you did not subscribe to email.unknown, list.completed can still show an unknown count with no per-address rows. That is a subscription gap. It does not name the people, and it is not an order to delete that many records.
not_checked is the other trap. It means no finished result yet. Those addresses do not arrive as email.unknown. Silence in the email stream is not this event.
Reconcile only the rows you stored. Do not delete the numeric gap between list.completed.unknown and your table. The sets differ when the endpoint was created mid-list, when tries ran out, or when the email event was off. A gap is a reason to look at the list in the product.
Test data still loses to the flag. Ack the button. Do not park person@example.com in production.
Documented signature checks
These two functions are the documented samples from the contract page. They are not a new API. The only secret string to type while reading is whsec_your_secret. Load the real secret from the environment at runtime.
Documented Node.js sample:
import { createHmac, timingSafeEqual } from "node:crypto";
function verifyMailCheckupWebhook(rawBody, signatureHeader, secret) {
const expected = "sha256=" + createHmac("sha256", secret).update(rawBody).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(signatureHeader);
if (a.length !== b.length) return false;
return timingSafeEqual(a, b);
}
Documented Python sample:
import hmac, hashlib
def verify_mailcheckup_webhook(raw_body: bytes, signature_header: str, secret: str) -> bool:
digest = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
expected = "sha256=" + digest
return hmac.compare_digest(expected, signature_header)
Leave the Node length check in place. Compare the full sha256= value, not the hex alone on one side. The samples do not create endpoints. An owner or admin does that on the Webhook page.
Conceptual wiring around the samples
This sketch is conceptual. It is not a MailCheckup route and not a REST client. It only shows order: verify, refuse the import on a test, insert the evt_ id, ack, apply later.
// Conceptual wiring. Not a MailCheckup endpoint.
// verifyMailCheckupWebhook is the documented Node sample.
// MAILCHECKUP_WEBHOOK_SECRET holds the workspace secret.
function handlePost(rawBody, headers) {
const secret = process.env.MAILCHECKUP_WEBHOOK_SECRET;
const signature = header(headers, "x-mailcheckup-signature");
if (!secret || !verifyMailCheckupWebhook(rawBody, signature, secret)) {
return { status: 400 }; // any non-2xx; do not store
}
const event = JSON.parse(rawBody.toString("utf8"));
if (event.data && event.data.test === true) {
return { status: 204 }; // ack the button; do not import
}
const inserted = insertEventIdOnce(event.id, rawBody);
if (!inserted) {
return { status: 204 }; // same evt_ id, already stored
}
enqueueApply(event.id); // must not wait for the worker
return { status: 204 };
}
400 and 204 are receiver choices. Any status outside 200–299 fails. Any status from 200 through 299 succeeds. The test flag is read from data because that is where the public page puts it. insertEventIdOnce has to commit or report a conflict. enqueueApply has to return without the CRM call. Parse JSON only after the signature matches. Hashing a pretty-printed object fails the documented check. Python uses the same order with the documented function and the raw bytes.
Logs
Log the evt_ id, the type, the delivery header, your status, and whether the insert was new. On a worker error, re-drive from the stored body. Do not log the secret. It can sign a new body. Redact the local part of addresses in broad log tools if your policy requires it, and keep the full value on the event row. That split is a recommendation, not a contract field.
If their log says success and your table has no row, you acked before the insert committed. If their log says failed and your table has the id, you stored it and still returned non-2xx, or you timed out after the insert. An empty log often means the URL was never dialed.
What this contract does not promise
Delivery retries until 2xx, inside the try budget. It is not exactly-once, and the queue is not endless. Order among list.started, list.progress, list.completed, and email batches is not specified, so do not drop a batch because the start event is missing. created_at has no pinned format here. rows, unique, and checked have no published formula. completed_with_warnings has no warning list. Nothing in this contract creates the endpoint, rotates the secret, or replays a spent event. An API key cannot create a webhook. Clearing the log does not resend. A test does not retry.
Deliverable is not inbox placement. Unknown is not invalid. Verification is not consent. Your status codes, queue, and database are yours. The samples only compare the signature. If a questionnaire asks at-least-once or exactly-once, use these limits. Do not answer with a percentage. None was measured.
Questions that come up in review
Do I poll as well?
MailCheckup calls your URL. This article does not define a poll API.
Will older lists appear after I save the URL?
No. Email events cover checks finished after the endpoint existed. list.completed is not replayed for lists that already finished. Read older files from the product.
Can I develop on localhost?
Not with the live sender. Sign a fixture locally with whsec_your_secret to test the compare. That is not a delivery test. Use a public HTTPS URL and the test button for a real POST.
Why does the signature keep failing?
Hash the raw bytes, use the workspace secret, and compare the full sha256= header. A redirect in front of the registered URL fails before your code runs.
I returned 2xx and the worker threw. Now what?
Re-drive that evt_ id from the stored body. If the body was never stored, use the list in the product.
Can the dedupe happen after the ack?
Only if you accept a double apply when the process dies between the two. Store the id first.
Add the URL, then send a test
When a locally signed fixture verifies, the id stores, and the handler returns 204 without slow work, an owner or admin can register the public HTTPS URL. Select the matrix rows you coded. If email.unknown is not selected, you cannot park those addresses, and silence is not a substitute. Put the live secret in the secret store. Send a test. Confirm the sample stayed out of the audience.
Open the Webhook page: https://cp.mailcheckup.net/webhooks.
Sources
- MailCheckup webhook contract, https://mailcheckup.net/webhooks, read from the published page source in this repository on 6 October 2026.
- MailCheckup docs webhook note, https://mailcheckup.net/docs/webhooks, read from published site copy in this repository on 6 October 2026.
- Unknown versus invalid, https://mailcheckup.net/resources/unknown-vs-invalid-email, linked for the distinction this receiver must keep.
- Results guide, https://mailcheckup.net/results-guide, linked for role-address context.








