Recipes

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.

RuleDetail
URLHTTPS and publicly routable. Private, loopback and link-local addresses are refused
VerificationA new endpoint gets one signed webhook.test request and no real events until it answers 2xx
Event filtercampaign.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:

  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

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

FieldAlways presentWhat it holds
contact_emailYesWho replied
intentYesKeyword reading: positive, negative, question, neutral, out_of_office or automated
confidenceYesHow sure that reading is, 0 to 1
subject, snippetYesThe reply's subject and a short excerpt
action_takenYesWhat AY Inbox did, comma-separated: paused_campaign, suppressed_recipient or created_crm_task; empty when nothing
thread_id, email_account_idYesThe conversation and the mailbox it arrived in
contact_id, campaign_idWhen linkedThe contact and campaign the reply belongs to. A reply AY Inbox cannot tie to a campaign arrives without campaign_id
ai_labelsWhen taggedLabels such as Interested, Meeting, Pricing, Question, Not interested
ai_intent, ai_intent_confidenceWhen taggedWhat 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_kindWhen taggedhuman_reply, auto_reply_ooo, auto_reply_ticket and others. Empty when unsure
ai_relevance, ai_needs_reviewWhen taggedA 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 "", 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

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.

Alternatives

You wantUse
A ping in a Slack channel, no codeThe Slack integration: route the Reply received notification category to a channel. Each post has Draft a reply
Every human reply worked in SlackThe Slack inbox channel, which posts human replies as one thread per conversation
A filter and action inside AY InboxAn automation on Reply received, filtered on ai_labels
A live feed for a running agentThe 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.

On this page