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.
Register an endpoint
Section titled “Register an endpoint”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.
Payload format
Section titled “Payload format”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, currentlyv1.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:
| Header | Description |
|---|---|
Noticia-Signature | The signature you verify (see below). Recomputed on every attempt. |
Noticia-Webhook-Id | Per-delivery id (whdel_...). Use it as your idempotency key. |
Noticia-Event-Id | The logical event id (whevt_...), identical to id in the body and shared across endpoints. |
Content-Type | Always 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 thephone_numberandconsent_source: the event is about consent on that number. - An identity change (
profile.updated) carrieschanged_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:
- 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. - 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.
Verify the signature
Section titled “Verify the signature”The Noticia-Signature header looks like this:
Noticia-Signature: t=1749373200,v1=5257a869e7...8a1f2c7dt: 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:
- Read the raw request body as received, before any JSON parsing. Re-serializing the parsed body changes bytes and breaks the signature.
- Build the signed content
"{t}.{raw_body}". - Compute the HMAC-SHA256 with your signing secret and compare it, in constant time, to
v1. - Reject the request if
tis 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)) ); });}import hashlibimport hmacimport time
# `raw_body` must be the exact bytes received (e.g. flask request.get_data()).def verify_noticia_signature( raw_body: bytes, signature_header: str, secret: str, tolerance_seconds: int = 300,) -> bool: parts = {} for part in signature_header.split(","): key, _, value = part.partition("=") if value: parts[key] = value
timestamp = parts.get("t") if timestamp is None or not timestamp.isdigit(): return False
# Replay protection: reject timestamps outside the tolerance window. if int(time.time()) - int(timestamp) > tolerance_seconds: return False
signed_content = f"{timestamp}.".encode() + raw_body expected = hmac.new( secret.encode(), signed_content, hashlib.sha256 ).hexdigest()
# Accept either the current (v1) or, during a rotation, the previous (v1_prev). return any( key in parts and hmac.compare_digest(parts[key], expected) for key in ("v1", "v1_prev") )<?php
// $rawBody must be the exact bytes received (e.g. file_get_contents('php://input')).function verify_noticia_signature( string $rawBody, string $signatureHeader, string $secret, int $toleranceSeconds = 300): bool { $parts = []; foreach (explode(',', $signatureHeader) as $part) { [$key, $value] = array_pad(explode('=', $part, 2), 2, null); $parts[$key] = $value; }
if (!isset($parts['t'])) { return false; }
// Replay protection: reject timestamps outside the tolerance window. if (time() - (int) $parts['t'] > $toleranceSeconds) { return false; }
$signedContent = $parts['t'] . '.' . $rawBody; $expected = hash_hmac('sha256', $signedContent, $secret);
// Accept either the current (v1) or, during a rotation, the previous (v1_prev). foreach (['v1', 'v1_prev'] as $key) { if (isset($parts[$key]) && hash_equals($expected, $parts[$key])) { return true; } }
return false;}Retry policy
Section titled “Retry policy”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:
| Attempt | Delay after the previous attempt |
|---|---|
| 1 | immediate |
| 2 | 1 minute |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
| 6 | 6 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.
Best practices for your receiver
Section titled “Best practices for your receiver”- Be idempotent. A delivery may arrive more than once (a retry where your
2xxwas lost, a manual redelivery). Deduplicate onNoticia-Webhook-Idand make handling safe to repeat. - Respond fast, process async. Acknowledge with a
2xxquickly (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_atrather 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.
Events
Section titled “Events”These events are available today. Subscribing to a name you do not need is harmless; you simply receive its deliveries.
subscription.opted_in
Section titled “subscription.opted_in”A profile gave SMS consent.
{ "profile_id": "prof_01h455vb4pex5vsknk084sn02q", "phone_number": "+33612345678", "consent_source": "WEB_FORM", "occurred_at": "2026-06-08T09:00:00.000Z"}subscription.opted_out
Section titled “subscription.opted_out”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.
profile.created
Section titled “profile.created”A profile was created.
{ "profile_id": "prof_01h455vb4pex5vsknk084sn02q"}profile.updated
Section titled “profile.updated”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"]}profile.deleted
Section titled “profile.deleted”A profile was deleted.
{ "profile_id": "prof_01h455vb4pex5vsknk084sn02q"}