DEVELOPER DOCUMENTATION

Get each result on your own URL.

When a list starts, moves, or finishes, MailCheckUP POSTs JSON to an HTTPS endpoint you control. You verify the signature, store the event id, and return 2xx. Deliverable is not inbox placement. Unknown is free and is its own event.

MAILCHECKUP WEBHOOKS
POST      your HTTPS URL
verify    HMAC-SHA256 of the raw body
sign      sha256=<hex>

Placeholder only · whsec_your_secret
Open the Webhook page ↗
MAILCHECKUP / WEBHOOKS↘

What it is for

MailCheckUP calls the customer. They do not poll. Use a webhook when a system should react as soon as addresses finish: drop deliverable rows into a sheet, remove undeliverable rows from a sender, or mark a list done. Use the portal or list_results when a person just wants to read the file. A webhook does not find emails, does not send the customer's campaign, and does not mean a deliverable address will reach the inbox.

Who can set one up

  • Workspace owner or admin, on the Webhook page after login.
  • Members cannot add or edit endpoints.
  • An API key cannot create one.
  • MCP list_webhooks returns the URL, enabled flag, event names, retry count, and gap. It does not return the secret.

Open the Webhook page

How to connect

  1. Open https://cp.mailcheckup.net/webhooks.
  2. Add an HTTPS URL. Production rejects plain HTTP.
  3. Pick the events. If the customer sends none, the default subscription is list.completed only.
  4. Optionally set retries. Default is 6 tries, 60 seconds apart.
  5. Copy the workspace signing secret from that page. It starts with whsec_. There is one secret for the workspace. Every endpoint is signed with it. Owners and admins can see it on the Webhook page. Do not put a real secret in docs, screenshots, or a chat prompt. Use the placeholder whsec_your_secret in snippets only.
  6. Send a test from the same page. The test body has "test": true. A test is not retried.

More than one URL is allowed. Each URL has its own event list, try count, and gap. Disabling an endpoint stops delivery to that URL. It does not rotate the secret.

The HTTP call

PieceWhat you get
MethodPOST. The body is raw JSON.
VerifyThe bytes received. Parsing and re-serializing breaks the signature.
Content-Typeapplication/json
User-AgentMailCheckUP-Webhook
Event namex-mailcheckup-event
This tryx-mailcheckup-delivery. It changes on every retry.
Signaturex-mailcheckup-signature: sha256=<hex>
Timeout8 seconds
SuccessAny HTTP 200-299. An empty body is fine.
FailureAnything else, a timeout, a network error, or a redirect. Redirects are not followed. A 301 or 302 is a failed delivery.

The signature is HMAC-SHA256 of the exact raw body, keyed with the workspace secret. Compare the header to sha256= plus the hex digest, in constant time.

Check the signature

rawBody is the request body before JSON parsing. In Node that is the raw Buffer. In Python it is the raw bytes, not request.json(). Return 2xx after the event id is stored. Do the slow work after the response. If the handler crosses 8 seconds, MailCheckUP treats the call as a timeout and tries again. The samples use the placeholder whsec_your_secret. They do not contain a live secret.

Node.js
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);
}
Python
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)

Envelope

Every event uses the same wrapper. id starts with evt_ and stays the same on every retry. Store it and ignore a repeat. x-mailcheckup-delivery is the attempt id and changes each try.

Event envelope
{"id":"evt_…","type":"list.completed","created_at":"2026-09-28T12:00:00.000Z","data":{}}

A test adds "test": true inside data, plus obvious sample values (Test list, person@example.com). Do not import test rows into a live audience.

Ten events only

Unknown names are rejected. Subscribe only to what the receiver will act on.

List events

EventWhen it firesWhat data carries
list.startedOnce, when a run starts.list_id and jobs. jobs is how many addresses were queued at the moment the run started. A large list keeps queueing after that. There is still only one list.started. jobs is not the size of the file. Use list.progress or list.completed for totals.
list.progressWhile a list is running. At most once a minute, and only when the checked count has moved.The same totals as list.completed, plus in_progress. status is the live list status, such as verifying.
list.completedOnce, when a list finishes (completed or completed_with_warnings). A list that finished before the endpoint existed is not replayed. A list that finishes after the endpoint exists is sent, including one the first attempt missed.list_id, name, status, rows, unique, checked, credits, not_checked, deliverable, undeliverable, catch_all, disposable, role_account, mailbox_full, unknown. credits is how many credits that list consumed. unknown did not use a credit. not_checked still has no finished result.

Email events

Each POST carries up to 50 new addresses. If more are waiting, the next batch follows on the next pass, about 20 seconds later. An address already sent on that endpoint is not sent again. These events only include checks that finished after the endpoint was created. Turning on email.valid does not replay old lists.

data.emails[] carries email, code, list_id, and list_name.

EventCodesCredit
email.validvalid, deliverable. The feed for putting this address in the send. Not an inbox-placement event.1 credit
email.undeliverableundeliverable, invalid, dead1 credit
email.disposabledisposable1 credit
email.catch_allcatch_all1 credit
email.role_accountrole_account1 credit
email.mailbox_fullmailbox_full1 credit
email.unknownunknown, retry_later, greylisted, mx_unreachable, provider_blocked, system_error. Not a delete event. Unknown was not proven invalid.Free

Retries

Tries 1 to 10, default 6. Gap 10 to 86400 seconds, default 60. Tries means the total number of calls if the URL does not return 2xx. The gap stays fixed. It does not grow. The JSON id is unchanged across tries. The delivery header changes.

Not retried: localhost, .local, .internal, a private or link-local address, or a URL that is not http(s). A URL with a username or password is rejected. DNS that points at a private address is not called.

After three failed deliveries in a row, MailCheckUP emails the workspace owners and admins once, then stays quiet for 24 hours. The endpoint is not deleted. Fix the URL or the handler, then use the test button.

The delivery log keeps 30 days. Status filters: success, failed, pending, waiting, sending. The log stores up to 2000 characters of the response. Owners and admins can clear that log. Clearing it does not resend the events.

Questions

Do I poll?

No. MailCheckUP calls you. Poll get_list or list_results only if you are using the API or an agent instead.

Will I get old results?

No. Email events start from when the endpoint was created. list.completed is sent for lists that finish after that.

What if my server is down?

The same event is tried again, up to the try count, with the same id. Return 2xx only after you have stored that id.

Can I use localhost?

No. The URL has to be a public HTTPS host. Private addresses are not called and are not retried.

Does valid mean the inbox?

No. It means the mailbox server accepted the recipient during the check.