Signed webhooks
Act on new evidence after signup.
Register an endpoint
Open Webhooks in site settings. Add up to five HTTPS endpoints and choose event types. Copy the whsec_… signing secret immediately: it is shown once and stored encrypted. Rotation invalidates the old secret immediately, including for pending deliveries.
POST /api/sites/:siteKey/webhooks
{"url":"https://example.com/hooks/nolgard","types":["account.flagged","cluster.grew"]}
// Owner session required. Returns {endpoint:{id,url,types,enabled,created},secret}.
GET /api/sites/:siteKey/webhooks
DELETE /api/sites/:siteKey/webhooks/:id
POST /api/sites/:siteKey/webhooks/:id/rotate
POST /api/sites/:siteKey/webhooks/:id/test
GET /api/sites/:siteKey/webhook-deliveries
POST /api/sites/:siteKey/webhook-deliveries/:id/redeliverOmit types, or use null or an empty list, to receive all events. Send test targets that endpoint regardless of its filter. Dashboard deliveries show the last 50 attempts; payloads are retained for 30 days. IP literals, localhost, .local and .internal destinations are rejected. Redirects are not followed.
Events and payload
{
"id":"event-uuid",
"type":"account.flagged",
"created":1792800000,
"site":"pk_…",
"data":{
"accountId":"user_5",
"reason":"feedback_fraud",
"clusterSize":5,
"recommendedAction":"revoke_credits"
}
}created is Unix seconds. The event id remains stable across retries and manual redelivery. It is also sent in Nolgard-Event-Id.
| Type | Data |
|---|---|
| account.flagged | accountId, reason (feedback_fraud or cluster_fraud), clusterSize, recommendedAction (revoke_credits). Covers previously granted linked accounts only; the labelled account itself is excluded. Network-only neighbours never qualify. |
| cluster.grew | clusterId, clusterSize, previousSize. Fires at five members and subsequent growth. |
| key.anomaly | API key anomaly details. |
| session.suspicious | Suspicious session details. |
| promo.abuse | Promotion abuse details. |
| agent.budget | Agent budget details. |
| outbound.abuse | accountId, kind, decision, reasons, recipients (count). Once per account per day. See Outbound guard. |
| mcp.registration | clientIdHash, clientName, decision, reasons. Blocked registrations, once per network per day. See MCP guard. |
| appeal.opened | ref, accountId, decision, stepUpPassed. See Appeals. |
| appeal.resolved | ref, accountId, outcome (approve or reject). Restore access or credits on approve. |
| payment.card_testing | scope (device or network), failures, windowMinutes (60). See Payments. |
| payment.fraud | accountId, kind (efw or dispute), reason, linkedAccounts. An early fraud warning or a fraudulent dispute. |
| payment.hold | accountId, state (held, frozen or released), cap, until, reason. Paid credits held until the money settles. |
| test | message: Nolgard test webhook. |
The envelope is common to all events. Treat event-specific data as an extensible object; ignore unknown fields.
Verify before processing
Read the raw body before JSON middleware. The signature is HMAC-SHA256 with the entire signing secret, including whsec_, over timestamp + "." + rawBody. Header: Nolgard-Signature: t=1792800000,v1=64_hex_characters. Reject timestamps outside five minutes and compare signatures in constant time.
Node
import { verifyWebhook } from '@nolgard/node';
const event = await verifyWebhook(rawBody, request.headers['nolgard-signature'],
process.env.NOLGARD_WEBHOOK_SECRET, 300);
// Persist event.id with a UNIQUE constraint, then enqueue your business action.
// Return 2xx only after durable acceptance.Python
import hashlib, hmac, time, json
parts = dict(piece.split('=', 1) for piece in signature_header.split(','))
t = int(parts['t'])
if abs(time.time() - t) > 300:
raise ValueError('Expired webhook')
expected = hmac.new(secret.encode(), parts['t'].encode() + b'.' + raw_body,
hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, parts['v1']):
raise ValueError('Invalid signature')
event = json.loads(raw_body)PHP
$raw = file_get_contents('php://input');
$parts = [];
foreach (explode(',', $signatureHeader) as $part) {
[$key, $value] = explode('=', $part, 2);
$parts[$key] = $value;
}
if (!isset($parts['t'], $parts['v1']) || !ctype_digit($parts['t'])
|| abs(time() - (int)$parts['t']) > 300) {
throw new Exception('Expired or malformed webhook');
}
$expected = hash_hmac('sha256', $parts['t'].'.'.$raw, $secret);
if (!hash_equals($expected, $parts['v1'])) throw new Exception('Invalid signature');
$event = json_decode($raw, true, 512, JSON_THROW_ON_ERROR);Retries and idempotency
A 2xx response acknowledges delivery. Requests time out after 10 seconds. After each failure, retries wait 1 minute, 5 minutes, 30 minutes, 2 hours, then 12 hours. A sixth failed attempt is marked failed. Cron timing can add delay. Delivery is at least once; order is not guaranteed. Store the event id with a unique constraint before applying credits changes. Manual redelivery preserves that id and restarts the retry counter.
Re-assess before payout
GET /v1/accounts/user_5
Authorization: Bearer sk_…
{"accountId":"user_5","risk":"high","clusterSize":5,"fraudLabels":1,
"devices":2,"lastDecision":"allow","flags":["reported_fraud","payment_fraud","refund_serial"],
"recommendedAction":"revoke_credits","hold":"frozen"}
const risk = await nolgard.getAccountRisk('user_5');risk is low, medium or high; recommendedAction is none, limit, review or revoke_credits. payment_fraud (chargeback or early fraud warning) recommends revoke_credits; refund_serial marks repeat refunds. hold is held, frozen or null for an active checkout hold: see Payments. Unknown account: 404 unknown-account. Invalid secret: 401. Demo: 403. Rate limit: 120 requests per minute per site. Test secrets return deterministic synthetic results without storage: allow/shadow low, limit medium, bot high.
Limits
These are logical defaults rather than measured fraud probabilities. Labels and local device/email links are evidence for review rather than proof. The local graph and device history cover 30 days. A grant means Nolgard recommended positive trial share; it cannot prove your billing system issued credits. Your backend owns revocation. Network-only reports never trigger automatic clawback. Webhooks are not a transactional billing ledger.