Webhooks
Alert Webhook

alert.sent webhook

The alert.sent event is delivered whenever one of your Customerscore alerts matches at least one customer. It contains the alert's configuration, the list of matching customers (with their score/property changes), and batching metadata so you can react to alerts in your own systems — Slack, a CRM, a data warehouse, etc.

Heads-up on batching: a single alert run can match thousands of customers. Customerscore splits the matches into batches of up to 1000 customers and sends one alert.sent request per batch. Always read the batch object (below) to know whether more requests are coming for the same run.


When it's triggered

An alert.sent request is dispatched when all of the following are true:

  1. Your account has at least one active webhook subscribed to the alert.sent event.
  2. An alert is evaluated and one or more customers match its conditions. Alerts that match zero customers do not produce a webhook.

Alerts are evaluated in two situations:

TriggerDescription
AutomaticAfter every data sync / import finishes processing, all of your alerts are re-evaluated against the new snapshot. This is the normal, day-to-day trigger.
ManualWhen an alert is sent on demand from the app (the "Send" action on an alert).

For each evaluation that produces matches, customers are split into batches of 1000 and a webhook request is sent per batch.


Delivery

PropertyValue
HTTP methodPOST
Content-Typeapplication/json
User-AgentCustomerscore.io-Webhook/1.0
Signature headerX-customerscore-HMAC: sha256=<hex> (see Verifying the signature)
Timeout10 seconds
RetriesUp to 3 attempts, exponential backoff (first retry after ~5s)

Response handling:

  • 2xx — treated as a successful delivery; no retry.
  • 5xx — treated as a transient failure and retried (up to the retry limit).
  • 4xx — treated as a permanent failure; not retried. Make sure your endpoint returns a 2xx once it has accepted the payload.

Your endpoint should respond quickly (within the 10s timeout). If you need to do heavy processing, acknowledge with a 2xx immediately and process the payload asynchronously.


Payload schema

Every webhook is wrapped in a common envelope:

{
  "event": "alert.sent",
  "timestamp": "2025-12-31T23:59:59.999Z",
  "data": { ... }
}
FieldTypeDescription
eventstringAlways "alert.sent" for this event.
timestampstring (ISO-8601)When the webhook payload was generated (UTC).
dataobjectThe alert payload — see below.

data

FieldTypeNullableDescription
datestring (ISO date, YYYY-MM-DD)noThe snapshot date the alert was evaluated against (the latest snapshot).
previous_datestring (ISO date)yesThe previous snapshot date used to compute changes. null if there is no previous snapshot.
alertobjectnoThe alert configuration — see data.alert.
customersarraynoMatching customers in this batch — see data.customers.
batchobjectnoBatching metadata — see data.batch.

data.alert

FieldTypeNullableDescription
namestringnoThe alert's name (max 60 chars).
descriptionstringyesThe alert's description (max 500 chars).
emailsstring[]noEmail recipients configured on the alert.
alert_conditionobjectnoThe condition that fired — see alert_condition.
segmentobjectno{ "name": string | null }. The segment the alert is scoped to, or null when the alert targets all customers.

Note: an alert may technically have more than one condition configured, but the webhook payload reports the first/primary condition as a single object.

data.alert.alert_condition

FieldTypeDescription
typestringThe kind of comparison. See Condition types.
filter_by_typestringThe category of value being evaluated. See Filter categories.
filter_bystringThe specific field within the category. See Filter categories.
operatorstringThe comparison operator (UPPERCASE). See Operators.
valuestringThe threshold the value is compared against. Always a string. For percentage conditions this is a decimal fraction (e.g. "0.4" means 40%).

data.customers

Each entry represents one matching customer.

FieldTypePresent whenDescription
namestringalwaysThe customer's name.
external_idstringalwaysYour external identifier for the customer (the ID you sync to CustomerScore).
current_valuenumber | stringstandard & attribute_change alertsThe customer's value on date. A number for standard alerts; for attribute_change alerts it is the changed-to attribute value and may be a string.
previous_valuenumberstandard alerts onlyThe customer's value on previous_date.
difference_absnumberstandard alerts onlyAbsolute change (current_value − previous_value).
difference_pctnumberstandard alerts onlyRelative change as a decimal fraction (e.g. -0.22 = −22%).

Which value fields you get depends on the alert type:

  • Standard (percentage, absolute, treshold) — each customer includes all four value fields: current_value, previous_value, difference_abs, difference_pct.
  • attribute_change — there is no numeric before/after comparison, so only current_value is included (the new attribute value, which may be a string). previous_value, difference_abs, and difference_pct are omitted.
  • churned — there is no value comparison at all, so none of the four value fields are included; each customer only has name and external_id.

data.batch

FieldTypeDescription
current_batchnumber1-based index of this batch.
total_batchesnumberTotal number of batches for this alert run.
total_customersnumberTotal matching customers across all batches.
batch_sizenumberNumber of customers in this batch (≤ 1000).

To know when you've received every customer for a run, collect requests until current_batch === total_batches.


Possible values

Condition types

alert_condition.type:

ValueMeaning
percentagePercentage change between previous_date and date.
absoluteAbsolute change between previous_date and date.
tresholdA fixed threshold comparison on the current value.
attribute_changeFires when a customer's attribute (a custom property) changes. Customer entries carry only current_value (the new value) — no before/after comparison.
churnedRecently churned customers. Customer entries omit the value/difference fields.

Filter categories

alert_condition.filter_by_type determines what filter_by refers to:

filter_by_typefilter_by valueMeaning
scoringengagementscoreHealth Score.
scoringfitscoreFit Score.
churn_riskchurn_riskChurn risk level.
propertya numeric property id (as a string, e.g. "42")One of your custom customer properties.
churnedchurnedChurned status.

attribute_change alerts always use filter_by_type: "property", with filter_by set to the numeric property id (as a string). The operator and value are then evaluated against the property's changed-to value — including non-numeric operators such as KNOWN, UNKNOWN, LIKE, NLIKE, IN, and NIN.

Operators

alert_condition.operator is always UPPERCASE:

OperatorMeaning
LTLess than (<)
LTELess than or equal to ()
GTGreater than (>)
GTEGreater than or equal to ()
EQEqual to (=)
NEQNot equal to ()
LIKEContains
NLIKEDoes not contain
INIs one of
NINIs none of
GTDMore than (days, for date fields)
LTDLess than (days, for date fields)
KNOWNIs known (value present)
UNKNOWNIs unknown (value absent)

Verifying the signature

Every request includes an X-customerscore-HMAC header so you can confirm it came from CustomerScore and was not tampered with. The value is:

sha256=<hex>

where <hex> is the HMAC-SHA256 of the raw request body (the exact bytes you received, before any JSON parsing), keyed with your webhook's secret key.

To verify:

  1. Read the raw request body as a string (do not re-serialize the parsed JSON — key ordering/whitespace must match exactly).
  2. Compute HMAC-SHA256(secret_key, rawBody) and hex-encode it.
  3. Compare it to the hex portion of the X-customerscore-HMAC header using a constant-time comparison.

Node.js / Express example:

const crypto = require("node:crypto");
 
// Mount with a raw body parser for this route so you keep the exact bytes:
//   app.post('/webhooks/customerscore', express.raw({ type: 'application/json' }), handler)
 
function handler(req, res) {
  const secret = process.env.CUSTOMERSCORE_WEBHOOK_SECRET;
  const rawBody = req.body; // a Buffer, thanks to express.raw
 
  const expected =
    "sha256=" +
    crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
  const received = req.get("X-customerscore-HMAC") || "";
 
  const valid =
    expected.length === received.length &&
    crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(received));
 
  if (!valid) return res.status(401).send("invalid signature");
 
  const payload = JSON.parse(rawBody.toString("utf8"));
  // ... handle payload.data ...
 
  res.sendStatus(200);
}

Example payloads

Standard alert (Health Score threshold)

{
  "event": "alert.sent",
  "timestamp": "2025-12-31T23:59:59.999Z",
  "data": {
    "date": "2025-12-31",
    "previous_date": "2025-12-30",
    "alert": {
      "name": "High Risk Customers",
      "description": "Alert triggered when customers score below 40",
      "emails": ["csm@example.com"],
      "alert_condition": {
        "type": "treshold",
        "filter_by_type": "scoring",
        "filter_by": "engagementscore",
        "operator": "LT",
        "value": "40"
      },
      "segment": { "name": "Enterprise Customers" }
    },
    "customers": [
      {
        "name": "Acme Corp",
        "external_id": "acme-001",
        "current_value": 35,
        "previous_value": 45,
        "difference_abs": -10,
        "difference_pct": -0.22
      },
      {
        "name": "Beta Industries",
        "external_id": "beta-002",
        "current_value": 28,
        "previous_value": 42,
        "difference_abs": -14,
        "difference_pct": -0.33
      }
    ],
    "batch": {
      "current_batch": 1,
      "total_batches": 1,
      "total_customers": 2,
      "batch_size": 2
    }
  }
}

Attribute change alert

For attribute_change alerts, customer entries carry only current_value (the new attribute value, which may be a string) — there is no before/after comparison:

{
  "event": "alert.sent",
  "timestamp": "2025-12-31T23:59:59.999Z",
  "data": {
    "date": "2025-12-31",
    "previous_date": "2025-12-30",
    "alert": {
      "name": "Plan Upgraded to Enterprise",
      "description": "Customers whose plan attribute changed to enterprise",
      "emails": ["csm@example.com"],
      "alert_condition": {
        "type": "attribute_change",
        "filter_by_type": "property",
        "filter_by": "42",
        "operator": "EQ",
        "value": "enterprise"
      },
      "segment": { "name": null }
    },
    "customers": [
      {
        "name": "Acme Corp",
        "external_id": "acme-001",
        "current_value": "enterprise"
      },
      {
        "name": "Beta Industries",
        "external_id": "beta-002",
        "current_value": "enterprise"
      }
    ],
    "batch": {
      "current_batch": 1,
      "total_batches": 1,
      "total_customers": 2,
      "batch_size": 2
    }
  }
}

Churned alert

For churned alerts, customer entries omit the value/difference fields:

{
  "event": "alert.sent",
  "timestamp": "2025-12-31T23:59:59.999Z",
  "data": {
    "date": "2025-12-31",
    "previous_date": "2025-12-30",
    "alert": {
      "name": "Recently Churned",
      "description": "Customers that churned since the last sync",
      "emails": ["csm@example.com"],
      "alert_condition": {
        "type": "churned",
        "filter_by_type": "churned",
        "filter_by": "churned",
        "operator": "EQ",
        "value": "true"
      },
      "segment": { "name": null }
    },
    "customers": [
      { "name": "Gamma LLC", "external_id": "gamma-003" },
      { "name": "Delta Co", "external_id": "delta-004" }
    ],
    "batch": {
      "current_batch": 1,
      "total_batches": 1,
      "total_customers": 2,
      "batch_size": 2
    }
  }
}