Docs/Getting started

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 typeFires when
EmailSentA campaign email is sent.
EmailReplyA prospect replies and the reply has been sorted into a category. Bounces are not replies.
EmailBounceAn email bounces, hard or soft.
EmailUnsubscribeA lead unsubscribes, by reply or through the unsubscribe link.
LeadCategoryUpdateA lead's category changes, for example to interested or meeting_booked.
LeadCreatedPeople are imported from Lead Finder. It does not fire for POST /leads or CSV uploads.
CampaignStatusChangedA campaign's status changes, for example from running to paused or completed.
CreditBalanceLowThe credit balance drops below the auto top-up threshold, or 100 when none is set.
AutopilotRunStatusChangedAn Autopilot run moves to a new status.
DfyOrderCompletedA 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

Shell
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:

HeaderValue
X-Emailchaser-TimestampThe send time, in Unix seconds
X-Emailchaser-Signaturev1= and the hex HMAC-SHA256 of <timestamp>.<raw body>, keyed with your secret
X-Emailchaser-Event-IdThe 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.

JavaScript
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))
  );
}
Python
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:

JSON
{
  "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:

EventFields
EmailSentThe EmailReply fields without response_category
EmailBounceThe EmailReply fields plus bounce_type (hard_bounce or soft_bounce) and bounced_at
EmailUnsubscribeevent_id, event (email.unsubscribed), lead_id, lead_email, first_name, last_name, campaign_id, email_id, source (reply or unsubscribe_link), unsubscribed_at
LeadCategoryUpdateevent_id, lead_id, email_address, first_name, last_name, company, title, linkedin_url, phone_number, old_category, new_category, campaign_id, updated_at
LeadCreatedevent_id, lead_id, email_address, first_name, last_name, company, title, linkedin_url, phone_number, origin, campaign_id, created_at
CampaignStatusChangedevent_id, campaign_id, name, old_status, new_status, changed_at
CreditBalanceLowevent_id, balance, threshold
AutopilotRunStatusChangedeventId, runId, from, to, lastError, killReason
DfyOrderCompletedeventId, 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.