Webhooks are how your systems find out that something happened without asking repeatedly. Every event that matters — a qualification, a reply, a completed dossier, a halted campaign — is delivered as a signed POST to an endpoint you control.
How delivery works#
- Method
- POST, application/json
- Signature header
- Arvyn-Signature
- Timestamp header
- Arvyn-Timestamp (Unix seconds)
- Event id header
- Arvyn-Event-Id
- Timeout
- 5 seconds
- Success
- any 2xx
Respond 2xx as soon as you have durably accepted the payload, then do the work. An endpoint that finishes its processing before responding will eventually exceed the timeout, get retried, and process the same event twice.
Registering an endpoint#
In Settings → API → Webhooks, or over the API with an admin-scoped token. Each endpoint subscribes to a list of event types and gets its own signing secret.
curl -X POST https://api.arvyn.io/v1/webhooks \
-H "Authorization: Bearer $ARVYN_ADMIN_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://hooks.yourdomain.com/arvyn",
"events": ["account.qualified", "reply.classified", "campaign.halted"],
"description": "Revenue ops pipeline"
}'- HTTPS only. Plain HTTP endpoints are rejected at registration.
- Subscribe narrowly. Subscribing to everything and filtering on your side works, and it also means a spike in one event type consumes your endpoint’s capacity for the rest.
- The secret is shown once. Rotating it issues a new secret and keeps the old one valid for 24 hours, so you can deploy without dropping deliveries.
Event types#
| Event | Fires when | Typical use |
|---|---|---|
| account.qualified | A decision resolves to qualified | Create or update the CRM record before outreach starts |
| account.tier_changed | An account moves between tiers | Reprioritise a work queue |
| account.held | A decision resolves to held with an unmet condition | Log the condition; it is the reason to revisit |
| account.rejected | A decision resolves to rejected | Close out any open sequence in your own tooling |
| window.closed | A buying window closes, with the reason | Stop your own follow-up cadence |
| message.sent | A message leaves the mailbox | Activity logging |
| reply.received | A reply is detected | Alerting |
| reply.classified | A reply is classified | The one to build on — classification is the instruction |
| campaign.halted | A stop condition fired | Page somebody; a halt usually means a data problem |
| dossier.completed | A requested dossier is ready | Attach it to the meeting or the CRM record |
| mailbox.unhealthy | Authentication, DNS or bounce-rate check failed | Alert — sending has already been affected |
Payload shape#
Every payload has the same envelope. data carries the resource in the same shape the REST API returns it, so one deserialiser handles both.
{
"id": "evt_01J9F3M8N2",
"type": "reply.classified",
"created_at": "2026-08-06T14:41:02Z",
"workspace_id": "ws_4021",
"data": {
"reply_id": "REP-8841",
"account_id": "ACC-19022",
"campaign_id": "CMP-204",
"message_id": "MSG-90144",
"classification": "not_now",
"revisit_at": "2026-10-01",
"confidence": 91
},
"previous": null
}On change events — account.tier_changed, window.closed — previous carries the prior values, so you can act on the transition rather than re-deriving it from your own last-known state.
Verifying signatures#
Every request carries an HMAC-SHA256 of the timestamp and the raw body, keyed with the endpoint secret. Verify it before you parse anything.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(rawBody: string, headers: Headers, secret: string) {
const timestamp = headers.get("Arvyn-Timestamp");
const signature = headers.get("Arvyn-Signature");
if (!timestamp || !signature) return false;
// Reject anything older than five minutes to stop replay.
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
if (age > 300) return false;
const expected = createHmac("sha256", secret)
.update(timestamp + "." + rawBody)
.digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(signature);
return a.length === b.length && timingSafeEqual(a, b);
}- Use the raw body. Sign-and-compare against the exact bytes received. Parsing to JSON and re-serialising changes whitespace and key order and the signature will not match.
- Compare in constant time.
timingSafeEqual, not===. - Check the timestamp. A valid signature on an old payload is a replay.
- Accept both secrets during rotation. Verify against the new secret first, then the old one, for the 24-hour overlap.
Retries and failures#
| Attempt | Delay after previous | Cumulative |
|---|---|---|
| 1 | — | immediate |
| 2 | 30 seconds | 30s |
| 3 | 5 minutes | ~6m |
| 4 | 30 minutes | ~36m |
| 5 | 2 hours | ~2h 36m |
| 6 | 6 hours | ~8h 36m |
| 7 | 24 hours | ~32h |
An endpoint that fails every delivery for 72 hours is disabled automatically and a mailbox.unhealthy-style notice appears in the console. Re-enabling it does not replay what was missed by default — use the replay control for that.
- Replay window
- 7 days of event history
- Replay granularity
- single event, or all failures for an endpoint
- Replayed events
- carry the original Arvyn-Event-Id
- Ordering
- not guaranteed — order by created_at, not by arrival
Next: integrations, if what you want is a CRM sync rather than an endpoint to maintain.