# Webhooks

Get a signed message at your own URL, or in Slack, the moment something happens in RecruitAffiliates.ai.

A **webhook** is a message we send to a web address you choose when something happens, like a reply or a finished search. Your code at that address can then act on it straight away.

Webhooks come with the Grow and Enterprise plans. [Billing](/billing) has the plans.

## Add an endpoint

An **endpoint** is the web address that receives the messages.

1. Go to **Settings**, then the **Webhooks** tab.
2. Add an endpoint and paste your URL. It must be a public `https://` address.
3. Pick the events you want. The full list is [below](#events).
4. Copy the signing secret. We show it once. If you lose it, rotate it.

We refuse an address that points at a private or internal network, both when you save it and again each time we send. We never follow a redirect.

## What we send

We send each event within a minute of it happening. Each event is an HTTP `POST` with a JSON body. Here's the shape:

```http
POST /your/endpoint HTTP/1.1
Content-Type: application/json
User-Agent: RecruitAffiliates-Webhooks/1.0
Rail-Signature: t=1790415000,v1=5f1c6c...e2a9
Rail-Event: reply.received
Rail-Event-Id: fev_1AbCdEfGhIjKlMnOpQrStU
Rail-Delivery-Id: fwd_7HgFdSaQwErTyUiOpLkJh
Rail-Attempt: 1
webhook-id: fev_1AbCdEfGhIjKlMnOpQrStU
webhook-timestamp: 1790415000
webhook-signature: v1,g0hM9SsE...JEE=

{"id":"fev_1AbCdEfGhIjKlMnOpQrStU","type":"reply.received","version":1,"created_at":"2026-09-26T09:30:00.000Z","livemode":true,"data":{...}}
```

| Header | What it tells you |
| --- | --- |
| `Rail-Signature` | Proof the message came from us. See [Check the signature](#check-the-signature). |
| `Rail-Event` | The event name, the same as `type` in the body. Handy for routing before you parse. |
| `Rail-Event-Id` | The event id. It stays the same across retries and replays. |
| `Rail-Delivery-Id` | This one send. A replay gets a new delivery id and keeps the event id. |
| `Rail-Attempt` | 1 for the first try, up to 8. |
| `webhook-id`, `webhook-timestamp`, `webhook-signature` | The same proof in [Standard Webhooks](https://github.com/standard-webhooks/standard-webhooks) form, for off-the-shelf libraries. |

### The envelope

The **envelope** is the outer layer of the body. It's the same for every event:

| Key | Type | Meaning |
| --- | --- | --- |
| `id` | string | The event id. Test events start with `evt_test_`. |
| `type` | string | The event name, from the [list below](#events). |
| `version` | number | The payload version. Every event is version 1 today. We won't change a payload's keys without a new version. |
| `created_at` | string | When it happened, in ISO 8601 UTC. |
| `livemode` | boolean | `false` for a test event. See [Test mode](#test-mode). |
| `data` | object | The event's own fields, shown per event below. |

Inside `data`, ids are prefixed strings like `fdi_` or `fca_`. A prospect is always `prospect_id`. Times are ISO 8601 UTC. A field with nothing in it comes as `null`, so every event of one type has the same keys.

We never put an email body, an email address, a typed search or a deal's terms in a webhook. You get the ids. Fetch the full record from the API when you need it.

## Events

Each event has a name like `reply.received`. Pick only the ones you need.

| Event | What happened |
| --- | --- |
| `affiliate.added` | Someone added a prospect by hand. |
| `affiliate.saved` | Someone saved a prospect, to a list or to their saved affiliates. |
| `affiliate.hidden` | Someone hid a prospect, with the reason they picked. |
| `affiliate.stage_changed` | A prospect moved to another pipeline stage, by hand or because of outreach. `by_user_id` is null when outreach moved it. |
| `email.revealed` | An email address was found for a prospect. Fetch the prospect from the API to read the address. |
| `affiliate.unsubscribed` | A prospect used the unsubscribe link. Every sequence to them has stopped. |
| `do_not_contact.added` | Someone marked a prospect Do not contact. No outreach will reach them. |
| `do_not_contact.lifted` | Someone lifted a Do not contact mark. |
| `search.completed` | A search you ran from Find new finished. `state` is `done`, or `stopped_at_ceiling` when it hit its spending cap. |
| `search.failed` | A search couldn't start. The search credit was refunded. |
| `scan.completed` | A scan finished: the first one, the weekly one, or the scan behind a search. |
| `scan.failed` | A scan failed. `error` says why in plain words, and `credit_refunded` says whether a search credit came back. |
| `message.sent` | An outreach email left your inbox. `step_index` is 0 for the first email, 1 for the first follow-up. |
| `message.bounced` | An outreach email bounced. The sequence to that address has stopped. |
| `message.failed` | Your mail provider refused an outreach email. |
| `reply.received` | A prospect replied to your outreach. `reply_kind` is how we read the reply: `positive`, `question`, `not_now`, `negative`, `wrong_person` or `angry`. Interested means `positive` or `question`. Out-of-office replies, bounces and unsubscribes have their own events, or none. |
| `sequence.completed` | Every email in a sequence went out and the prospect never replied. |
| `campaign.launched` | A campaign started sending. |
| `campaign.paused` | A campaign stopped sending. `reason` is `bounce_rate` or `spam_complaint` when we paused it to protect your inbox, and null when you paused it yourself. When we pause it, `fix` says in one sentence what to do. A `bounce_rate` pause also has `sent` and `bounced`, counted since our last pause. If the campaign can't carry on, for example your plan can't send or every inbox is gone, `reason` says that instead. |
| `campaign.inbox_dropped` | One of a campaign's inboxes disconnected. Only the sequences sending from that inbox paused. The campaign keeps sending from its other inboxes, and the paused ones pick up again when you reconnect it. |
| `campaign.resumed` | A paused campaign started sending again. |
| `campaign.stopped` | Someone stopped a campaign for good. |
| `campaign.finished` | Every prospect in a campaign has finished their sequence. |
| `campaign.send_failed` | A campaign couldn't send to one prospect. The rest of the campaign carries on. |
| `deal.agreed` | You and a prospect agreed a deal. The terms are frozen at `version`; fetch the deal to read them. |
| `affiliate.activated` | An agreed deal became a live partner in AffiliateRail, with a commission rule on your program. |
| `inbox.connected` | A mailbox was connected for sending. |
| `inbox.disconnected` | A mailbox disconnected. Its sequences and campaigns paused until you reconnect it. |
| `affiliate.assigned` | A prospect got a new owner on your team, or lost one. `assignee_member_id` is null when nobody owns them now. Member ids match `GET /team`. `by_member_id` is null when the API or the system did it. |
| `note.added` | Someone on your team added a note to a prospect. The note's text isn't in the event. Read it with the prospect's notes endpoint in the API. |
| `affiliate.invited` | Someone marked a prospect invited to your affiliate program. |
| `affiliate.signed_up` | An invited prospect signed up as your affiliate. `source` is `manual` when your team marked it, or `affiliaterail` when we saw them join your AffiliateRail program. |

## Check the signature

Anyone can post to your URL. The signature proves a message came from us and wasn't changed on the way. Check it before you trust anything in the body.

The `Rail-Signature` header looks like `t=1790415000,v1=5f1c...`. Here's how to check it:

1. Split the header on commas. Take `t`, a Unix time in seconds, and every `v1` value.
2. Build the signed text: `t`, a full stop, then the **raw body** exactly as it arrived. Don't parse and re-write the JSON first, because that changes it.
3. Work out HMAC-SHA256 of that text, using your whole secret as the key, `whsec_` and all. Turn it into hex.
4. Compare it with each `v1` value, in constant time. Any match means it's real.
5. Reject it if `t` is more than five minutes from your clock. That stops someone from sending an old message again.

<Tabs items={["Node.js", "Python"]}>
<Tab value="Node.js">

```js
import { createHmac, timingSafeEqual } from "node:crypto";
import express from "express";

export function verifyRailSignature(secret, header, rawBody, toleranceSeconds = 300) {
  const parts = header.split(",").map((p) => p.trim());
  const t = parts.find((p) => p.startsWith("t="))?.slice(2);
  const sigs = parts.filter((p) => p.startsWith("v1=")).map((p) => p.slice(3));
  if (!t || !/^\d+$/.test(t) || sigs.length === 0) return false;
  if (Math.abs(Date.now() / 1000 - Number(t)) > toleranceSeconds) return false;
  const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
  return sigs.some(
    (s) => s.length === expected.length && timingSafeEqual(Buffer.from(s, "hex"), Buffer.from(expected, "hex")),
  );
}

const app = express();

// Keep the raw body. express.json() would re-write it and the check would fail.
app.post("/webhooks/recruit-affiliates", express.raw({ type: "application/json" }), (req, res) => {
  const raw = req.body.toString("utf8");
  if (!verifyRailSignature(process.env.RA_WEBHOOK_SECRET, req.get("Rail-Signature") ?? "", raw)) {
    return res.status(400).send("bad signature");
  }
  const event = JSON.parse(raw);
  res.sendStatus(200); // answer first, then do the slow work
  if (event.type === "reply.received") {
    // look up event.data.prospect_id and tell your team
  }
});

app.listen(3000);
```

</Tab>
<Tab value="Python">

```python
import hashlib
import hmac
import os
import time

from flask import Flask, abort, request

app = Flask(__name__)

def verify_rail_signature(secret: str, header: str, raw_body: bytes, tolerance: int = 300) -> bool:
    parts = [p.strip() for p in header.split(",")]
    t = next((p[2:] for p in parts if p.startswith("t=")), None)
    sigs = [p[3:] for p in parts if p.startswith("v1=")]
    if t is None or not t.isdigit() or not sigs:
        return False
    if abs(time.time() - int(t)) > tolerance:
        return False
    expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(expected, s) for s in sigs)

@app.post("/webhooks/recruit-affiliates")
def recruit_affiliates_webhook():
    raw = request.get_data()  # the raw bytes, before any JSON parsing
    if not verify_rail_signature(os.environ["RA_WEBHOOK_SECRET"], request.headers.get("Rail-Signature", ""), raw):
        abort(400)
    event = request.get_json()
    if event["type"] == "deal.agreed":
        pass  # fetch the deal by event["data"]["deal_id"]
    return "", 200
```

</Tab>
</Tabs>

### Using a Standard Webhooks library

Every message also carries the three Standard Webhooks headers, signed with the same secret. If your stack has a Standard Webhooks library, hand it the secret, the headers and the raw body, and it does the check for you.

- `webhook-id` is the event id. It stays the same across retries and replays, which is what the library uses to spot a repeat.
- `webhook-timestamp` is the same Unix time as `t` in `Rail-Signature`.
- `webhook-signature` is `v1,` then a base64 HMAC-SHA256 of `{id}.{timestamp}.{raw body}`.

The key works a little differently here. These libraries drop the `whsec_` part of the secret and base64-decode the rest before they sign. We sign these headers the same way, so the library works as it is. `Rail-Signature` keeps using the whole secret.

## Answer fast, or we retry

Answer with any **2xx** status within **10 seconds**. If the work is slow, answer first and do it after.

Anything else counts as a failed try. That means a 3xx, 4xx or 5xx, a timeout or a refused connection. We try again on this schedule, counted from the try before:

| Try | Waits |
| --- | --- |
| 2 | 1 minute |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
| 6 | 12 hours |
| 7 | 24 hours |
| 8 | 24 hours |

That's eight tries over about 63 hours, which covers a weekend outage. The waits are the least we wait, so a retry can land a little later. After the eighth failure the delivery is marked failed and stays in the log, where you can replay it.

You may get the same event twice, for example when a network drop hides your 200 from us. Treat `id` as the key and skip one you've already handled.

### When we switch an endpoint off

Let's say your endpoint went away for good. We don't keep failing at it forever. If the last 20 deliveries to an endpoint all failed every try, and those failures span at least five days, we switch it off.

Turn it back on once it works. That, or rotating its secret, starts the count again. The failed deliveries stay in the log so you can replay them.

## The delivery log and replays

Each endpoint shows its last 50 deliveries. You see the status, the full request we sent and the response your server gave.

A **replay** sends a past delivery again. You can replay any delivery from the last 30 days. It goes out as a new delivery with the same body and the same event id, so a handler that skips repeats will skip it. The old entry stays in the log as it was.

To replay one, open the log with **Show delivery log**, then press **Resend** on the delivery.

## Send a test or a sample event

Press **Send a test** on the endpoint, or pick an event and press **Send a sample**. Either one posts a sample of that event to your endpoint, right now. It's signed with your real secret, and you see the status your server gave straight away.

- Its id starts with `evt_test_` and its `livemode` is `false`.
- The `data` is the example from the [event list](#example-payloads).
- It's sent once. A failed test isn't retried.

Use it to check your signature code before a real event arrives.

## Rotate the secret

Rotate when a secret leaks, or on a routine you set. Press **Rotate secret**, then press **Yes, rotate**. You get a new secret at once, and the old one keeps working for **24 hours**.

During those 24 hours, each `Rail-Signature` has two `v1` values, the new secret's first. The code above accepts either one, so nothing breaks while you swap. After 24 hours only the new secret works. Rotating again inside the window replaces the old secret, so at most two are ever live.

## Test mode

Each endpoint is either live or test. A test endpoint gets only test events, so you can build against it without real prospects in the way. Test sends and sample events are always test events, with `livemode` set to `false`.

You can do all of this with a test API key (one starting `rail_test_`), so you never need a live key while you build:

- Add an endpoint. A test key always makes a test endpoint.
- Send it a test event of any type, then read the delivery log.
- Turn it off and on, rotate its secret, or delete it.

A test key can't see or change a live endpoint, and it can't replay a delivery.

A test key can also save, hide, move and note the 12 sample prospects. Each of those sends a real event to your test endpoints, marked `livemode: false`. Nothing else hears about it: your live endpoints, the bell, emails, reports and Activity all skip test events. Use this to run a Zapier, Make or n8n test with real-looking events.

## Send events to Slack

You can point an endpoint at a Slack channel. You don't need a Slack app or any sign-in.

1. In Slack, create an **incoming webhook** for the channel. That's a web address Slack gives you that posts into one channel. Slack's guide is at [api.slack.com/messaging/webhooks](https://api.slack.com/messaging/webhooks).
2. Add an endpoint in RecruitAffiliates.ai, choose Slack as its type, and paste that address.
3. Pick your events.

Three events get a proper Slack message:

- `reply.received`: you've got a reply, with the prospect and campaign ids.
- `deal.agreed`: you and a prospect agreed terms, with the deal id.
- `search.completed`: your search is done, and how many new prospects it found.

Any other event you pick shows as one short line with its name. Slack messages go through the same retries and the same auto switch-off as any endpoint.

## Using webhooks with Zapier, Make and n8n

Each of these tools gives you a web address that waits for a message. Add it as an endpoint and you've got a trigger for any workflow. The steps for each tool are in its recipe: [Zapier](/recipes/zapier#get-events-the-moment-they-happen), [Make](/recipes/make#get-events-the-moment-they-happen) and [n8n](/recipes/n8n#get-events-the-moment-they-happen).

## Manage endpoints with the API

You can add, list, test, rotate and delete endpoints with [the API](/api#webhooks) too. It's the same as the Webhooks tab, with the same rules.

## Example payloads

Every example below is version 1. The ids are made up.

### Prospects

#### `affiliate.added`

Someone added a prospect by hand.

```json
{
  "id": "fev_1AbCdEfGhIjKlMnOpQrStU",
  "type": "affiliate.added",
  "version": 1,
  "created_at": "2026-09-26T09:30:00.000Z",
  "livemode": true,
  "data": {
    "prospect_id": "fdi_9aBcDeFgHiJkLmNoPqRsT",
    "channel": "youtube",
    "source": "manual_add"
  }
}
```

#### `affiliate.saved`

Someone saved a prospect, to a list or to their saved affiliates.

```json
{
  "id": "fev_1AbCdEfGhIjKlMnOpQrStU",
  "type": "affiliate.saved",
  "version": 1,
  "created_at": "2026-09-26T09:30:00.000Z",
  "livemode": true,
  "data": {
    "prospect_id": "fdi_9aBcDeFgHiJkLmNoPqRsT",
    "channel": "website",
    "key": "reviewer.com",
    "list_id": null
  }
}
```

#### `affiliate.hidden`

Someone hid a prospect, with the reason they picked.

```json
{
  "id": "fev_1AbCdEfGhIjKlMnOpQrStU",
  "type": "affiliate.hidden",
  "version": 1,
  "created_at": "2026-09-26T09:30:00.000Z",
  "livemode": true,
  "data": {
    "prospect_id": "fdi_9aBcDeFgHiJkLmNoPqRsT",
    "reason": "not_relevant"
  }
}
```

#### `affiliate.stage_changed`

A prospect moved to another pipeline stage, by hand or because of outreach. `by_user_id` is null when outreach moved it.

```json
{
  "id": "fev_1AbCdEfGhIjKlMnOpQrStU",
  "type": "affiliate.stage_changed",
  "version": 1,
  "created_at": "2026-09-26T09:30:00.000Z",
  "livemode": true,
  "data": {
    "prospect_id": "fdi_9aBcDeFgHiJkLmNoPqRsT",
    "from": "contacted",
    "to": "replied",
    "by_user_id": null,
    "reason": null
  }
}
```

#### `email.revealed`

An email address was found for a prospect. Fetch the prospect from the API to read the address.

```json
{
  "id": "fev_1AbCdEfGhIjKlMnOpQrStU",
  "type": "email.revealed",
  "version": 1,
  "created_at": "2026-09-26T09:30:00.000Z",
  "livemode": true,
  "data": {
    "prospect_id": "fdi_9aBcDeFgHiJkLmNoPqRsT",
    "source": "website",
    "verification": "valid",
    "addresses": 1
  }
}
```

#### `affiliate.unsubscribed`

A prospect used the unsubscribe link. Every sequence to them has stopped.

```json
{
  "id": "fev_1AbCdEfGhIjKlMnOpQrStU",
  "type": "affiliate.unsubscribed",
  "version": 1,
  "created_at": "2026-09-26T09:30:00.000Z",
  "livemode": true,
  "data": {
    "brand_id": "fbr_4Jk2mN8pQrS7tUvWxYz01A",
    "prospect_id": "fdi_9aBcDeFgHiJkLmNoPqRsT",
    "campaign_id": "fca_3KlMnOpQrStUvWxYz01234",
    "sequence_id": "fsq_5HgFdSaQwErTyUiOpLkJh",
    "inbox_id": "fin_6ZxCvBnMaSdFgHjKlQwEr",
    "occurred_at": "2026-09-26T09:30:00.000Z"
  }
}
```

#### `do_not_contact.added`

Someone marked a prospect Do not contact. No outreach will reach them.

```json
{
  "id": "fev_1AbCdEfGhIjKlMnOpQrStU",
  "type": "do_not_contact.added",
  "version": 1,
  "created_at": "2026-09-26T09:30:00.000Z",
  "livemode": true,
  "data": {
    "prospect_id": "fdi_9aBcDeFgHiJkLmNoPqRsT",
    "reason": "asked_not_to",
    "by_user_id": "usr_1QaZxSwEdCvFrTgBnHyUj"
  }
}
```

#### `do_not_contact.lifted`

Someone lifted a Do not contact mark.

```json
{
  "id": "fev_1AbCdEfGhIjKlMnOpQrStU",
  "type": "do_not_contact.lifted",
  "version": 1,
  "created_at": "2026-09-26T09:30:00.000Z",
  "livemode": true,
  "data": {
    "prospect_id": "fdi_9aBcDeFgHiJkLmNoPqRsT",
    "reason": "asked_not_to",
    "by_user_id": "usr_1QaZxSwEdCvFrTgBnHyUj"
  }
}
```

### Searches and scans

#### `search.completed`

A search you ran from Find new finished. `state` is `done`, or `stopped_at_ceiling` when it hit its spending cap.

```json
{
  "id": "fev_1AbCdEfGhIjKlMnOpQrStU",
  "type": "search.completed",
  "version": 1,
  "created_at": "2026-09-26T09:30:00.000Z",
  "livemode": true,
  "data": {
    "search_id": "fsc_7WxYz0123456789AbCdEf",
    "brand_id": "fbr_4Jk2mN8pQrS7tUvWxYz01A",
    "search_type": "topic_search",
    "state": "done",
    "found": 42
  }
}
```

#### `search.failed`

A search couldn't start. The search credit was refunded.

```json
{
  "id": "fev_1AbCdEfGhIjKlMnOpQrStU",
  "type": "search.failed",
  "version": 1,
  "created_at": "2026-09-26T09:30:00.000Z",
  "livemode": true,
  "data": {
    "search_id": "fsc_7WxYz0123456789AbCdEf",
    "credit_refunded": true
  }
}
```

#### `scan.completed`

A scan finished: the first one, the weekly one, or the scan behind a search.

```json
{
  "id": "fev_1AbCdEfGhIjKlMnOpQrStU",
  "type": "scan.completed",
  "version": 1,
  "created_at": "2026-09-26T09:30:00.000Z",
  "livemode": true,
  "data": {
    "scan_id": "fsc_7WxYz0123456789AbCdEf",
    "brand_id": "fbr_4Jk2mN8pQrS7tUvWxYz01A",
    "trigger": "weekly",
    "state": "done",
    "found": 17
  }
}
```

#### `scan.failed`

A scan failed. `error` says why in plain words, and `credit_refunded` says whether a search credit came back.

```json
{
  "id": "fev_1AbCdEfGhIjKlMnOpQrStU",
  "type": "scan.failed",
  "version": 1,
  "created_at": "2026-09-26T09:30:00.000Z",
  "livemode": true,
  "data": {
    "scan_id": "fsc_7WxYz0123456789AbCdEf",
    "brand_id": "fbr_4Jk2mN8pQrS7tUvWxYz01A",
    "trigger": "competitor_search",
    "error": "No discovery source answered.",
    "credit_refunded": true
  }
}
```

### Outreach

#### `message.sent`

An outreach email left your inbox. `step_index` is 0 for the first email, 1 for the first follow-up.

```json
{
  "id": "fev_1AbCdEfGhIjKlMnOpQrStU",
  "type": "message.sent",
  "version": 1,
  "created_at": "2026-09-26T09:30:00.000Z",
  "livemode": true,
  "data": {
    "brand_id": "fbr_4Jk2mN8pQrS7tUvWxYz01A",
    "prospect_id": "fdi_9aBcDeFgHiJkLmNoPqRsT",
    "campaign_id": "fca_3KlMnOpQrStUvWxYz01234",
    "sequence_id": "fsq_5HgFdSaQwErTyUiOpLkJh",
    "inbox_id": "fin_6ZxCvBnMaSdFgHjKlQwEr",
    "occurred_at": "2026-09-26T09:30:00.000Z",
    "message_id": "fms_8PoIuYtReWqAsDfGhJkLz",
    "step_index": 0
  }
}
```

#### `message.bounced`

An outreach email bounced. The sequence to that address has stopped.

```json
{
  "id": "fev_1AbCdEfGhIjKlMnOpQrStU",
  "type": "message.bounced",
  "version": 1,
  "created_at": "2026-09-26T09:30:00.000Z",
  "livemode": true,
  "data": {
    "brand_id": "fbr_4Jk2mN8pQrS7tUvWxYz01A",
    "prospect_id": "fdi_9aBcDeFgHiJkLmNoPqRsT",
    "campaign_id": "fca_3KlMnOpQrStUvWxYz01234",
    "sequence_id": "fsq_5HgFdSaQwErTyUiOpLkJh",
    "inbox_id": "fin_6ZxCvBnMaSdFgHjKlQwEr",
    "occurred_at": "2026-09-26T09:30:00.000Z",
    "message_id": "fms_8PoIuYtReWqAsDfGhJkLz"
  }
}
```

#### `message.failed`

Your mail provider refused an outreach email.

```json
{
  "id": "fev_1AbCdEfGhIjKlMnOpQrStU",
  "type": "message.failed",
  "version": 1,
  "created_at": "2026-09-26T09:30:00.000Z",
  "livemode": true,
  "data": {
    "brand_id": "fbr_4Jk2mN8pQrS7tUvWxYz01A",
    "prospect_id": "fdi_9aBcDeFgHiJkLmNoPqRsT",
    "campaign_id": "fca_3KlMnOpQrStUvWxYz01234",
    "sequence_id": "fsq_5HgFdSaQwErTyUiOpLkJh",
    "inbox_id": "fin_6ZxCvBnMaSdFgHjKlQwEr",
    "occurred_at": "2026-09-26T09:30:00.000Z",
    "step_index": 1,
    "reason": "provider_rejected"
  }
}
```

#### `reply.received`

A prospect replied to your outreach. `reply_kind` is how we read the reply: `positive`, `question`, `not_now`, `negative`, `wrong_person` or `angry`. Interested means `positive` or `question`. Out-of-office replies, bounces and unsubscribes have their own events, or none.

```json
{
  "id": "fev_1AbCdEfGhIjKlMnOpQrStU",
  "type": "reply.received",
  "version": 1,
  "created_at": "2026-09-26T09:30:00.000Z",
  "livemode": true,
  "data": {
    "brand_id": "fbr_4Jk2mN8pQrS7tUvWxYz01A",
    "prospect_id": "fdi_9aBcDeFgHiJkLmNoPqRsT",
    "campaign_id": "fca_3KlMnOpQrStUvWxYz01234",
    "sequence_id": "fsq_5HgFdSaQwErTyUiOpLkJh",
    "inbox_id": "fin_6ZxCvBnMaSdFgHjKlQwEr",
    "occurred_at": "2026-09-26T09:30:00.000Z",
    "message_id": "fms_8PoIuYtReWqAsDfGhJkLz",
    "reply_kind": "positive"
  }
}
```

#### `sequence.completed`

Every email in a sequence went out and the prospect never replied.

```json
{
  "id": "fev_1AbCdEfGhIjKlMnOpQrStU",
  "type": "sequence.completed",
  "version": 1,
  "created_at": "2026-09-26T09:30:00.000Z",
  "livemode": true,
  "data": {
    "brand_id": "fbr_4Jk2mN8pQrS7tUvWxYz01A",
    "prospect_id": "fdi_9aBcDeFgHiJkLmNoPqRsT",
    "campaign_id": "fca_3KlMnOpQrStUvWxYz01234",
    "sequence_id": "fsq_5HgFdSaQwErTyUiOpLkJh",
    "inbox_id": "fin_6ZxCvBnMaSdFgHjKlQwEr",
    "occurred_at": "2026-09-26T09:30:00.000Z",
    "outcome": "unanswered"
  }
}
```

### Campaigns

Three of these tell you a campaign needs you.

- `campaign.paused` means the whole campaign stopped sending. If `reason` is `bounce_rate`, too many emails bounced back, which usually means the list has bad addresses. If it's `spam_complaint`, someone marked an email as spam. We pause first so your inbox keeps a good name. Read `fix`, do what it says, then resume the campaign. If `reason` is null, you or a teammate paused it, and there's nothing to fix. Any other `reason`, like `not_entitled`, means the campaign is missing something it needs, such as a plan that can send or a connected inbox. Sort that out, then resume.
- `campaign.inbox_dropped` means one inbox the campaign sends from lost its connection. Only the emails from that inbox are on hold. The rest keep going. Reconnect the inbox in Settings and its emails pick up where they stopped.
- `campaign.send_failed` means one person couldn't be emailed, often because we have no address for them. Everyone else still gets their emails.

#### `campaign.launched`

A campaign started sending.

```json
{
  "id": "fev_1AbCdEfGhIjKlMnOpQrStU",
  "type": "campaign.launched",
  "version": 1,
  "created_at": "2026-09-26T09:30:00.000Z",
  "livemode": true,
  "data": {
    "brand_id": "fbr_4Jk2mN8pQrS7tUvWxYz01A",
    "campaign_id": "fca_3KlMnOpQrStUvWxYz01234",
    "occurred_at": "2026-09-26T09:30:00.000Z"
  }
}
```

#### `campaign.paused`

A campaign stopped sending. `reason` is `bounce_rate` or `spam_complaint` when we paused it to protect your inbox, and null when you paused it yourself. When we pause it, `fix` says in one sentence what to do. A `bounce_rate` pause also has `sent` and `bounced`, counted since our last pause. If the campaign can't carry on, for example your plan can't send or every inbox is gone, `reason` says that instead.

```json
{
  "id": "fev_1AbCdEfGhIjKlMnOpQrStU",
  "type": "campaign.paused",
  "version": 1,
  "created_at": "2026-09-26T09:30:00.000Z",
  "livemode": true,
  "data": {
    "brand_id": "fbr_4Jk2mN8pQrS7tUvWxYz01A",
    "campaign_id": "fca_3KlMnOpQrStUvWxYz01234",
    "occurred_at": "2026-09-26T09:30:00.000Z",
    "reason": "bounce_rate",
    "fix": "Too many emails bounced. Check the list for bad addresses, then resume.",
    "sent": 40,
    "bounced": 5
  }
}
```

#### `campaign.inbox_dropped`

One of a campaign's inboxes disconnected. Only the sequences sending from that inbox paused. The campaign keeps sending from its other inboxes, and the paused ones pick up again when you reconnect it.

```json
{
  "id": "fev_1AbCdEfGhIjKlMnOpQrStU",
  "type": "campaign.inbox_dropped",
  "version": 1,
  "created_at": "2026-09-26T09:30:00.000Z",
  "livemode": true,
  "data": {
    "brand_id": "fbr_4Jk2mN8pQrS7tUvWxYz01A",
    "campaign_id": "fca_3KlMnOpQrStUvWxYz01234",
    "occurred_at": "2026-09-26T09:30:00.000Z",
    "inbox_id": "fin_6ZxCvBnMaSdFgHjKlQwEr",
    "reason": "inbox_disconnected"
  }
}
```

#### `campaign.resumed`

A paused campaign started sending again.

```json
{
  "id": "fev_1AbCdEfGhIjKlMnOpQrStU",
  "type": "campaign.resumed",
  "version": 1,
  "created_at": "2026-09-26T09:30:00.000Z",
  "livemode": true,
  "data": {
    "brand_id": "fbr_4Jk2mN8pQrS7tUvWxYz01A",
    "campaign_id": "fca_3KlMnOpQrStUvWxYz01234",
    "occurred_at": "2026-09-26T09:30:00.000Z"
  }
}
```

#### `campaign.stopped`

Someone stopped a campaign for good.

```json
{
  "id": "fev_1AbCdEfGhIjKlMnOpQrStU",
  "type": "campaign.stopped",
  "version": 1,
  "created_at": "2026-09-26T09:30:00.000Z",
  "livemode": true,
  "data": {
    "brand_id": "fbr_4Jk2mN8pQrS7tUvWxYz01A",
    "campaign_id": "fca_3KlMnOpQrStUvWxYz01234",
    "occurred_at": "2026-09-26T09:30:00.000Z"
  }
}
```

#### `campaign.finished`

Every prospect in a campaign has finished their sequence.

```json
{
  "id": "fev_1AbCdEfGhIjKlMnOpQrStU",
  "type": "campaign.finished",
  "version": 1,
  "created_at": "2026-09-26T09:30:00.000Z",
  "livemode": true,
  "data": {
    "brand_id": "fbr_4Jk2mN8pQrS7tUvWxYz01A",
    "campaign_id": "fca_3KlMnOpQrStUvWxYz01234",
    "occurred_at": "2026-09-26T09:30:00.000Z"
  }
}
```

#### `campaign.send_failed`

A campaign couldn't send to one prospect. The rest of the campaign carries on.

```json
{
  "id": "fev_1AbCdEfGhIjKlMnOpQrStU",
  "type": "campaign.send_failed",
  "version": 1,
  "created_at": "2026-09-26T09:30:00.000Z",
  "livemode": true,
  "data": {
    "brand_id": "fbr_4Jk2mN8pQrS7tUvWxYz01A",
    "campaign_id": "fca_3KlMnOpQrStUvWxYz01234",
    "occurred_at": "2026-09-26T09:30:00.000Z",
    "prospect_id": "fdi_9aBcDeFgHiJkLmNoPqRsT",
    "sequence_id": "fsq_5HgFdSaQwErTyUiOpLkJh",
    "reason": "no_email"
  }
}
```

### Deals and partners

#### `deal.agreed`

You and a prospect agreed a deal. The terms are frozen at `version`; fetch the deal to read them.

```json
{
  "id": "fev_1AbCdEfGhIjKlMnOpQrStU",
  "type": "deal.agreed",
  "version": 1,
  "created_at": "2026-09-26T09:30:00.000Z",
  "livemode": true,
  "data": {
    "deal_id": "fdl_2PqRsTuVwXyZ0123456789",
    "prospect_id": "fdi_9aBcDeFgHiJkLmNoPqRsT",
    "version": 2,
    "agreed_by": "usr_1QaZxSwEdCvFrTgBnHyUj"
  }
}
```

#### `affiliate.activated`

An agreed deal became a live partner in AffiliateRail, with a commission rule on your program.

```json
{
  "id": "fev_1AbCdEfGhIjKlMnOpQrStU",
  "type": "affiliate.activated",
  "version": 1,
  "created_at": "2026-09-26T09:30:00.000Z",
  "livemode": true,
  "data": {
    "deal_id": "fdl_2PqRsTuVwXyZ0123456789",
    "prospect_id": "fdi_9aBcDeFgHiJkLmNoPqRsT",
    "program_id": "prg_4Jk2mN8pQrS7tUvWxYz01B",
    "partner_id": "part_4Jk2mN8pQrS7tUvWxYz01C",
    "rule_id": "flw_7WxYz0123456789AbCdEg",
    "linked_existing": false,
    "terms_summary": "20% for 12 months"
  }
}
```

### Inboxes

#### `inbox.connected`

A mailbox was connected for sending.

```json
{
  "id": "fev_1AbCdEfGhIjKlMnOpQrStU",
  "type": "inbox.connected",
  "version": 1,
  "created_at": "2026-09-26T09:30:00.000Z",
  "livemode": true,
  "data": {
    "inbox_id": "fin_6ZxCvBnMaSdFgHjKlQwEr",
    "occurred_at": "2026-09-26T09:30:00.000Z"
  }
}
```

#### `inbox.disconnected`

A mailbox disconnected. Its sequences and campaigns paused until you reconnect it.

```json
{
  "id": "fev_1AbCdEfGhIjKlMnOpQrStU",
  "type": "inbox.disconnected",
  "version": 1,
  "created_at": "2026-09-26T09:30:00.000Z",
  "livemode": true,
  "data": {
    "inbox_id": "fin_6ZxCvBnMaSdFgHjKlQwEr",
    "reason": "provider_revoked",
    "paused_sequences": 12,
    "paused_campaigns": 1,
    "occurred_at": "2026-09-26T09:30:00.000Z"
  }
}
```

### Team and partners

#### `affiliate.assigned`

A prospect got a new owner on your team, or lost one. `assignee_member_id` is null when nobody owns them now. Member ids match `GET /team`. `by_member_id` is null when the API or the system did it.

```json
{
  "id": "fev_1AbCdEfGhIjKlMnOpQrStU",
  "type": "affiliate.assigned",
  "version": 1,
  "created_at": "2026-09-26T09:30:00.000Z",
  "livemode": true,
  "data": {
    "prospect_id": "fdi_9aBcDeFgHiJkLmNoPqRsT",
    "brand_id": "fbr_4Jk2mN8pQrS7tUvWxYz01A",
    "assignee_member_id": "tm_4RfVbGtYhNjUmIkOlP0q1W",
    "previous_member_id": null,
    "by_member_id": "tm_7YhNbGtRfVcDeXsWzAq2E"
  }
}
```

#### `note.added`

Someone on your team added a note to a prospect. The note's text isn't in the event. Read it with the prospect's notes endpoint in the API.

```json
{
  "id": "fev_1AbCdEfGhIjKlMnOpQrStU",
  "type": "note.added",
  "version": 1,
  "created_at": "2026-09-26T09:30:00.000Z",
  "livemode": true,
  "data": {
    "prospect_id": "fdi_9aBcDeFgHiJkLmNoPqRsT",
    "brand_id": "fbr_4Jk2mN8pQrS7tUvWxYz01A",
    "note_id": "fcn_2WsXcDeRfVbGtYhNjUmKi",
    "author_member_id": "tm_4RfVbGtYhNjUmIkOlP0q1W"
  }
}
```

#### `affiliate.invited`

Someone marked a prospect invited to your affiliate program.

```json
{
  "id": "fev_1AbCdEfGhIjKlMnOpQrStU",
  "type": "affiliate.invited",
  "version": 1,
  "created_at": "2026-09-26T09:30:00.000Z",
  "livemode": true,
  "data": {
    "prospect_id": "fdi_9aBcDeFgHiJkLmNoPqRsT",
    "brand_id": "fbr_4Jk2mN8pQrS7tUvWxYz01A",
    "by_member_id": "tm_4RfVbGtYhNjUmIkOlP0q1W"
  }
}
```

#### `affiliate.signed_up`

An invited prospect signed up as your affiliate. `source` is `manual` when your team marked it, or `affiliaterail` when we saw them join your AffiliateRail program.

```json
{
  "id": "fev_1AbCdEfGhIjKlMnOpQrStU",
  "type": "affiliate.signed_up",
  "version": 1,
  "created_at": "2026-09-26T09:30:00.000Z",
  "livemode": true,
  "data": {
    "prospect_id": "fdi_9aBcDeFgHiJkLmNoPqRsT",
    "brand_id": "fbr_4Jk2mN8pQrS7tUvWxYz01A",
    "source": "manual"
  }
}
```
