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.