Create a webhook endpoint
/api/v1/webhooksRegisters a URL to receive delivery events as they happen.
Delivery is at least once, so the same event can arrive twice — key your handling
on the event id and make it idempotent. Events can also arrive out of order; a
delivered before the accepted that preceded it is normal and not a bug.
Verify the signature on every request before trusting the body. See Receiving webhooks for the full list of events and their payloads.
What an email.inbound event carries. The sender, the recipients and the subject of
an email that arrived, and for some messages its body. It goes to every webhook in your
account that subscribes to it and whose filters it passes — so an unfiltered webhook gets
the incoming mail of every mailbox on every domain in your account, not only the mailbox
your integration uses. domain narrows it to one domain's mailboxes; there is no filter
for a single mailbox, so check the recipient on your side if you need one.
Incoming mail needs a domain when your account has more than one. A webhook
subscribed to email.inbound must then set domain (one of yours, exact match). Leaving
it out answers 422 on domain — on create, on an update that would remove it, and when
turning an old webhook back on. With one domain it is optional; when you add a second
domain, a webhook without one is tied to the domain you had, so it keeps getting exactly
what it got before.
Filters, three ways, one result. The domain can be sent as domain,
filters.domain or targeting.domain; the top-level domain wins when it is sent.
filters uses the stored names (senderIncludes, subjectContains, bodyContains);
targeting uses the names the response returns (sender_includes, ...), so you can send
back what you read. Send one of the two, not both. Every call answers with targeting:
read it back to confirm what is applied. Anything unknown is a 422 that names it —
a field, a filter key, or an event name — never a 200 that quietly ignored it.
email.deferred usually means we are still trying. The receiving server asked us to
wait, the message is in our queue, and we retry it for you. One is not ours to retry: when
a marketing SMTP key sends to a contact whose inbox is full and Bounce protection is
holding marketing to them, the message is refused before it is queued (a 452 that names
a date). It is logged deferred, but Mailyte never queued it, so your app must send it
again after the date in the reason.
Three account events are about your account, not about a message, so a webhook's domain, sender, subject and body filters never hold them back. They are sent only once Bounce protection is on for your account.
contact.checked: a contact's address was checked and the answer changed, once per change.{contact_id, email, email_status, reason, suggestion, checked_at, email_full_until}.email_statusis the contact's new value;reasonandsuggestionread as on a verification result (suggestionis the corrected address for a misspelt domain, else null).unknownwith a checker that could not confirm yet is asked again later and may be announced again.email_full_untilis the end of a hold on marketing to a full inbox while it runs, else null.checks.running_low:{trigger, plan_left, bought, credit_fallback, forecast_days}.triggerisforecast(under 7 days of checks left at your recent pace) orplan_80(80% of this month's plan checks used, and none bought). The counts are checks, as onGET /billing/usage.checks.ran_out:{since, buy_path}. A new contact could not be checked because no checks were left; it was added asnot_checked.buy_pathis the dashboard page that buys the checks we suggest.
The two checks.* events are sent at most once per cycle: a new month, a purchase or
checks added by us start a new one.
Request body
namestringrequiredA name for your team, like
Production events.eventsarray<string>requiredThe events to receive. Each one's payload is in the Receiving webhooks guide.
urlstringrequiredThe full HTTPS address we send to. It must be reachable from the internet.
filtersobjectOptional. Send only some events, by what is in the email. A list matches if any of its words appears (case does not matter); leave a list out to match everything. Other keys are refused with a 422. On update this replaces all the filters; leave it out to keep them.
domainstringThe same as the top-level
domain.senderIncludesarray<string>Only mail whose sender contains one of these.
subjectContainsarray<string>Only mail whose subject contains one of these.
bodyContainsarray<string>Only mail whose body contains one of these.
targetingobjectThe same filters, spelt as the response returns them, so you can send back what you read. Send
filtersortargeting, not both.domainstringThe same as the top-level
domain.sender_includesarray<string>As
filters.senderIncludes.subject_containsarray<string>As
filters.subjectContains.body_containsarray<string>As
filters.bodyContains.
domainstringOnly events for this domain, such as
yourdomain.com: one of your domains, exact match. Required withemail.inboundwhen your account has more than one domain. Can also be sent asfilters.domainortargeting.domain; this field wins.
Request
/api/v1/webhookscurl -X POST 'https://app.mailyte.com/api/v1/webhooks' \
-H 'Authorization: Bearer mk_live_YOUR_API_KEY' \
-H 'Content-Type: application/json' \
-d '{
"name": "Production events",
"events": [
"email.delivered",
"email.bounced"
],
"url": "https://example.com/webhooks/mailyte"
}'const response = await fetch('https://app.mailyte.com/api/v1/webhooks', {
method: 'POST',
headers: {
Authorization: 'Bearer mk_live_YOUR_API_KEY',
'Content-Type': 'application/json',
},
body: JSON.stringify({
"name": "Production events",
"events": [
"email.delivered",
"email.bounced"
],
"url": "https://example.com/webhooks/mailyte"
}),
});
const { data } = await response.json();import requests
response = requests.post(
"https://app.mailyte.com/api/v1/webhooks",
headers={"Authorization": "Bearer mk_live_YOUR_API_KEY"},
json={
"name": "Production events",
"events": [
"email.delivered",
"email.bounced"
],
"url": "https://example.com/webhooks/mailyte"
},
)
data = response.json()["data"]<?php
$response = Http::withToken('mk_live_YOUR_API_KEY')
->post('https://app.mailyte.com/api/v1/webhooks', [
'name' => 'Production events',
'events' => [
'email.delivered',
'email.bounced',
],
'url' => 'https://example.com/webhooks/mailyte',
]);
$data = $response->json('data');require "net/http"
require "json"
uri = URI("https://app.mailyte.com/api/v1/webhooks")
request = Net::HTTP::Post.new(uri)
request["Authorization"] = "Bearer mk_live_YOUR_API_KEY"
request["Content-Type"] = "application/json"
request.body = {
"name": "Production events",
"events": [
"email.delivered",
"email.bounced"
],
"url": "https://example.com/webhooks/mailyte"
}.to_json
response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(request) }Response
Success.
dataobjectobjectstringwebhookidstringUnique identifier for the webhook.
namestringYour name for the webhook.
urlstringWhere we send events. It must be a public HTTPS address.
statusstringactive | inactiveAn
inactivewebhook is skipped by the dispatcher. It is not deleted and keeps its delivery history and its secret.eventsarray<string>The events this webhook receives. A name not on this list is refused with a 422.
quota.warningis an older name forstorage.quota.warningand still works, but do not use it for new webhooks.contact.checked,checks.running_lowandchecks.ran_outare about your account, not one email, so filters never hold them back; they are sent only while Bounce protection is on.targetingobjectWhich events reach this webhook. A field that is null does not narrow anything.
domainstringOnly events for this domain, exact match (
acme.comdoes not matchnotacme.com). Null means every domain. Always set on anemail.inboundwebhook in an account with more than one domain.sender_includesarray<string>Any-of substring match on the sender. null = not narrowed.
subject_containsarray<string>Any-of substring match on the subject. null = not narrowed.
body_containsarray<string>Any-of substring match on the body. null = not narrowed.
deliveriesobjectThe delivery history, grouped so the numbers cannot disagree.
succeededintegerLifetime tally of accepted deliveries.
failedintegerLifetime tally of failed deliveries.
consecutive_failuresintegerResets to zero on any success. This is what drives auto-disable, so it answers a different question from
failed.success_ratenumberThe percentage of deliveries that succeeded, 0 to 100. Null when no delivery has been tried yet; show it as "no deliveries yet", not as 0.
last_delivered_atstringWhen a delivery to this webhook last succeeded, in ISO 8601 UTC. Null when none has succeeded yet; check
deliveriesto see whether any were tried.created_atstringISO 8601 UTC.
secretstringThe secret for checking our signatures. **Shown only in the response to
POST /webhooks**, and never again: save it now. If you lose it, delete the webhook and make a new one. Check the signature on every request before you trust it.
Returned inside the standard envelope.
Errors
| Status | When |
|---|---|
401 | The API key is missing, unknown, revoked or expired. All four answer identically, on purpose: distinguishing them would confirm which keys exist. |
403 | The key is valid but may not do this: it lacks the required scope, its IP allowlist does not include you, or this endpoint does not accept API keys. |
404 | No such resource in this organization. |
422 | The request was understood but the values were not acceptable. |
429 | Too many requests, or the organization has spent its sending allowance. `Retry-After` says how long to wait. |
Every status, with what causes it and what to do, is on the error reference.