Webhooks
Get an HTTPS request from Signal when records change, and verify that it came from Signal.
Webhooks let Signal tell your systems when something happens, such as a new prospect contact, a won Deal or a Marketing Email click. When a subscribed event happens, Signal sends a POST request with a JSON body to a URL you choose.
Most events fire whether the change was made in Signal or through the REST API. The event catalog notes the exceptions.
Register an endpoint#
- In Signal, open Autopilot in the sidebar.
- Select the Webhooks & API tab. Webhooks is the first section.
- Select Create Webhook and choose the outbound direction ("Send CRM events to external tools").
- Enter a name and your endpoint URL, such as
https://example.com/signal/webhooks. - Pick the events to send. You can tick whole categories or single events. A sample payload is shown for the events you pick.
- Optionally add custom headers, or a bearer token that Signal sends as
Authorization: Bearer <token>. - Save. Copy the signing secret now: it's shown only once. You need it to verify signatures.
You need permission to manage integrations to create, edit, test or delete webhooks. People who can view integrations see webhooks read-only. Marketing events are offered only when your plan includes Marketing.
From the webhook's row you can:
- Test it: Signal sends a
webhook.testevent and shows the response code. - View deliveries: the most recent deliveries with their status, response code, response body (first 2,000 characters), error and duration.
- Edit it: change the URL, events, headers or token, turn it off, or rotate the signing secret.
- Delete it, along with its delivery history.
Your endpoint must be reachable from the public internet. Use HTTPS.
Event catalog#
| Event | Sent when |
|---|---|
prospect.created |
A prospect contact is created, including by POST /prospects when no existing one matches the email. |
prospect.updated |
A prospect contact is updated, including by PATCH /prospects/{id} and by POST /prospects when it matches an existing email. |
prospect.deleted |
A prospect contact is deleted (moved to Recently deleted). |
contact.created |
A Contact is created. |
contact.deleted |
A Contact is deleted (moved to Recently deleted). |
deal.created |
A Deal is created. |
deal.won |
A Deal is won. In Signal, that's when it moves to a won stage or a signed proposal marks it won. Through the API, it's when PATCH /deals/{id} raises probability to 100 or more. |
activity.created |
An activity is logged in Signal. (Activities created through the API don't send it.) |
development.created |
A Property development is created. |
development.updated |
A Property development is updated. |
plot.created |
A Property plot is created. |
plot.updated |
A Property plot is updated. |
plot.status_changed |
A plot's status changes. Sent alongside plot.updated, with previousStatus in data. |
marketing.email_opened |
A recipient opens a Marketing Email. |
marketing.email_clicked |
A recipient clicks a link in a Marketing Email. |
marketing.email_unsubscribed |
A recipient unsubscribes. |
marketing.email_bounced |
A Marketing Email bounces. |
marketing.email_complained |
A recipient marks a Marketing Email as spam. |
marketing.email_replied |
A recipient replies to a Marketing Email. |
marketing.campaign_email_sent |
A Campaign Email is sent. |
marketing.nurture_graduated |
Someone completes a nurture Sequence. |
marketing.feedback_received |
A recipient answers an Email's 0 to 10 feedback question. |
marketing.added_to_list |
Someone is added to one of your Lists. |
webhook.test |
You select Test on the webhook. |
Marketing events need a plan that includes Marketing.
The event picker also lists events that Signal doesn't send yet (for example account.created, deal.stage_changed, task.created and the deck.* events). Subscribing to them does no harm, but nothing is delivered for them today.
Payload#
Every delivery is a POST with a JSON body in this envelope:
| Field | Type | Notes |
|---|---|---|
eventId |
string | Unique ID for this event (UUID). Use it to ignore duplicates. |
eventType |
string | The event name, such as deal.created. |
workspaceId |
string | The workspace the event happened in. |
timestamp |
string | When Signal sent the event, ISO 8601 in UTC. |
entityType |
string | The kind of record, such as prospect, contact, deal, activity, development or plot. Marketing events name the person: a prospect contact or a Contact. |
entityId |
string | The record's ID. Use it with the REST API to fetch the record. |
data |
object | A snapshot of the record or event, in camelCase. |
data is a snapshot, and its fields depend on the event and on where the change was made (in Signal or through the API). Read the fields you need defensively, and fetch the record from the REST API when you need its full, current state.
Example deal.created (sent by POST /deals):
{
"eventId": "0c6f7b2e-9d41-4b8a-a3e5-1f2d4c6b8a90",
"eventType": "deal.created",
"workspaceId": "cm1a2b3c40000xy12z3a4b5c6",
"timestamp": "2026-11-12T09:30:01.214Z",
"entityType": "deal",
"entityId": "7c2e9f14-3a6b-4d1e-8f5a-9b0c2d4e6f81",
"data": {
"id": "7c2e9f14-3a6b-4d1e-8f5a-9b0c2d4e6f81",
"name": "Contoso renewal 2027",
"value": 48500,
"valuePeriod": "yearly",
"probability": 50,
"stageId": "rds_proposal",
"accountId": "3f6c2a1e-8b4d-4c2a-9f1e-2b7d5a9c4e10",
"ownerId": null,
"expectedCloseAt": "2026-12-15T00:00:00.000Z",
"createdAt": "2026-11-12T09:30:00.000Z"
}
}
Example prospect.created (sent by POST /prospects):
{
"eventId": "5a1e9c3d-7b2f-4e6a-8d0c-2b4f6a8c0e13",
"eventType": "prospect.created",
"workspaceId": "cm1a2b3c40000xy12z3a4b5c6",
"timestamp": "2026-11-12T09:30:01.502Z",
"entityType": "prospect",
"entityId": "e4b7c1d9-2f6a-4b3e-9c8d-5a1f7e2b4c63",
"data": {
"id": "e4b7c1d9-2f6a-4b3e-9c8d-5a1f7e2b4c63",
"firstName": "Sam",
"lastName": "Lee",
"email": "sam.lee@fabrikam.example",
"phone": null,
"jobTitle": "Operations Director",
"stageId": "rps_new",
"sourceId": "rls_website",
"ownerId": null,
"createdAt": "2026-11-12T09:30:00.000Z",
"updatedAt": "2026-11-12T09:30:00.000Z",
"customFields": { "budget_band": "50k-100k" }
}
}
Marketing events carry the person and Email in data: contactId, prospectId, accountId, ownerId, marketingEmailId, emailName, subject, campaignId, campaignName and occurredAt, plus fields for the event (for example linkUrl and linkIndex on marketing.email_clicked).
Headers#
| Header | Value |
|---|---|
Content-Type |
application/json |
User-Agent |
Empiraa-Webhooks/1.0 |
X-Empiraa-Event |
The event name, such as deal.created. |
X-Empiraa-Delivery |
Unique ID of this delivery. |
X-Empiraa-Timestamp |
When the request was signed, in Unix seconds. |
X-Empiraa-Signature |
sha256= followed by the hex HMAC signature. See below. |
Authorization |
Bearer <token>, if you set a bearer token on the webhook. |
Any custom headers you added to the webhook are sent too.
Verify signatures#
Check every request's signature before you trust it. Signal signs each delivery with your webhook's signing secret:
- Take the
X-Empiraa-Timestampheader and the raw request body, exactly as received. - Join them with a period:
<timestamp>.<raw body>. - Compute an HMAC-SHA256 of that string, using the signing secret as the key, and hex-encode it.
- Compare it with the value after
sha256=inX-Empiraa-Signature, using a constant-time comparison. - Reject requests whose timestamp is more than a few minutes old, to stop replays.
Parse the JSON only after the signature checks out. Re-serializing parsed JSON changes the bytes and breaks the signature.
Node.js example#
import crypto from "node:crypto";
import express from "express";
const app = express();
const SIGNING_SECRET = process.env.SIGNAL_WEBHOOK_SECRET; // the secret Signal showed you
const TOLERANCE_SECONDS = 300;
function verifySignalSignature(rawBody, timestamp, signatureHeader) {
if (!timestamp || !signatureHeader?.startsWith("sha256=")) return false;
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
if (!Number.isFinite(age) || age > TOLERANCE_SECONDS) return false;
const expected = crypto
.createHmac("sha256", SIGNING_SECRET)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
const received = signatureHeader.slice("sha256=".length);
const a = Buffer.from(expected, "hex");
const b = Buffer.from(received, "hex");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// Read the body as raw text so the signature can be checked against the exact bytes.
app.post("/signal/webhooks", express.text({ type: "application/json" }), (req, res) => {
const ok = verifySignalSignature(
req.body,
req.get("X-Empiraa-Timestamp"),
req.get("X-Empiraa-Signature"),
);
if (!ok) return res.status(401).send("Invalid signature");
const event = JSON.parse(req.body);
res.status(200).send("ok"); // acknowledge quickly
// Then process asynchronously, ignoring events you've already handled.
queueEvent(event); // your own function
});
app.listen(3000);
Rotate the signing secret#
Edit the webhook and rotate the secret. The new secret is shown once, and every delivery after that is signed with it, so update your endpoint's secret straight away. Deliveries signed with the old secret will fail your check until you do.
Delivery, timeouts and retries#
- Signal sends events shortly after the change, separately from the request or action that caused it.
- Your endpoint must answer within 10 seconds. Any
2xxstatus counts as delivered. Any other status, a timeout or a connection error counts as failed. - Every attempt is recorded in the webhook's delivery log, with the response code and the first 2,000 characters of the response body.
- Signal makes one delivery attempt per event. A failed delivery stays in the delivery log and isn't sent again automatically, so reconcile with the REST API (for example, list recently changed records) if your endpoint was down.
- If the same event fires for the same record several times within about 5 seconds, Signal may send it only once.
- Events can arrive out of order. Use
timestamp, or fetch the record, rather than relying on arrival order.
Best practices#
- Verify every signature and reject old timestamps.
- Respond fast. Return
2xxas soon as you've verified and stored the event, then do the work in the background. Slow endpoints hit the 10-second timeout. - Be idempotent. Store
eventId(or theX-Empiraa-Deliveryheader) and skip events you've already processed. - Treat
dataas a hint. For anything important, fetch the record from the REST API usingentityId. - Ignore unknown events and fields. New events and
datafields may be added at any time. - Use HTTPS, and keep the signing secret and any bearer token in your secrets manager.
- Watch the delivery log in Signal after a deployment to catch failures early.

