Outbound webhooks
Atlas calls YOUR URL when something happens in your workspace — signed, retried, at-least-once. The reverse direction of the lead webhook.
Setup#
In Atlas → Settings → Integrations → Outbound Webhooks (workspace owner, up to 5 endpoints): add your public https:// URL, pick the events, and copy the signing secret (whsec_…) — it's shown once. Then hit Send test: Atlas posts a signed pingevent and shows you your server's response code on the spot.
Events#
| Field | Accepted keys | Notes |
|---|---|---|
| lead.created | data = the lead | Fires for EVERY entry path — in-app, lead webhook, REST API, Meta Lead Ads import. |
| lead.stage_changed | data = the lead (current state) | Fires on pipeline moves. A move to status "confirmed" is your booking-won signal today. |
| quote.accepted | data = { quote_id, lead } | Customer accepted a quotation; the lead is included so you can act on contact details immediately. |
| ping | data = { message } | Sent by the "Send test" button only. |
booking.confirmed and payment.received events. Until they ship, lead.stage_changed → status confirmed covers the booking-won moment — and this page will say so honestly until the real events exist.Delivery format#
POST with Content-Type: application/json and headers X-Atlas-Event, X-Atlas-Delivery (unique id — dedupe on it), X-Atlas-Timestamp (unix seconds) and X-Atlas-Signature. Lead objects use the exact same shape as the REST API.
{
"event": "lead.created",
"created_at": "2026-07-21T10:15:00.000Z",
"data": {
"id": "3f7c2b9e-…",
"name": "Rahul Sharma",
"phone": "+91 98765 43210",
"email": "rahul@example.com",
"destination": "Bali",
"budget": 150000,
"pax": 2,
"travel_date": "12 Aug 2026",
"status": "new",
"source": "website",
"score": 0,
"notes": "",
"follow_up_date": null,
"tags": [],
"created_at": "2026-07-21T10:14:59.000Z",
"updated_at": "2026-07-21T10:14:59.000Z"
}
}Verifying signatures#
The signature is v1=HMAC_SHA256(secret, `{timestamp}.{raw_body}`) in hex. Always verify against the raw request body — re-serialising the JSON can reorder keys and break the match.
import { createHmac, timingSafeEqual } from 'crypto'
// Express example — use the RAW body string, not re-serialized JSON.
app.post('/atlas-webhook', express.raw({ type: 'application/json' }), (req, res) => {
const secret = process.env.ATLAS_WEBHOOK_SECRET // whsec_… from Settings
const ts = req.header('X-Atlas-Timestamp')
const sig = (req.header('X-Atlas-Signature') || '').replace('v1=', '')
const expected = createHmac('sha256', secret)
.update(`${ts}.${req.body.toString()}`)
.digest('hex')
const valid = sig.length === expected.length &&
timingSafeEqual(Buffer.from(sig), Buffer.from(expected))
// Reject stale timestamps too (replay guard) — e.g. older than 5 minutes.
if (!valid || Math.abs(Date.now() / 1000 - Number(ts)) > 300) {
return res.status(401).end()
}
const body = JSON.parse(req.body.toString())
// …handle body.event / body.data. Respond fast; do slow work async.
res.status(200).end()
})Retries & guarantees#
— Any 2xx from you counts as delivered. Respond within 5 seconds — acknowledge fast, process async.
— Non-2xx or timeout → retries with backoff: ~2 min, 10 min, 30 min, 2 h, 6 h (5 attempts total), then the delivery is marked failed.
— Delivery is at-least-once: rare duplicates are possible — dedupe on X-Atlas-Delivery.
— Order is not guaranteed across events; use the payload's timestamps.
— Delivery history is retained for 30 days. The Settings card shows each endpoint's last success and last failure.
