Events and webhooks
Follow message statuses in the sending log and receive signed delivery, bounce, complaint, open and click events on your own endpoint.
Every message you send gets a message ID and moves through a series of statuses as it's queued, delivered, retried or bounced. You can follow this in the console's sending log, query it with the API, or have each event pushed to your own server with a webhook.
Message statuses
Each recipient copy has one status. A status only moves forward: for example, a delivered message can still become bounced or complained, but never goes back to queued.
| Status | Console label | Meaning |
|---|---|---|
queued | Queued | Accepted and waiting to be delivered |
deferred | Deferred | The receiving server returned a temporary error. Delivery is retried for up to 72 hours. |
delivered | Delivered | The receiving server accepted the message |
bounced | Bounced | Delivery failed permanently, either during delivery or through a bounce report received later. See the bounce types below. |
complained | Complaints | The recipient marked the message as spam |
rejected | Rejected | Refused before sending, for example because the recipient is suppressed, isn't allowed in the sandbox, or the content was rejected |
failed | Failed | Still undeliverable after 72 hours of retries, or couldn't be queued |
Opens, clicks and unsubscribes aren't statuses. They're recorded on the message (opened_at, clicked_at, unsubscribed_at) and sent as webhook events. In the sending log, an opened message has an extra Opened tag.
Bounce types
| Type | When | Effect |
|---|---|---|
| Hard | A permanent 5xx rejection, such as an unknown user | The address is added to the global suppression list |
| Soft | A full mailbox (x.2.2) or a system, network or policy rejection (x.3.x to x.7.x, for example an IP reputation block) | The address isn't suppressed |
The sending log
The Message log tab of a stream lists the messages from the last 30 days with their time, recipient, subject, category, source (API or SMTP) and status. You can filter by Recipient, Status, Source, Category and a date range.
Click a message to open Message details: the status, sender, subject, category, tags, the receiving server's last response, and the list of events with the SMTP code, response, bounce type, MX host and IP of each delivery attempt.
The Overview tab adds up the same data for 7 or 30 days: sent, delivery rate, bounce rate, complaint rate, opens and clicks.
Webhooks
A webhook sends each event to an HTTPS endpoint of yours as it happens, so your application can, for example, mark a user's address as invalid after a hard bounce.
Set up a webhook
- Open the stream and go to the Webhook tab.
- In Endpoint URL, enter an
https://URL on the public internet. Private and local addresses aren't accepted. - Under Events, select the events you want. All events are selected by default.
- Click Save. The first time you save a URL, the Signing secret is shown once. It starts with
whsec_. Store it on your server. - Click Send test to send a
testevent and check that your endpoint answers.
To get a new secret, click Reset signing secret. The old secret stops working immediately. To stop receiving events, clear the URL and save.
Events
| Event | Console label | Sent when |
|---|---|---|
delivered | Delivered | The receiving server accepted the message |
deferred | Delay (tarpit) | A delivery attempt failed temporarily. Sent once per attempt. |
bounced | Bounced | Permanent failure. data.bounce_type is hard or soft. |
complained | Complaints | The recipient reported the message as spam |
failed | Failed | Delivery was given up after 72 hours |
rejected | Deny | The sending server refused a recipient |
opened | Open | The first time the message is opened (requires open tracking) |
clicked | Clicks | Every click on a tracked link (requires click tracking) |
unsubscribed | Unsubscribed | The recipient unsubscribed |
Recipients refused by the HTTP API itself are returned in the rejected field of the API response and don't produce a webhook.
Request format
Each event is sent as a POST with a JSON body:
POST /your/webhook HTTP/1.1
Content-Type: application/json
User-Agent: Yeap-TM-Webhook/1
X-Yeap-Signature: t=1760955304,v1=5f2b9c...e81a
{
"id": "evt_01JAQ40B2C3D4E5F6G7H8J9K0M",
"type": "bounced",
"stream_id": 12,
"message_id": "01JAQ3Z9V8K4M2N6P7R5S1T0XW",
"created_at": "2026-10-20T10:15:04+00:00",
"data": {
"rcpt": "alice@example.org",
"smtp_code": 550,
"smtp_response": "5.1.1 The email account that you tried to reach does not exist",
"bounce_type": "hard",
"mx": "mx.example.org"
}
}id is unique per event: use it to ignore duplicates. What data contains depends on the event:
| Event | data fields |
|---|---|
delivered, deferred, bounced, complained, failed, rejected | rcpt, and when available smtp_code, smtp_response, bounce_type, mx, at |
opened | rcpt, ip, user_agent |
clicked | rcpt, url, ip, user_agent |
unsubscribed | rcpt, category, via |
test | message |
Verify the signature
The X-Yeap-Signature header has the form t=<timestamp>,v1=<signature>. The signature is the hex HMAC-SHA256 of <timestamp>.<raw request body>, using your full signing secret (including whsec_) as the key. To check a request:
- Read the raw body exactly as received, before parsing the JSON.
- Compute the HMAC over
t + "." + bodyand compare it withv1using a constant-time comparison. - Reject requests whose timestamp is more than 5 minutes old, to block replays.
Python:
import hashlib, hmac, time
def verify(secret: str, header: str, body: bytes, tolerance: int = 300) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
ts, sig = parts.get("t", ""), parts.get("v1", "")
if not ts.isdigit() or abs(time.time() - int(ts)) > tolerance:
return False
expected = hmac.new(secret.encode(), ts.encode() + b"." + body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, sig)Node.js:
const crypto = require('crypto');
function verify(secret, header, rawBody, tolerance = 300) {
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
const ts = Number(parts.t);
if (!ts || Math.abs(Date.now() / 1000 - ts) > tolerance) return false;
const expected = crypto.createHmac('sha256', secret).update(`${parts.t}.${rawBody}`).digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(parts.v1 || '');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}Responses and retries
- Answer with any
2xxstatus within 10 seconds. Anything else counts as a failure. - Failed deliveries are retried with increasing delays, starting at 1 minute and growing to at most 4 hours between attempts. Retries stop 24 hours after the event.
- Events can arrive more than once or out of order. Use the event
idto skip duplicates and the message status to decide what's current.
The Recent deliveries table on the Webhook tab shows the last 100 deliveries with their event, status (Pending pending, Success succeeded, Retrying retrying, Gave up given up), number of attempts, last error and next attempt time.