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"]
    }
  ]
}
FieldDescription
typesignals.members for a list batch, webhook.test for a test event. Route on it if one endpoint receives several kinds.
audience_id, audience_nameThe exported audience. audience_name is omitted if it could not be read.
state, dayThe Predictive Signals state and the signals day (UTC) the list is for.
totalPeople in the whole delivery, across every batch.
batch.index, batch.countThis batch's position (from 0) and the number of batches in the delivery.
people[].hemSHA-256 of the person's lowercase email. Always present.
people[].emailOnly with include_email: true, and only where the audience has an email for the person.
people[].company_domain, intent_trend, active_days_14dPresent when known.
people[].topic_idsThe topics the person is active in; may be empty.

Every request carries three Standard Webhooks headers:

HeaderValue
webhook-idThe 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-timestampUnix seconds when this attempt was sent. Each attempt is signed with its own timestamp.
webhook-signaturev1, followed by the base64 HMAC-SHA256 signature. Several space-separated entries may appear; accept the request if any v1 entry matches.

Verify the Signature

  1. Take the webhook-id, webhook-timestamp and the raw request body, before any JSON parsing.
  2. Build the signed content "{webhook-id}.{webhook-timestamp}.{body}".
  3. The key is the base64-decoded part of the secret after whsec_, not the secret string itself.
  4. Compute HMAC-SHA256 over the signed content, base64-encode it, and compare it in constant time with each v1, entry of webhook-signature.
  5. Refuse a webhook-timestamp more 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 False

Node.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 2xx within 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. A Retry-After header (seconds or an HTTP date, up to 10 minutes) replaces the wait.
  • Any other status refuses the batch. That includes every other 4xx and every 3xx, 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 answer 2xx.

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.


Did this page help you?