# Recipe: get notified of positive replies

> Receive a signed webhook when a prospect replies to a campaign, read its intent and confidence, and route interested replies to Slack, a CRM or your code.

Source: https://docs.ayinbox.com/api/recipes/positive-reply-webhook/

Subscribe a webhook to `campaign.reply_received` and AY Inbox posts each reply it detects to your HTTPS endpoint, signed with HMAC-SHA256. The payload carries the contact, a snippet, and the reply's intent and confidence, plus the [inbox tagging](/guides/inbox-tagging/) verdict when there is one. Filter on those fields in your receiver to route positive replies to Slack, a CRM or a person.

A **positive reply** in this recipe is one where the prospect wants a next step: they are interested, want a call, or ask about pricing. AY Inbox gives you two readings of that. `intent` comes from the workspace's reply keyword lists, with out-of-office and other automated replies recognised from their headers and wording. The `ai_*` fields come from inbox tagging, which reads the message with a model and only states an intent it is confident in. How the two compare is covered in [reply classification](/learn/reply-classification/).

1. Create the endpoint [#1-create-the-endpoint]

`POST /webhooks` needs the `WEBHOOKS` scope (for a member, **Manage settings**).

```bash
curl -s -X POST "https://api.ayinbox.com/v1/webhooks" \
  -H "Authorization: Bearer aybox_..." \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://hooks.example.com/ayinbox",
    "description": "Positive replies",
    "event_types": ["campaign.reply_received"]
  }'
```

The `201` response is the only time the signing `secret` (`whsec_...`) is returned. Store it in a secrets manager now; if you lose it, rotate it with `POST /webhooks/:id/rotate-secret`.

| Rule         | Detail                                                                                          |
| ------------ | ----------------------------------------------------------------------------------------------- |
| URL          | HTTPS and publicly routable. Private, loopback and link-local addresses are refused             |
| Verification | A new endpoint gets one signed `webhook.test` request and no real events until it answers `2xx` |
| Event filter | `campaign.reply_received` is a standard event. An empty list means every standard event         |

The same endpoint can be created in **Settings > Webhooks**; see the [webhooks guide](/guides/webhooks/).

2. Verify the signature [#2-verify-the-signature]

Every delivery carries `X-Ayinbox-Signature: t=<unix>,v1=<hex>`. To check it:

1. Parse `t` and `v1` from the header. Ignore any scheme other than `v1`.
2. Reject the request if `t` is more than 5 minutes from your clock.
3. Compute `HMAC-SHA256(secret, t + "." + raw_body)` over the raw request body, before any JSON parsing, and hex-encode it.
4. Compare it with `v1` in constant time (`hmac.compare_digest` in Python).

Compute over the raw bytes. Parsing and re-serializing the JSON changes the bytes and the check fails.

3. Read the payload [#3-read-the-payload]

The body is an envelope with `id`, `event_type`, `organization_id`, `created_at` and `data`. For `campaign.reply_received`, `data` holds:

| Field                               | Always present | What it holds                                                                                                                                                                   |
| ----------------------------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `contact_email`                     | Yes            | Who replied                                                                                                                                                                     |
| `intent`                            | Yes            | Keyword reading: `positive`, `negative`, `question`, `neutral`, `out_of_office` or `automated`                                                                                  |
| `confidence`                        | Yes            | How sure that reading is, 0 to 1                                                                                                                                                |
| `subject`, `snippet`                | Yes            | The reply's subject and a short excerpt                                                                                                                                         |
| `action_taken`                      | Yes            | What AY Inbox did, comma-separated: `paused_campaign`, `suppressed_recipient` or `created_crm_task`; empty when nothing                                                         |
| `thread_id`, `email_account_id`     | Yes            | The conversation and the mailbox it arrived in                                                                                                                                  |
| `contact_id`, `campaign_id`         | When linked    | The contact and campaign the reply belongs to. A reply AY Inbox cannot tie to a campaign arrives without `campaign_id`                                                          |
| `ai_labels`                         | When tagged    | Labels such as `Interested`, `Meeting`, `Pricing`, `Question`, `Not interested`                                                                                                 |
| `ai_intent`, `ai_intent_confidence` | When tagged    | What a human reply wants (`agreed`, `scheduling`, `wants_pricing`, `not_interested` and others), and how sure tagging was, 0 to 1. Empty string and 0 when it was not confident |
| `ai_kind`                           | When tagged    | `human_reply`, `auto_reply_ooo`, `auto_reply_ticket` and others. Empty when unsure                                                                                              |
| `ai_relevance`, `ai_needs_review`   | When tagged    | A 0 to 100 relevance score, and whether tagging flagged it for a person                                                                                                         |

The six `ai_*` keys are absent when tagging never judged the message. Test for `ai_needs_review` to tell "judged" from "not judged": it is present even when its value is `false`.

Three things to know about when the event fires. Automated replies arrive too, with `intent` set to `out_of_office` or `automated`. Bounces and notifications do not. And the event comes from reply handling that the workspace's reply-intent setting governs. It is on by default; with it off, replies are still detected but no `campaign.reply_received` is sent.

4. Route by intent [#4-route-by-intent]

This receiver verifies the signature, drops duplicates and automated mail, and forwards replies that read as interested.

```python
import hashlib, hmac, json, os, time
from flask import Flask, request

app = Flask(__name__)
SECRET = os.environ["AYINBOX_WEBHOOK_SECRET"].encode()
seen = set()  # use a database table in production

def valid(header, raw):
    parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
    t, v1 = parts.get("t"), parts.get("v1")
    if not t or not v1 or abs(time.time() - int(t)) > 300:
        return False
    expected = hmac.new(SECRET, f"{t}.".encode() + raw, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, v1)

def is_positive(d):
    if d.get("intent") in ("out_of_office", "automated"):
        return False
    if "ai_needs_review" in d:  # inbox tagging judged it
        labels = {l.lower() for l in d.get("ai_labels", [])}
        return bool(labels & {"interested", "meeting", "pricing"})
    return d.get("intent") == "positive" and d.get("confidence", 0) >= 0.6

@app.post("/ayinbox")
def receive():
    raw = request.get_data()
    if not valid(request.headers.get("X-Ayinbox-Signature", ""), raw):
        return "", 401
    event = json.loads(raw)
    event_id = request.headers.get("X-Ayinbox-Event-Id", event["id"])
    if event_id in seen or event["event_type"] != "campaign.reply_received":
        return "", 200
    seen.add(event_id)
    if is_positive(event["data"]):
        notify(event["data"])  # post to Slack, upsert in your CRM, page a rep
    return "", 200
```

`notify` is yours to write. Keep it fast, or queue the work and answer `2xx` first, so a slow downstream call does not turn into a failed delivery and a retry. The `0.6` threshold is an example; tune it on your own replies.

5. Handle retries and duplicates [#5-handle-retries-and-duplicates]

A delivery that gets a non-`2xx` answer, a redirect or no answer is retried with exponential backoff (30 seconds, 1 minute, 2 minutes and so on, capped at 1 hour between attempts) for up to 8 attempts, then marked abandoned. An endpoint that keeps failing over a sustained window is disabled automatically.

* **Dedupe on the event id.** `X-Ayinbox-Event-Id` (the payload `id`) is stable across retries and redeliveries, so a retry that arrives after you already handled the event is safe to drop.
* **Read the delivery log.** `GET /webhooks/deliveries?event_type=campaign.reply_received` lists attempts with status, response code and a snippet of your response body.
* **Recover abandoned events.** `POST /webhooks/deliveries/:deliveryId/redeliver` sends the original payload again with the same event id.

The full endpoint and payload reference is in the [webhooks API reference](/api/reference/webhooks/).

Alternatives [#alternatives]

| You want                            | Use                                                                                                                                       |
| ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| A ping in a Slack channel, no code  | The [Slack integration](/guides/slack/): route the **Reply received** notification category to a channel. Each post has **Draft a reply** |
| Every human reply worked in Slack   | The Slack **inbox channel**, which posts human replies as one thread per conversation                                                     |
| A filter and action inside AY Inbox | An [automation](/guides/automations/) on **Reply received**, filtered on `ai_labels`                                                      |
| A live feed for a running agent     | The [realtime WebSocket](/api/realtime/): `EMAIL_REPLIED` events to a key with `REALTIME_SUBSCRIBE` and `READ_CAMPAIGNS`                  |

The WebSocket is the low-latency path and replays only a short buffer after a disconnect. Webhooks are stored and retried, so use them for anything that must not be missed.

## Frequently asked questions

### Which webhook event fires when a prospect replies to a cold email?

campaign.reply_received. It carries the contact's address, the subject, a snippet, the keyword intent and confidence, the thread id and, when inbox tagging judged the message, its labels and intent.

### Does campaign.reply_received fire for out-of-office replies?

Yes. Automated replies arrive too, with intent set to out_of_office or automated. Filter them out in your receiver if you only want people.

### How do I tell an interested reply from a polite no?

Prefer the inbox tagging verdict: ai_labels containing Interested or Meeting. Without tagging, intent is positive when the workspace's positive keywords outweigh the others, which is a coarser reading.

### Can the same reply be delivered to my webhook twice?

Yes. A retry can arrive after your endpoint already handled the first attempt. Dedupe on the X-Ayinbox-Event-Id header, which is the same for every retry of one event.

### Do I need a webhook to get replies into Slack?

No. The Slack integration posts Reply received notifications to a channel you choose, and its inbox channel can post every human reply as a thread. A webhook is for routing into your own code or a CRM.
