Webhooks
Receive alerts, new scripts and finished reports on your endpoint, verify the HMAC signature and handle retries.
With a webhook, Proteside sends a POST to your endpoint as soon as something happens: a new alert, an unknown script
on the checkout, a finished PCI DSS report. You don't need to keep polling the API.
Webhooks, Slack, Microsoft Teams and email are all notification channels of the same organization. What you create through the API shows up in Settings → Notifications, and vice versa.
Create a webhook
Open Settings → Notifications and click Add channel. You need to be an owner or admin.
Choose the Webhook type and the scope: This store (only events from the selected store) or Organization (events from every store). Type and scope can't be changed later.
Fill in Name and URL (it must start with https://) and leave Format set to JSON (signed).
Fill in Signing secret with a random value of 16 to 128 characters and keep it safe: you'll need it to verify deliveries.
Choose the Minimum severity and the Events, keep Channel active on and click Save changes.
No secret, no signature
In the dashboard, the secret is optional. If you leave the field empty, deliveries go out without the
X-Proteside-Signature header and the channel card shows "unsigned". Always fill in the secret on JSON webhooks.
Events
| Event | When it's sent | severity | Filtered by min_severity |
|---|---|---|---|
alert.created | A new alert is opened (first occurrence), including CSP violations | the alert's | yes |
alert.resolved | An SDK silent alert resolves itself because the SDK started sending events again | medium | yes |
integrity.changed | An authorized script's content changed (SCRIPT_INTEGRITY_MISMATCH alert) | high | yes |
header.changed | A security header on the payment page changed (HEADER_CHANGED alert) | medium | yes |
sdk.silent | A store with traffic went 24 hours without sending SDK events (SDK_SILENT alert) | medium | yes |
script.detected | A new script appeared on the checkout and is awaiting review | info | no |
policy.applied | An approval policy decided on a script (automatic mode or Apply) | info | no |
report.ready | The scheduled PCI DSS report is ready (Monday, 09:00 UTC) | info | no |
test | You sent a test | info | ignores all filters |
Catalog events that aren't sent today
script.authorized and script.blocked appear in the dashboard's event list and are accepted in the events
field, but Proteside doesn't send them today. To track authorizations and blocks, query GET /scripts or the audit
log.
alert.resolved is only sent when an SDK silent alert resolves itself. Resolving an alert in the dashboard or via
PATCH /alerts/{id} doesn't generate an event. Reports generated by GET /reports/pci don't generate
report.ready either: only the scheduled delivery does.
Channel filters
Each channel has two filters:
events: the list of accepted events. Empty ornullmeans all of them.min_severity: applies only to alert events (alert.created,alert.resolved,integrity.changed,header.changedandsdk.silent).script.detected,policy.appliedandreport.readyalways go through.
The high default drops medium events
New channels start with min_severity: high (High and above). With that value, sdk.silent, header.changed
and medium or low alerts (such as CSP violations) never arrive. If you want to know when the SDK stopped
running, use min_severity: "medium" (Medium and above) or lower.
Delivery format
Proteside makes a POST with a JSON body and these headers:
| Header | Content |
|---|---|
Content-Type | application/json |
User-Agent | Proteside-Webhooks/1.0 |
X-Proteside-Event | The event type, for example alert.created |
X-Proteside-Delivery | The delivery ID (unique per channel and event, the same across all retries) |
X-Proteside-Signature | t=<unix>,v1=<hex> (present when the channel has a secret) |
Every event uses the same envelope:
Prop
Type
Payload examples
{
"id": "evt_5d41402abc4b2a76b9719d911017c592",
"type": "alert.created",
"created_at": "2026-10-06T09:40:01.123Z",
"org_id": "7f3c1e2a-0b4d-4c8e-9f10-2a3b4c5d6e7f",
"store_id": "0c1d2e3f-1111-4222-8333-444455556666",
"severity": "critical",
"data": {
"alert": {
"id": "e1f2a3b4-8888-4999-8000-111122223333",
"type": "PIX_TAMPERED",
"severity": "critical",
"occurrences": 1,
"page": "/checkout/payment",
"details": { "reason": "untrusted_recipient", "method": "pix" },
"first_seen_at": "2026-10-06T09:40:00.000Z",
"source": "sdk",
"url": "https://app.proteside.com/alerts/e1f2a3b4-8888-4999-8000-111122223333"
},
"store": {
"id": "0c1d2e3f-1111-4222-8333-444455556666",
"name": "My Store",
"domain": "mystore.com"
}
}
}alert.resolved, integrity.changed, header.changed and sdk.silent use the same data.alert format.
{
"id": "evt_7c9e6679f4e14b2a8f0e2d3c4b5a6978",
"type": "integrity.changed",
"created_at": "2026-10-06T10:15:02.000Z",
"org_id": "7f3c1e2a-0b4d-4c8e-9f10-2a3b4c5d6e7f",
"store_id": "0c1d2e3f-1111-4222-8333-444455556666",
"severity": "high",
"data": {
"alert": {
"id": "c3d4e5f6-2222-4333-8444-555566667777",
"type": "SCRIPT_INTEGRITY_MISMATCH",
"severity": "high",
"occurrences": 1,
"page": "/",
"details": {
"scriptId": "b2c3d4e5-6666-4777-8888-999900001111",
"src": "https://cdn.fornecedor.example/widget.js",
"authorizedHashPrefix": "9b74c9897bac770f",
"currentHashPrefix": "1f3870be274f6c49",
"hashSource": "verifier",
"previousStatus": "authorized"
},
"first_seen_at": "2026-10-06T10:15:00.000Z",
"source": "system",
"url": "https://app.proteside.com/alerts/c3d4e5f6-2222-4333-8444-555566667777"
},
"store": { "id": "0c1d2e3f-1111-4222-8333-444455556666", "name": "My Store", "domain": "mystore.com" }
}
}{
"id": "evt_0a1b2c3d4e5f60718293a4b5c6d7e8f9",
"type": "sdk.silent",
"created_at": "2026-10-06T11:00:04.000Z",
"org_id": "7f3c1e2a-0b4d-4c8e-9f10-2a3b4c5d6e7f",
"store_id": "0c1d2e3f-1111-4222-8333-444455556666",
"severity": "medium",
"data": {
"alert": {
"id": "d4e5f6a7-3333-4444-8555-666677778888",
"type": "SDK_SILENT",
"severity": "medium",
"occurrences": 1,
"page": "/",
"details": {
"lastEventAt": "2026-10-05T10:58:00.000Z",
"silentHours": 24,
"domain": "mystore.com",
"host": "mystore.com",
"page": "/"
},
"first_seen_at": "2026-10-06T11:00:00.000Z",
"source": "system",
"url": "https://app.proteside.com/alerts/d4e5f6a7-3333-4444-8555-666677778888"
},
"store": { "id": "0c1d2e3f-1111-4222-8333-444455556666", "name": "My Store", "domain": "mystore.com" }
}
}{
"id": "evt_9f86d081884c7d659a2feaa0c55ad015",
"type": "script.detected",
"created_at": "2026-10-06T09:12:30.000Z",
"org_id": "7f3c1e2a-0b4d-4c8e-9f10-2a3b4c5d6e7f",
"store_id": "0c1d2e3f-1111-4222-8333-444455556666",
"severity": "info",
"data": {
"script": {
"id": "f6a7b8c9-4444-4555-8666-777788889999",
"src": "https://cdn.novo-fornecedor.example/tag.js",
"type": "unknown",
"category": null,
"status": "needs_review",
"vendor_id": null,
"size_bytes": 18234,
"gtm_container": null,
"first_seen_at": "2026-10-06T09:12:29.000Z"
},
"store": { "id": "0c1d2e3f-1111-4222-8333-444455556666", "name": "My Store", "domain": "mystore.com" }
}
}{
"id": "evt_2c26b46b68ffc68ff99b453c1d304134",
"type": "policy.applied",
"created_at": "2026-10-06T09:13:00.000Z",
"org_id": "7f3c1e2a-0b4d-4c8e-9f10-2a3b4c5d6e7f",
"store_id": "0c1d2e3f-1111-4222-8333-444455556666",
"severity": "info",
"data": {
"script": {
"id": "b2c3d4e5-6666-4777-8888-999900001111",
"src": "https://www.googletagmanager.com/gtm.js",
"status": "authorized",
"type": "known_third",
"category": "tag_manager"
},
"policy": { "id": "a1b2c3d4-5555-4666-8777-888899990000", "name": "Catalog analytics", "version": 1 },
"action": "authorize",
"justification": "Marketing tag manager, no access to payment fields.",
"expires_at": "2027-01-04T09:13:00.000Z",
"actor": "api:ps_live_Ab3dEf6hIj9kLm2nOp5qRs8t",
"store": { "id": "0c1d2e3f-1111-4222-8333-444455556666", "name": "My Store", "domain": "mystore.com" }
}
}{
"id": "evt_fcde2b2edba56bf408601fb721fe9b5c",
"type": "report.ready",
"created_at": "2026-10-05T09:02:11.000Z",
"org_id": "7f3c1e2a-0b4d-4c8e-9f10-2a3b4c5d6e7f",
"store_id": "0c1d2e3f-1111-4222-8333-444455556666",
"severity": "info",
"data": {
"snapshot_id": "5a6b7c8d-9999-4aaa-8bbb-cccc0000dddd",
"kind": "weekly",
"cadence": "weekly",
"schedule_source": "schedule",
"locale": "pt-BR",
"store_name": "My Store",
"domain": "mystore.com",
"period": { "start": "2026-09-28T09:00:00.000Z", "end": "2026-10-05T09:00:00.000Z" },
"pdf_url": "https://…/reports/…pdf?token=…",
"url": "https://app.proteside.com/compliance?tab=reports&snapshot=5a6b7c8d-9999-4aaa-8bbb-cccc0000dddd",
"pending_reviews": 3,
"review_due": 1,
"open_alerts": 2,
"open_alerts_by_severity": { "critical": 0, "high": 1, "medium": 1, "low": 0, "info": 0 },
"summary_643": { "...": "..." },
"summary_1161": { "...": "..." },
"digest": { "...": "..." },
"report": {
"id": "5a6b7c8d-9999-4aaa-8bbb-cccc0000dddd",
"kind": "weekly",
"period_start": "2026-09-28T09:00:00.000Z",
"period_end": "2026-10-05T09:00:00.000Z",
"pdf_url": "https://…/reports/…pdf?token=…",
"url": "https://app.proteside.com/compliance?tab=reports&snapshot=5a6b7c8d-9999-4aaa-8bbb-cccc0000dddd"
},
"store": { "name": "My Store", "domain": "mystore.com" }
}
}The pdf_url link is signed and valid for 7 days. It may be null if the PDF isn't available.
{
"id": "evt_e4da3b7fbbce2345d7772b0674a318d5",
"type": "test",
"created_at": "2026-10-06T12:11:00.000Z",
"org_id": "7f3c1e2a-0b4d-4c8e-9f10-2a3b4c5d6e7f",
"store_id": null,
"severity": "info",
"data": {
"message": "Test notification from Proteside",
"channel": { "id": "a9b8c7d6-5555-4666-8777-888899990000", "name": "Production SIEM", "type": "webhook" }
}
}Verify the signature
When the channel has a secret, each delivery includes:
X-Proteside-Signature: t=1791277201,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bdtis the send time in Unix seconds (recomputed on every attempt).v1is the HMAC-SHA256, in hexadecimal, oft+.+ the raw body, using the entire secret as the key, including thewhsec_prefix.
To verify:
- Read the raw body, before any JSON parsing. Re-serializing the JSON changes the bytes and breaks the signature.
- Split out
tandv1and check their format:tan integer,v164 hexadecimal characters. - Reject deliveries whose
tis more than 300 seconds off from your clock. Proteside doesn't enforce this limit: protecting against malicious replays is the receiver's responsibility. - Compute the expected HMAC and compare in constant time.
import express from 'express';
import { createHmac, timingSafeEqual } from 'node:crypto';
const SECRET = process.env.PROTESIDE_WEBHOOK_SECRET; // "whsec_..."
const TOLERANCE_S = 300;
function verifySignature(rawBody, header, secret) {
if (!header) return false;
const parts = {};
for (const item of header.split(',')) {
const i = item.indexOf('=');
if (i > 0) parts[item.slice(0, i).trim()] = item.slice(i + 1).trim();
}
// 1. validate the format before any comparison
if (!/^\d+$/.test(parts.t ?? '') || !/^[0-9a-f]{64}$/i.test(parts.v1 ?? '')) return false;
// 2. time window against replays
const t = Number(parts.t);
if (Math.abs(Date.now() / 1000 - t) > TOLERANCE_S) return false;
// 3. HMAC-SHA256(secret, "t." + raw body), compared in constant time
const expected = createHmac('sha256', secret).update(`${t}.`).update(rawBody).digest();
const given = Buffer.from(parts.v1, 'hex');
return given.length === expected.length && timingSafeEqual(given, expected);
}
const app = express();
const seen = new Set(); // in production, use Redis or your database
// express.raw keeps the body as a Buffer, unparsed
app.post('/hooks/proteside', express.raw({ type: 'application/json' }), (req, res) => {
if (!verifySignature(req.body, req.get('X-Proteside-Signature'), SECRET)) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body.toString('utf8'));
if (seen.has(event.id)) return res.sendStatus(200); // already processed
seen.add(event.id);
res.sendStatus(204); // respond quickly (under 8 s)...
queueMicrotask(() => handleEvent(event)); // ...and process afterwards
});
function handleEvent(event) {
console.log(event.type, event.data);
}
app.listen(3000);Rotate the secret
Send a new secret in PATCH /webhooks/{id} (or edit the Signing secret in the dashboard). The change applies
from the next delivery, with no overlap period: pending retries are also signed with the new secret. To avoid losing
events, make your receiver accept both secrets for a few minutes, rotate it in Proteside, and then remove the old one.
Delivery and retries
- The first attempt goes out when the event happens.
- Your endpoint has 8 seconds to respond. Only
2xxresponses count as success. - Redirects aren't followed: a
301or302counts as a failure. Register the final URL. - On failure, Proteside retries until it completes 4 attempts: the immediate one plus three more, roughly 1 minute, 10 minutes and 60 minutes after the previous failure. Retries run in 5-minute cycles, so they may be delayed a few minutes beyond these intervals.
- After the fourth failure, the delivery is marked Failed and isn't resent. The channel isn't deactivated automatically.
- If the channel is deactivated when a retry is due, the delivery fails without being resent.
- There's no ordering guarantee between events.
It may arrive more than once
If your endpoint processes the event but takes longer than 8 seconds to respond, Proteside counts it as a failure
and sends it again. Deduplicate by the event id (evt_…), which stays the same across retries, or by the
X-Proteside-Delivery header. Respond 2xx quickly and process in the background.
The history of the last 50 deliveries (event, status, attempts, HTTP code and error) is in Settings → Notifications, in the Delivery history card, for up to 90 days. The API doesn't expose the history or let you resend a delivery manually.
Test the webhook
In the dashboard, click Test connection on the channel's card. Through the API:
curl -s -X POST "https://app.proteside.com/api/v1/webhooks/a9b8c7d6-5555-4666-8777-888899990000/test" \
-H "Authorization: Bearer $PROTESIDE_TOKEN"{ "id": "a9b8c7d6-5555-4666-8777-888899990000", "delivered": false, "error": "http_404: Not Found" }The test sends a signed test event, with a single attempt. It ignores the event and severity filters and is sent
even if the channel is deactivated.
Check the delivered field
POST /webhooks/{id}/test responds 200 even when delivery fails. Check delivered and, if it's false, the
reason in error: http_<status>: <start of the body>, timeout or a network message.
Slack, Microsoft Teams and email
If you only want notifications for people, you don't need your own endpoint:
- Slack: create an Incoming Webhook in Slack and register its URL with
"format": "slack"in the API, or with the Slack type in the dashboard. Proteside sends a formatted message with a summary of the event and a button to open it in the dashboard. - Microsoft Teams: the same with
"format": "teams"or the Teams type. The message is an Adaptive Card. - Email: dashboard only (Email type, up to 5 recipients). Every store starts with an "E-mail (padrão)" channel (the default email channel) for the owner's email address.
Slack and Teams messages have fixed English text. Email channels only receive alert.created, report.ready and
test, even if other events are checked: integrity.changed, header.changed and sdk.silent notifications don't
arrive by email. For those, use a webhook, Slack or Teams.