Skip to content

Webhooks

Developers

Webhooks

Signed HTTP callbacks for qualifications, replies, meetings and dossiers, with retries and replay.

/docs/webhooks18 / 20

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.

Register an endpoint
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#

EventFires whenTypical use
account.qualifiedA decision resolves to qualifiedCreate or update the CRM record before outreach starts
account.tier_changedAn account moves between tiersReprioritise a work queue
account.heldA decision resolves to held with an unmet conditionLog the condition; it is the reason to revisit
account.rejectedA decision resolves to rejectedClose out any open sequence in your own tooling
window.closedA buying window closes, with the reasonStop your own follow-up cadence
message.sentA message leaves the mailboxActivity logging
reply.receivedA reply is detectedAlerting
reply.classifiedA reply is classifiedThe one to build on — classification is the instruction
campaign.haltedA stop condition firedPage somebody; a halt usually means a data problem
dossier.completedA requested dossier is readyAttach it to the meeting or the CRM record
mailbox.unhealthyAuthentication, DNS or bounce-rate check failedAlert — 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.

reply.classified
{
  "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.

Verification
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#

AttemptDelay after previousCumulative
1—immediate
230 seconds30s
35 minutes~6m
430 minutes~36m
52 hours~2h 36m
66 hours~8h 36m
724 hours~32h
Seven attempts over roughly 32 hours, then the event is marked failed.

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.