Outbound webhooks
Overview
This page documents the wire protocol (what hits your server and how to respond). Use the management APIs to register URLs and signing secrets:
Partner API
Manage webhooks via the Partner API.
Distributor API
Manage webhooks via the Distributor API.
When an event fires for a partner organization, the gateway resolves every enabled webhook_endpoints row whose event_types overlaps the event, for both that organization and (when configured) the parent distributor. Partner-owned URLs and secrets are managed under the Partner API; distributor-owned URLs and their signing secrets are managed under the Distributor API above. Each match becomes a queued delivery processed by the runner.
Delivery HTTP contract
Each attempt is a single POST to your registered URL.
Request headers
| Header | Meaning |
|---|---|
Content-Type | Always application/json. |
X-Signature | Hex-encoded HMAC-SHA256 of the raw JSON body bytes, using your current active signing secret token (UUID string from secret generation). |
X-Token | A random UUID generated for this request. You must incorporate it when computing your response signature (see below). |
Request body (JSON)
The body is a stable envelope:
{
"idempotencyToken": "8f2c1b0e-…",
"type": "customer.breach.found",
"data": {
"partnerId": "22222222-2222-4222-8222-222222222222",
"customer": {
"uuid": "550e8400-e29b-41d4-a716-446655440000",
"name": "Acme Ltd",
"reference": "ACME-001"
},
"breaches": [
{
"uuid": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"name": "LinkedIn-2024",
"emails": ["user@example.com"]
}
]
},
"timestamp": 1715601234567
}| Field | Description |
|---|---|
idempotencyToken | Stable id for this logical event instance (used for deduplication if you see retries). |
type | Canonical event name (see Event types). |
data | Event payload (see per-type shapes below). Every event includes partnerId (the partner organization UUID) and customer; asset lifecycle events also include assets; breach events include breaches instead. |
timestamp | Milliseconds since Unix epoch for when data. |
Timeouts and response requirements
- The runner uses a 3 second request timeout per HTTP call.
- The HTTP status must be 2xx.
- The response must include header
X-Signaturewith hex-encoded HMAC-SHA256 over the UTF-8 string formed by: theX-Tokenheader value, a literal colon (:), then the raw request body string (identical bytes to the body you verified on ingress). The same signing secret token is used for the requestX-Signatureand for computing the expected responseX-Signature.
Retries
Each registered endpoint has a maxAttempts value (from 1 to 3, set when you create or update the endpoint).
- a timeout (no complete HTTP response within 3 seconds);
- a non-
2xxHTTP status from your server; - an invalid or missing response
X-Signature(the header does not match the value expected for the request body andX-Token); - a transport-level failure before a response is received (for example a network error).
When attempts are exhausted without a successful delivery, the event is treated as failed (subject to your monitoring and support processes).
Fallback signing secret — If you have both an active and a fallback signing secret (for example immediately after cycling secrets), and an attempt fails only because the response X-Signature did not verify against the active secret, 2xx responses, or invalid request X-Signature headers.
Signing secret rotation
Secrets are stored per organization UUID or distributor UUID. At most one active and one fallback secret may exist. After you cycle (Partner API) or cycle distributor secrets, the previous active becomes fallback so in-flight deliveries can still complete while you deploy the new active token.
Event types
Canonical type strings (also used in event_types when you create or update an endpoint):
type | When it fires |
|---|---|
customer.created | A new customer record exists for the org. |
customer.deleted | A customer was removed / offboarded. |
customer.asset.added | One or more domains or emails were added to monitoring. |
customer.asset.removed | Assets were removed. |
customer.breach.found | New breach rows were detected for the customer. |
customer.breach.firstscan.completed | A customer's first breach scan finished. |
customer.breach.resolved | Breach(es) transitioned to resolved. |
customer.breach.unresolved | Breach(es) transitioned back to unresolved. |
Payload shapes (data)
Every event includes partnerId and customer; asset lifecycle events also include assets; breach events include breaches instead.
partnerId is the UUID of the partner organization the customer belongs to — the same value you pass as SW-PARTNER-ID when calling the Partner API. Distributor-scoped webhook receivers use this field to tell which partner a customer belongs to when events are forwarded from multiple organizations.
CustomerAsset objects (uuid, type, value) appear only on customer.asset.added and customer.asset.removed. Breach events do not include an assets array — they identify which monitored email addresses were affected via breaches[].emails.
Shared types
/** Partner organization UUID on every webhook payload */
type PartnerId = string;
/** Customer record on the webhook payload */
type Customer = {
uuid: string;
name: string;
reference: string;
};
/** Monitored domain or email address (asset lifecycle events only) */
type CustomerAsset = {
uuid: string;
/** Determines how to interpret `value` */
type: 'domain' | 'email';
value: string;
};
/** Breach row summarized for the customer */
type BreachSummary = {
/** Customer breach instance UUID */
uuid: string;
/** Breach catalogue name (for example `LinkedIn-2024`) */
name: string;
/**
* Monitored email address where this breach instance was found for this customer.
* Plain email strings — not `CustomerAsset` objects. Each instance typically has
* one email (one row per customer email × breach). Domain-only assets are not
* listed here; if a domain hunt discovers an email and that email is breached,
* the email address appears in this array.
*/
emails: string[];
};
/** Redacted breach entry for prospect customers */
type ProspectBreachSummary = {
/** Unique identifier for this breach instance */
uuid: string;
/**
* Monitored email address where this breach was found for the customer.
*/
emails: string[];
/** Whether this breach instance is marked resolved */
resolved: boolean;
};customer.created and customer.deleted
type Data = {
partnerId: PartnerId;
customer: Customer;
};customer.asset.added and customer.asset.removed
type Data = {
partnerId: PartnerId;
customer: Customer;
/** Always at least one entry */
assets: CustomerAsset[];
};customer.breach.firstscan.completed
Fires once when a customer's first breach scan completes successfully (active and prospect customers). Subsequent scans do not emit this event.
type Data = {
partnerId: PartnerId;
customer: Customer;
/**
* ISO 8601 timestamp when the platform recorded the scan as complete.
* Matches the customer's `last_breach_scan` value in the platform database.
* May differ from the top-level envelope `timestamp` (delivery record time).
*/
scanCompletedAt: string;
};customer.breach.found, customer.breach.resolved, and customer.breach.unresolved
Active customers:
type Data = {
partnerId: PartnerId;
customer: Customer;
/** Always at least one entry. No `assets` field on breach events. */
breaches: BreachSummary[];
};Prospect customers (customer.breach.found only):
type Data = {
partnerId: PartnerId;
customer: Customer;
breachStatus: boolean;
count: number;
/** Redacted breach items — always at least one entry */
breaches: ProspectBreachSummary[];
};For prospect customers, breach items use the same uuid field as active customers but omit breach names and titles. The format matches the Get customer breaches API response for prospect customers.
For customer.breach.found, each entry in breaches is a newly detected breach for the customer. When several exposures are found during the same scan,
The uuid on each webhook breach entry is accepted as breachId in Update breach resolved status. For active customers, the same value appears as id in List customer breaches. Prospect customers use uuid in both API and webhook responses. It is not the global breach catalogue UUID from Get breach (use name for catalogue lookups on active customers).
To correlate an affected email with a monitored asset UUID or domain, call Get customer monitored assets and match on type: 'email' and value. Breach payloads intentionally carry email strings only, not full asset records.
To mark a breach resolved or unresolved from your integration, call Update breach resolved status with the breach uuid from the webhook payload (as breachId), the customer uuid (as customerId), and resolved: true or false. customer.breach.resolved or customer.breach.unresolved when the flag changes.
For customers with status: "prospect", customer.breach.found events include breachStatus, count, and a redacted breaches array. Each entry contains uuid, emails, and resolved. Resolved and unresolved events are not sent for prospect customers. See Prospecting for details and an example payload.
Example customer.breach.found (prospect):
{
"idempotencyToken": "8f2c1b0e-…",
"type": "customer.breach.found",
"data": {
"partnerId": "22222222-2222-4222-8222-222222222222",
"customer": {
"uuid": "550e8400-e29b-41d4-a716-446655440000",
"name": "Acme Ltd",
"reference": "ACME-001"
},
"breachStatus": true,
"count": 1,
"breaches": [
{
"uuid": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"emails": ["user@example.com"],
"resolved": false
}
]
},
"timestamp": 1715601234567
}idempotencyToken.
End-to-end checklist
- Create a signing secret — Without an active secret token, deliveries are rejected before HTTP (events are marked failed). Use Generate signing secret (Partner API) or Generate distributor signing secret depending on scope.
- Register your HTTPS URL — Create webhook endpoint with
url,eventTypes, optionalenabledandmaxAttempts(default retry budget per delivery). - Implement the POST handler — Verify
X-Signature, parse JSON, process asynchronously, return200with a correctX-Signatureresponse header as above. - Rotate safely — Call the cycle endpoint, deploy the new active secret, then deactivate old material when traffic has drained.
Example local receiver
X-Signature, then return 2xx with a response X-Signature built from X-Token, a colon, and that same raw body string.
The script below mirrors what our delivery runner expects. Install Express (npm install express), save the file as local-webhook-receiver.mjs, set WEBHOOK_SECRET_TOKEN to the token returned once from the signing secret API, and run it with a current Node.js:
npm install express
WEBHOOK_SECRET_TOKEN=<token-uuid> node local-webhook-receiver.mjs
1. **Register an endpoint** — `POST /api/v1/integrations/webhooks` with your URL and the events you want
2. **Store your secret** — the response includes an HMAC signing secret (only shown once)
3. **Handle deliveries** — accept POST requests at your URL and return a `2xx` status
4. **Verify signatures** — validate the `X-SafeWeb-Signature` header to confirm authenticity
```bash
curl -X POST https://connect.safestate.com/api/v1/integrations/webhooks \
-H "Content-Type: application/json" \
-H "SW-PARTNER-ID: your-partner-id" \
-H "SW-API-KEY: your-api-key" \
-d '{
"url": "https://yourapp.com/webhooks/safeweb",
"events": ["breach.new", "breach.resolved", "breach.unresolved", "email.added", "email.removed"]
}'Expose it to the internet (for example with ngrok or Cloudflare Tunnel) and register that public URL on a staging webhook endpoint.
/**
* Minimal local webhook receiver for SafeWeb deliveries (Express).
*
* Prerequisites: npm install express
*
* Usage:
* WEBHOOK_SECRET_TOKEN=<uuid-from-signing-secret-api> node local-webhook-receiver.mjs
* Optional: PORT=8765
*/
import { createHmac } from 'node:crypto';
import express from 'express';
const token = process.env.WEBHOOK_SECRET_TOKEN;
if (!token) {
console.error('Missing WEBHOOK_SECRET_TOKEN (signing secret token UUID).');
process.exit(1);
}
const port = Number(process.env.PORT ?? 8765);
const app = express();
app.get('/', (_req, res) => res.status(204).end());
app.head('/', (_req, res) => res.status(204).end());
app.post('/', express.raw({ type: 'application/json' }), (req, res) => {
const rawBody = req.body instanceof Buffer ? req.body.toString('utf8') : '';
const requestSig = req.get('x-signature');
const responseToken = req.get('x-token');
if (!requestSig || !responseToken) {
res.status(400).send('missing X-Signature or X-Token');
return;
}
const expectedRequestSig = createHmac('sha256', token)
.update(rawBody)
.digest('hex');
if (requestSig !== expectedRequestSig) {
res.status(401).send('invalid X-Signature');
return;
}
try {
console.dir(JSON.parse(rawBody), { depth: null });
} catch {
console.log(rawBody);
}
const responseSig = createHmac('sha256', token)
.update(`${responseToken}:${rawBody}`)
.digest('hex');
res.status(200).setHeader('X-Signature', responseSig).json({ ok: true });
});
app.listen(port, '127.0.0.1', () => {
console.error(`listening on http://127.0.0.1:${port}`);
});API reference
- Partner API — List endpoints, create, get, update, delete, failed deliveries, and signing secrets.
- Distributor — Webhook endpoints (list, create, get, update, delete) and signing secret lifecycle (generate, cycle, deactivate).
Retries
If your endpoint returns a non-2xx status code or the request times out (2 seconds),
Managing Endpoints
All management endpoints use the standard SW-PARTNER-ID and SW-API-KEY authentication headers.
Create an endpoint
POST /api/v1/integrations/webhooks| Field | Type | Required | Description |
|---|---|---|---|
url | string | Yes | Your HTTPS endpoint URL |
events | string[] | Yes | Event types to subscribe to |
active | boolean | No | Defaults to true |
Returns the endpoint including the secret. Store it securely — it is not returned on subsequent requests.
List endpoints
GET /api/v1/integrations/webhooksReturns all webhook endpoints for your organization.
Update an endpoint
PATCH /api/v1/integrations/webhooks/{id}Provide any combination of url, events, or active to update. At least one field is required.
Delete an endpoint
DELETE /api/v1/integrations/webhooks/{id}Permanently removes the endpoint and all associated delivery logs.
Best Practices
- Respond quickly — return a
2xxwithin a few seconds and process the payload asynchronously. Deliveries time out after 2 seconds. - Use HTTPS — webhook URLs must be publicly reachable HTTPS endpoints.
- Verify every delivery — always validate the
X-SafeWeb-Signatureheader before trusting the payload. - Handle duplicates — in rare cases the same event may be delivered more than once. Use the
created_atand payload fields to deduplicate. - Monitor failures — if your endpoint is consistently failing, disable it and investigate before re-enabling.