Webhooks
Subscribe to lead.created, opportunity.stage_changed, and form.submitted events, and verify delivery signatures.
The Public API can push events to your own HTTPS endpoint instead of you polling for them. Manage subscriptions with webhooks:read / webhooks:write scopes; see API Reference for full request/response schemas.
Events
| Event | Fired when |
|---|---|
lead.created | A new lead is created in CRM. |
opportunity.stage_changed | An opportunity moves to a different pipeline stage. |
form.submitted | Not yet available — reserved for an upcoming forms feature. Subscribing to it is accepted but no events will be delivered yet. |
Managing subscriptions
# Create a subscription
curl -X POST https://api.ac-co.ai/webhooks/subscriptions \
-H "Authorization: Bearer $ACCO_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/webhooks/acco", "events": ["lead.created", "opportunity.stage_changed"]}'The response includes a one-time secret (whsec_…) — store it now. It's never returned again; GET /webhooks/subscriptions only shows a display prefix.
{
"id": "5f2c…",
"url": "https://example.com/webhooks/acco",
"events": ["lead.created", "opportunity.stage_changed"],
"enabled": true,
"secretPrefix": "whsec_ab12cd",
"secret": "whsec_ab12cd34ef56…",
"createdBy": "…",
"createdAt": "2026-09-14T12:00:00.000Z",
"updatedAt": "2026-09-14T12:00:00.000Z"
}List, update (url / events / enabled), delete, and inspect recent deliveries for a subscription (GET /webhooks/subscriptions/:id/deliveries — status, HTTP response code, and last error, useful while debugging your receiver) round out the surface — see API Reference for exact shapes.
Delivery
Each event is POSTed to your url as JSON:
{
"event": "lead.created",
"data": { "lead_id": "…", "source": "…", "status": "new" },
"sentAt": "2026-09-14T12:00:05.000Z"
}with headers:
Content-Type: application/json
X-Acco-Event: lead.created
X-Acco-Signature: t=1757851200,v1=5257a869e7…Retries. A non-2xx response (or a timeout — we wait 10s) is retried with exponential backoff (30s, 60s, 120s, … capped at 6h) up to 8 attempts total, after which the delivery is marked exhausted and dropped. Make your endpoint idempotent on X-Acco-Event + the delivery's id (in the response body) — a retry can arrive after your first attempt already succeeded but the response was lost in transit.
Verifying signatures
X-Acco-Signature follows the same shape Stripe uses: t=<unix timestamp>,v1=<hex HMAC-SHA256>, computed over "${t}.${raw request body}" with your subscription's signing secret as the HMAC key. Always verify against the raw, unparsed request body — re-serializing parsed JSON can change byte-for-byte formatting and break the signature.
import { createHmac, timingSafeEqual } from "crypto";
function verifyAccoWebhook(
secret: string,
rawBody: string,
signatureHeader: string,
toleranceSeconds = 300,
): boolean {
const parts = new Map(
signatureHeader.split(",").map((kv) => kv.split("=", 2) as [string, string]),
);
const t = parts.get("t");
const v1 = parts.get("v1");
if (!t || !v1) return false;
// Reject stale signatures — guards against a replayed request.
if (Math.abs(Date.now() / 1000 - Number(t)) > toleranceSeconds) return false;
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`, "utf8").digest("hex");
const expectedBuf = Buffer.from(expected, "hex");
const gotBuf = Buffer.from(v1, "hex");
return expectedBuf.length === gotBuf.length && timingSafeEqual(expectedBuf, gotBuf);
}This is the exact algorithm used on our side (apps/services/public-api/lib/webhooks/signature.ts) — the two are unit-tested to stay in lockstep. If verification fails, double-check you're hashing the raw body your framework received, not a value re-stringified after JSON parsing.
Errors
insufficient_scope (403) for a missing webhooks:read / webhooks:write scope, and the usual 401 / 404 cases — see Errors.