ac-co.ai docs

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

EventFired when
lead.createdA new lead is created in CRM.
opportunity.stage_changedAn opportunity moves to a different pipeline stage.
form.submittedNot 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.

On this page