Skip to content

Listen to webhooks

Pushing data into Noticia is one direction. Webhooks add the reverse path: Noticia POSTs a signed payload to an endpoint you register whenever a subscribed event occurs, so your systems stay current without polling.

The first use case is consent flowing back: when a customer replies STOP or is unsubscribed inside Noticia, your system is notified so its records match the opt-out you honor in Noticia.

Create a webhook in the Noticia app (Integrations, then Webhooks). You declare:

  • a destination URL (HTTPS in production),
  • the events you subscribe to,
  • and you receive a signing secret (whsec_...), shown once at creation. Store it: you verify every delivery with it.

Noticia then POSTs each subscribed event to your URL. The Webhooks dashboard shows the delivery history, lets you inspect any payload and response, redeliver a failed event, and send a test ping.

Every delivery is a POST with a JSON body in this envelope:

{
"id": "whevt_01j8zqm6scefa9bsqq10cjmetc",
"type": "subscription.opted_out",
"api_version": "v1",
"created_at": "2026-06-08T09:00:00.000Z",
"data": {
"profile_id": "prof_01h455vb4pex5vsknk084sn02q",
"phone_number": "+33612345678",
"consent_source": "IN_STORE_TABLET",
"occurred_at": "2026-06-08T09:00:00.000Z"
}
}
  • id: the logical event id (whevt_...), shared by every endpoint subscribed to it.
  • type: the event name (see Events).
  • api_version: the payload contract version, currently v1.
  • created_at: when the event occurred, not when it was delivered. Use it to order events.
  • data: the event-specific payload.

Each request carries these headers:

HeaderDescription
Noticia-SignatureThe signature you verify (see below). Recomputed on every attempt.
Noticia-Webhook-IdPer-delivery id (whdel_...). Use it as your idempotency key.
Noticia-Event-IdThe logical event id (whevt_...), identical to id in the body and shared across endpoints.
Content-TypeAlways application/json.

What a payload contains, and what it does not

Section titled “What a payload contains, and what it does not”

Noticia webhooks are notifications, not copies of your data. A payload carries enough to tell you what happened and to address the subject; the API is the source of truth for current state. Every payload includes the subject’s prefixed id, and beyond that only the data that is the event:

  • A consent change (subscription.*) carries the phone_number and consent_source: the event is about consent on that number.
  • An identity change (profile.updated) carries changed_fields, the names of the fields that changed, not their values.
  • Lifecycle events (profile.created, profile.deleted) carry only the id.

Two consequences for your integration:

  1. Fetch the current state when you need field values. Read it from the API (for example GET /v1/profiles/{profile_id}) rather than trusting an embedded value. This is also what keeps you correct when deliveries arrive out of order or are redelivered: the API returns the latest state, an old payload does not.
  2. Identity values are not broadcast. This keeps your customers’ personal data out of transit, retries and delivery history by default. The one place a payload carries a phone number is the consent events, where the number is the subject of the event.

The reasoning: a webhook travels to an endpoint you operate, is retried, and is stored in a delivery history. Keeping personal data out of it shrinks the blast radius of a misconfigured or compromised endpoint and keeps the authenticated, audited API as the single source of truth.

The Noticia-Signature header looks like this:

Noticia-Signature: t=1749373200,v1=5257a869e7...8a1f2c7d
  • t: the delivery timestamp, in Unix seconds.
  • v1: an HMAC-SHA256, hex-encoded, of <t>.<raw_body> keyed with your signing secret.

To verify a delivery:

  1. Read the raw request body as received, before any JSON parsing. Re-serializing the parsed body changes bytes and breaks the signature.
  2. Build the signed content "{t}.{raw_body}".
  3. Compute the HMAC-SHA256 with your signing secret and compare it, in constant time, to v1.
  4. Reject the request if t is older than your tolerance (5 minutes is a good default). This is what prevents replay.
const crypto = require('crypto');
// `rawBody` must be the exact bytes received (e.g. via express.raw()).
function verifyNoticiaSignature(
rawBody,
signatureHeader,
secret,
toleranceSeconds = 300,
) {
const parts = Object.fromEntries(
signatureHeader.split(',').map((part) => part.split('=')),
);
if (!parts.t) return false;
// Replay protection: reject timestamps outside the tolerance window.
const age = Math.floor(Date.now() / 1000) - Number(parts.t);
if (Number.isNaN(age) || age > toleranceSeconds) return false;
const signedContent = `${parts.t}.${rawBody}`;
const expected = crypto
.createHmac('sha256', secret)
.update(signedContent)
.digest('hex');
// Accept either the current (v1) or, during a rotation, the previous (v1_prev).
return ['v1', 'v1_prev'].some((key) => {
const provided = parts[key];
return (
provided !== undefined &&
provided.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(provided), Buffer.from(expected))
);
});
}

A delivery succeeds when your endpoint answers with a 2xx status within 10 seconds. Anything else (a non-2xx status, a timeout, a connection error) counts as a failure and is retried.

Retries follow a fixed schedule, 1 initial attempt plus 5 retries, 6 attempts in total:

AttemptDelay after the previous attempt
1immediate
21 minute
35 minutes
430 minutes
52 hours
66 hours

After the final attempt a delivery is marked failed. You can redeliver it from the dashboard once your endpoint is fixed.

If an endpoint accumulates 20 consecutive failures, Noticia disables it automatically and emails the organization. Re-enable it from the dashboard once it is healthy again; re-activating clears the failure counter.

  • Be idempotent. A delivery may arrive more than once (a retry where your 2xx was lost, a manual redelivery). Deduplicate on Noticia-Webhook-Id and make handling safe to repeat.
  • Respond fast, process async. Acknowledge with a 2xx quickly (aim for under 2 seconds), then do the real work in a background job. Slow handlers hit the 10-second timeout and get retried.
  • Do not assume order. Events can arrive out of order, especially across retries. Order by created_at rather than by arrival time.
  • Treat the webhook as a trigger, the API as the source of truth. When you need a field value, fetch the current state from the API rather than relying on the payload. It is always fresh, even when a payload is old or out of order.
  • Verify every request. Reject any delivery whose signature does not verify or whose timestamp is stale.
  • Subscribe narrowly. Only subscribe to the events you act on, so your endpoint is not woken for traffic you ignore.

These events are available today. Subscribing to a name you do not need is harmless; you simply receive its deliveries.

A profile gave SMS consent.

{
"profile_id": "prof_01h455vb4pex5vsknk084sn02q",
"phone_number": "+33612345678",
"consent_source": "WEB_FORM",
"occurred_at": "2026-06-08T09:00:00.000Z"
}

A profile withdrew SMS consent, via STOP or a manual opt-out.

{
"profile_id": "prof_01h455vb4pex5vsknk084sn02q",
"phone_number": "+33612345678",
"consent_source": "IN_STORE_TABLET",
"occurred_at": "2026-06-08T09:00:00.000Z"
}

For both subscription events, consent_source is one of LOYALTY_PROGRAM, CSV_IMPORT, API, WEB_FORM, IN_STORE_TABLET, OTHER.

A profile was created.

{
"profile_id": "prof_01h455vb4pex5vsknk084sn02q"
}

A profile identity or core field changed. changed_fields lists the names of the fields that changed, each one of external_id, phone_number, first_name, last_name. The new values are not included: read them from GET /v1/profiles/{profile_id}.

{
"profile_id": "prof_01h455vb4pex5vsknk084sn02q",
"changed_fields": ["phone_number", "last_name"]
}

A profile was deleted.

{
"profile_id": "prof_01h455vb4pex5vsknk084sn02q"
}