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.
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 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.
1. Create the endpoint
POST /webhooks needs the WEBHOOKS scope (for a member, Manage settings).
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.
2. Verify the signature
Every delivery carries X-Ayinbox-Signature: t=<unix>,v1=<hex>. To check it:
- Parse
tandv1from the header. Ignore any scheme other thanv1. - Reject the request if
tis more than 5 minutes from your clock. - Compute
HMAC-SHA256(secret, t + "." + raw_body)over the raw request body, before any JSON parsing, and hex-encode it. - Compare it with
v1in constant time (hmac.compare_digestin Python).
Compute over the raw bytes. Parsing and re-serializing the JSON changes the bytes and the check fails.
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
This receiver verifies the signature, drops duplicates and automated mail, and forwards replies that read as interested.
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 "", 200notify 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
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 payloadid) 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_receivedlists attempts with status, response code and a snippet of your response body. - Recover abandoned events.
POST /webhooks/deliveries/:deliveryId/redeliversends the original payload again with the same event id.
The full endpoint and payload reference is in the webhooks API reference.
Alternatives
| You want | Use |
|---|---|
| A ping in a Slack channel, no code | The Slack integration: 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 on Reply received, filtered on ai_labels |
| A live feed for a running agent | The realtime WebSocket: 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.
Recipe: launch a campaign from a CSV
Import a lead list, create a campaign with its steps and senders, run preflight and start it over the AY Inbox API or CLI, with the launch list check explained.
Recipe: review and approve AI reply drafts
List the AI reply drafts waiting in the inbox, read what each one used and flagged, then approve, edit or discard them from your own app with the unibox API.