Webhooks
A webhook sends an HTTPS POST to your server the moment something happens in Emailchaser: an email goes out, a prospect replies, a lead changes category, a campaign stops. You stop polling, and your CRM or Slack hears about a reply within seconds.
One webhook listens to one event type
| Event type | Fires when |
|---|---|
EmailSent | A campaign email is sent. |
EmailReply | A prospect replies and the reply has been sorted into a category. Bounces are not replies. |
EmailBounce | An email bounces, hard or soft. |
EmailUnsubscribe | A lead unsubscribes, by reply or through the unsubscribe link. |
LeadCategoryUpdate | A lead's category changes, for example to interested or meeting_booked. |
LeadCreated | People are imported from Lead Finder. It does not fire for POST /leads or CSV uploads. |
CampaignStatusChanged | A campaign's status changes, for example from running to paused or completed. |
CreditBalanceLow | The credit balance drops below the auto top-up threshold, or 100 when none is set. |
AutopilotRunStatusChanged | An Autopilot run moves to a new status. |
DfyOrderCompleted | A done-for-you order of domains and mailboxes finishes. |
To hear about several event types, register one webhook per type. They can all point at the same URL and branch on the payload.
Register a webhook with one call
curl -X POST "https://api.emailchaser.com/r/webhooks" \
-H "Authorization: Bearer $EMAILCHASER_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/webhooks/emailchaser",
"type": "EmailReply",
"name": "Replies to CRM"
}'The URL must use https and be reachable from the internet: local, private and loopback addresses are refused. name is optional. GET /webhooks lists your webhooks, PUT /webhooks/{id} changes the URL, type, name or isEnabled, and DELETE /webhooks/{id} removes one. Each POST creates a new webhook, so check GET /webhooks before retrying a failed create.
Copy the signing secret from the app
Every new webhook gets its own signing secret, starting whsec_. The API does not return it. Open Settings, Integrations in the app, go to the Webhooks tab, and copy the secret from your webhook. You can rotate it there too.
Check the signature on every delivery
Each delivery carries three headers:
| Header | Value |
|---|---|
X-Emailchaser-Timestamp | The send time, in Unix seconds |
X-Emailchaser-Signature | v1= and the hex HMAC-SHA256 of <timestamp>.<raw body>, keyed with your secret |
X-Emailchaser-Event-Id | The event's ID |
Compute the same HMAC over the raw request body, before any JSON parsing, and compare it in constant time. Refusing timestamps more than 5 minutes old also stops someone replaying an old delivery.
import crypto from "node:crypto";
// rawBody: the request body exactly as received, as a string.
function isFromEmailchaser(rawBody, headers, secret) {
const timestamp = headers["x-emailchaser-timestamp"];
const signature = headers["x-emailchaser-signature"] || "";
const expected =
"v1=" + crypto.createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex");
const fresh = Math.abs(Date.now() / 1000 - Number(timestamp)) < 300;
return (
fresh &&
signature.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
);
}import hashlib
import hmac
import time
def is_from_emailchaser(raw_body: bytes, headers, secret: str) -> bool:
timestamp = headers.get("X-Emailchaser-Timestamp", "")
signature = headers.get("X-Emailchaser-Signature", "")
expected = "v1=" + hmac.new(
secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256
).hexdigest()
fresh = abs(time.time() - int(timestamp or 0)) < 300
return fresh and hmac.compare_digest(signature, expected)Webhooks created before signing existed, and never given a secret since, are delivered unsigned. Rotating the secret in the app turns signing on.
Answer fast, because failed deliveries are retried only briefly
Answer with any 2xx status within 30 seconds; do slow work after you answer. When your server answers 5xx or can't be reached, we try 3 more times, waiting 1, 2 and then 4 seconds. A 4xx answer stops the retries at once. After the last try the event is dropped, so keep the endpoint up and fast, and use the list endpoints to catch up after an outage.
EmailReply, EmailBounce and EmailUnsubscribe carry a unique ID (evt_ and 32 hex characters) in event_id and in the X-Emailchaser-Event-Id header, and a retried delivery keeps it, so you can skip repeats. The other event types can arrive with an empty ID today; deduplicate those on their own fields, such as lead_id with updated_at.
Every payload is flat JSON
Most events use snake_case field names. AutopilotRunStatusChanged and DfyOrderCompleted use camelCase.
An EmailReply delivery:
{
"event_id": "evt_3f2a9c0d6b1e4f7a8c5d2e9b0a1f3c4d",
"email_id": 9011245,
"thread_id": "<0a1b2c3d@mail.acme.com>",
"external_id": "<4e5f6a7b@mail.acme.com>",
"kind": "inbound",
"recipient": "you@yourcompany.com",
"subject": "Re: Quick question about Acme",
"body_text": "Sounds interesting. Can you send pricing?",
"body_html": "<div>Sounds interesting. Can you send pricing?</div>",
"response_category": "interested",
"from_email": "jane@acme.com",
"from_name": "Jane Doe",
"recipients": "[{\"name\":\"\",\"email\":\"you@yourcompany.com\"}]",
"lead_id": 8589949820,
"campaign_id": 1234,
"sent_at": "2026-10-07T09:30:00Z"
}recipients is a string that holds a JSON array; parse it a second time. The other events carry these fields:
| Event | Fields |
|---|---|
EmailSent | The EmailReply fields without response_category |
EmailBounce | The EmailReply fields plus bounce_type (hard_bounce or soft_bounce) and bounced_at |
EmailUnsubscribe | event_id, event (email.unsubscribed), lead_id, lead_email, first_name, last_name, campaign_id, email_id, source (reply or unsubscribe_link), unsubscribed_at |
LeadCategoryUpdate | event_id, lead_id, email_address, first_name, last_name, company, title, linkedin_url, phone_number, old_category, new_category, campaign_id, updated_at |
LeadCreated | event_id, lead_id, email_address, first_name, last_name, company, title, linkedin_url, phone_number, origin, campaign_id, created_at |
CampaignStatusChanged | event_id, campaign_id, name, old_status, new_status, changed_at |
CreditBalanceLow | event_id, balance, threshold |
AutopilotRunStatusChanged | eventId, runId, from, to, lastError, killReason |
DfyOrderCompleted | eventId, orderId, status, mailboxCount |
Lead categories are interested, not_interested, bounced, out_of_office, delivery_incomplete and meeting_booked.
Questions about the API? Email support@emailchaser.com.