Aller au contenu

Webhooks

Subscribe an HTTPS endpoint to receive signed, real-time events (lead created, quote generated, job completed, …). Requires the webhooks:manage scope.

Subscribe

curl -X POST https://dev.aeliam.ai/api/v1/public/webhooks \
  -H "X-API-Key: aelm_dev_xxxxxxxx" -H "Content-Type: application/json" \
  -d '{
    "url": "https://your-app.example.com/hooks/aeliam",
    "events": ["opportunity.created", "job.completed"],
    "description": "Prod listener"
  }'

Use "events": ["*"] to subscribe to every event. The response returns the signing secret once — store it now:

{
  "id": "…",
  "url": "https://your-app.example.com/hooks/aeliam",
  "events": ["opportunity.created", "job.completed"],
  "active": true,
  "secret": "whsec_…",
  "_warning": "Store this secret now — it will not be shown again."
}

Manage subscriptions with GET/PATCH/DELETE /webhooks/{id}, rotate the secret with POST /webhooks/{id}/rotate-secret (zero-downtime: both secrets sign during the overlap window), send a test with POST /webhooks/{id}/test, and inspect history with GET /webhooks/{id}/deliveries.

Delivery format

Each delivery is a POST with a common JSON envelope:

{
  "id": "<event-uuid>",
  "type": "opportunity.created",
  "version": "v1",
  "created_at": "2026-06-30T14:30:00.000Z",
  "cabinet_id": "<cabinet-uuid>",
  "data": { /* event-specific payload */ }
}

HTTP headers sent with every delivery:

Header Value
User-Agent Aeliam-Webhook/1.0
Aeliam-Timestamp Unix timestamp (seconds)
Aeliam-Signature t=<ts>,v1=<hmac_sha256> (multiple ,v1= during secret rotation)
Aeliam-Event-Id Event UUID
Aeliam-Event-Type e.g. opportunity.created
Aeliam-Delivery-Id Delivery UUID (for replay)

Verify the signature

The signature is HMAC-SHA256(secret, "<timestamp>.<raw_body>"), where <raw_body> is the exact bytes you received and <timestamp> is the t= value in Aeliam-Signature.

Sign the raw body

Compute the HMAC over the raw request body as received, before any JSON re-serialisation. Re-encoding can change byte order/spacing and break the comparison.

import crypto from 'node:crypto';

function verify(rawBody, header, secret) {
  const parts = Object.fromEntries(header.split(',').map(p => p.split('=')));
  const t = parts.t;
  if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false; // anti-replay
  const expected = crypto.createHmac('sha256', secret)
    .update(`${t}.${rawBody}`).digest('hex');
  // header may contain several v1= values (rotation) — accept if any matches
  const macs = header.split(',').filter(p => p.startsWith('v1=')).map(p => p.slice(3));
  return macs.some(m =>
    m.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(m), Buffer.from(expected)));
}
import hmac, hashlib, time

def verify(raw_body: bytes, header: str, secret: str) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    ts = parts["t"]
    if abs(time.time() - int(ts)) > 300:          # anti-replay
        return False
    signed = f"{ts}.".encode() + raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    macs = [v[3:] for v in header.split(",") if v.startswith("v1=")]
    return any(hmac.compare_digest(m, expected) for m in macs)

Always reject deliveries whose timestamp is more than 300 s from now, and compare with a constant-time function (timingSafeEqual / hmac.compare_digest).

Event catalogue

All events are version: "v1". Subscribe to any of these (or ["*"]):

Event Listen scope* Fires when
opportunity.created crm:read An opportunity is created
opportunity.stage_changed crm:read An opportunity's stage changes
opportunity.won crm:read Stage reaches won (in addition to stage_changed)
opportunity.lost crm:read Stage reaches lost
devis.generated devis:read A quote is successfully priced
devis.failed devis:read A quote pricing failed
devis.signed devis:read A quote is signed
job.completed automation:read A tarification job completes
job.failed automation:read A tarification job fails
job.mfa_required automation:read A job is paused awaiting MFA
contract.signed crm:read A contract is signed
webhook.test Manual test delivery (not subscribable via *)

* informational — the scope conceptually associated with the event.

Example payload — opportunity.created

{
  "id": "…", "type": "opportunity.created", "version": "v1",
  "created_at": "2026-06-30T14:30:00Z", "cabinet_id": "…",
  "data": {
    "opportunity_id": "…",
    "title": "Lead - MRP",
    "stage": "lead",
    "expected_value_eur": 800,
    "source": "api",
    "contact": { "last_name": "Martin", "company_name": "Acme", "siret": "00000000000000" },
    "tags": ["mrp"],
    "created_via": "api_public"
  }
}

If your cabinet hasn't enabled third-party LLM sharing, PII fields in payloads are masked.

Delivery guarantees

Guarantee Detail
At-least-once Be idempotent: dedupe on the envelope id.
No ordering Events may arrive out of order — use created_at.
Retries 6 attempts with exponential backoff (0s, 30s, 5m, 30m, 2h, 12h).
Replay Deliveries retained ~90 days; replay from the console or API.
Latency Target < 5 s; can rise under load.

Respond 2xx quickly (ack and process asynchronously). Non-2xx triggers the retry schedule.