Receive Predictive Signals by Webhook
Use Case
- Push the people in one Predictive Signals state (for example, everyone who entered market yesterday) to your own HTTPS endpoint, every day, as they appear
- Act on them in your own systems without polling an API or picking up files
- Verify that every request came from Delivr and was not altered
A webhook is a destination for a signal-state export: an export of an audience that selects one Predictive Signals state with signalState (see Predictive Signals). It delivers people only for such an export; an export of the whole audience cannot use it.
Prerequisites
- Organization API key + secret pair (Authentication), or access to the dashboard.
- A project id and an intents audience or persona id.
- An HTTPS endpoint that answers on a public address.
Steps
1. Connect the Endpoint
In the dashboard, open Connections, then Catalog, then Webhook, and enter the endpoint URL. Or create it with the API:
curl -X POST "https://api.delivr.ai/api/v1/integrations?project_id=YOUR_PROJECT_ID" \
-H "X-Api-Key: YOUR_API_KEY" \
-H "X-Api-Secret: YOUR_API_SECRET" \
-H "Content-Type: application/json" \
-d '{
"type": "webhook",
"name": "Ops endpoint",
"url": "https://hooks.example.com/delivr"
}'Delivr generates the signing secret. The create response is the only response that contains it, as signing_secret, so store it in your secret manager now:
{
"id": "6c1f0e2a-8d3b-4c5e-9f10-2a3b4c5d6e7f",
"type": "webhook",
"name": "Ops endpoint",
"config": { "settings": { "url": "https://hooks.example.com/delivr" } },
"has_credentials": true,
"enabled": true,
"signing_secret": "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw"
}If the secret is lost, regenerate it from the connection's page in the dashboard. The old secret stops verifying at once and the new one is shown once.
The endpoint must be https:// with a public address. Delivr refuses private, loopback, link-local and cloud metadata addresses when it connects, after resolving the hostname, does not follow redirects, and refuses a URL with a username or password in it.
2. Test the Connection
Test connection on the connection's page sends one signed event to the endpoint:
{ "type": "webhook.test", "sent_at": "2026-10-24T14:30:00Z" }It passes when the endpoint answers with a 2xx status. Use it to check your signature verification before the first delivery.
3. Create the Signal-State Export
Create an export of the audience with a signalState and a webhook destination. include_email: true adds each person's email where the audience has one; without it people are identified by hem (the SHA-256 of the lowercase email) alone.
curl -X POST "https://api.delivr.ai/api/v1/exports" \
-H "X-Api-Key: YOUR_API_KEY" \
-H "X-Api-Secret: YOUR_API_SECRET" \
-H "Content-Type: application/json" \
-d '{
"project_id": "YOUR_PROJECT_ID",
"name": "Entered market push",
"query": {
"sourceType": "audience",
"sourceId": "YOUR_AUDIENCE_ID",
"signalState": "entered_market"
},
"destination": {
"type": "webhook",
"integration_id": "6c1f0e2a-8d3b-4c5e-9f10-2a3b4c5d6e7f",
"include_email": true
},
"schedule": "daily",
"run_time": "14:30",
"run_timezone": "UTC"
}'Each run delivers the previous UTC day's list. A run before that day is published waits for it, and a run whose day and state were already delivered is skipped.
What Each Request Carries
A run sends the day's list as POST requests with Content-Type: application/json, in batches of at most 100 people and 256 KB, sorted by hem:
{
"type": "signals.members",
"audience_id": "15418",
"audience_name": "Ops leaders",
"state": "entered_market",
"day": "2026-10-23",
"total": 230,
"batch": { "index": 0, "count": 3 },
"people": [
{
"hem": "3f1b6e0c9a8d7e5f4a3b2c1d0e9f8a7b6c5d4e3f2a1b0c9d8e7f6a5b4c3d2e1f",
"email": "[email protected]",
"company_domain": "acme.example",
"intent_trend": "rising",
"active_days_14d": 6,
"topic_ids": ["4012", "4413"]
}
]
}| Field | Description |
|---|---|
type | signals.members for a list batch, webhook.test for a test event. Route on it if one endpoint receives several kinds. |
audience_id, audience_name | The exported audience. audience_name is omitted if it could not be read. |
state, day | The Predictive Signals state and the signals day (UTC) the list is for. |
total | People in the whole delivery, across every batch. |
batch.index, batch.count | This batch's position (from 0) and the number of batches in the delivery. |
people[].hem | SHA-256 of the person's lowercase email. Always present. |
people[].email | Only with include_email: true, and only where the audience has an email for the person. |
people[].company_domain, intent_trend, active_days_14d | Present when known. |
people[].topic_ids | The topics the person is active in; may be empty. |
Every request carries three Standard Webhooks headers:
| Header | Value |
|---|---|
webhook-id | The message id, <run_id>:<destination_id>:<batch index>:<content hash> for a list batch. A retry that sends the same people sends the same id; a batch whose people changed gets a new one. |
webhook-timestamp | Unix seconds when this attempt was sent. Each attempt is signed with its own timestamp. |
webhook-signature | v1, followed by the base64 HMAC-SHA256 signature. Several space-separated entries may appear; accept the request if any v1 entry matches. |
Verify the Signature
- Take the
webhook-id,webhook-timestampand the raw request body, before any JSON parsing. - Build the signed content
"{webhook-id}.{webhook-timestamp}.{body}". - The key is the base64-decoded part of the secret after
whsec_, not the secret string itself. - Compute HMAC-SHA256 over the signed content, base64-encode it, and compare it in constant time with each
v1,entry ofwebhook-signature. - Refuse a
webhook-timestampmore than five minutes from your clock, so a captured request cannot be replayed later.
Python:
import base64, hashlib, hmac, time
def verify(secret, headers, body, tolerance=300):
msg_id = headers["webhook-id"]
ts = headers["webhook-timestamp"]
if abs(time.time() - int(ts)) > tolerance:
return False
key = base64.b64decode(secret.removeprefix("whsec_"))
signed = f"{msg_id}.{ts}.".encode() + body
expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
for entry in headers["webhook-signature"].split():
version, _, value = entry.partition(",")
if version == "v1" and hmac.compare_digest(value, expected):
return True
return FalseNode.js:
import crypto from "node:crypto";
export function verify(secret, headers, rawBody, toleranceSec = 300) {
const id = headers["webhook-id"];
const ts = headers["webhook-timestamp"];
if (Math.abs(Date.now() / 1000 - Number(ts)) > toleranceSec) return false;
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const expected = crypto.createHmac("sha256", key).update(`${id}.${ts}.`).update(rawBody).digest("base64");
return headers["webhook-signature"].split(" ").some((entry) => {
const [version, value] = entry.split(",");
return version === "v1" && value.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(value), Buffer.from(expected));
});
}The Standard Webhooks libraries (standardwebhooks on npm and PyPI, and their Go, Ruby, Java and PHP equivalents) verify these requests as well, given the full whsec_ secret.
To check an implementation, this is the reference vector the Standard Webhooks libraries test against. With the secret whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw, the id msg_p5jXN8AQM9LWM0D4loKWxJek, the timestamp 1614265330 and the body {"test": 2432232314}, the signature is:
v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE=Respond, Retries, and Duplicates
- Acknowledge with a
2xxwithin 15 seconds. Do slow work after responding. 408,429,5xx, timeouts and connection failures are retried after 1 second, 5 seconds, 30 seconds, 2 minutes and 10 minutes. ARetry-Afterheader (seconds or an HTTP date, up to 10 minutes) replaces the wait.- Any other status refuses the batch. That includes every other
4xxand every3xx, since redirects are not followed. A refused batch fails the run, and nothing after it is sent. - A batch never accepted fails the run, and the run is retried later. A retried run sends every batch again. A batch with the same people keeps its
webhook-id; if the list changed in between, the changed batches arrive under new ids, so deduplicating on the id never drops a person. - Deduplicate on
webhook-id. The same batch can arrive more than once; treat a second arrival of an id you already processed as a success and answer2xx.
Billing
A webhook delivery is billed like every signal-state export: at the Predictive Signals rate, one unit per person in the batches your endpoint acknowledged with a 2xx, on a run that completes.
Updated about 3 hours ago
