Reference

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/redeliver

Omit 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.

TypeData
account.flaggedaccountId, 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.grewclusterId, clusterSize, previousSize. Fires at five members and subsequent growth.
key.anomalyAPI key anomaly details.
session.suspiciousSuspicious session details.
promo.abusePromotion abuse details.
agent.budgetAgent budget details.
outbound.abuseaccountId, kind, decision, reasons, recipients (count). Once per account per day. See Outbound guard.
mcp.registrationclientIdHash, clientName, decision, reasons. Blocked registrations, once per network per day. See MCP guard.
appeal.openedref, accountId, decision, stepUpPassed. See Appeals.
appeal.resolvedref, accountId, outcome (approve or reject). Restore access or credits on approve.
payment.card_testingscope (device or network), failures, windowMinutes (60). See Payments.
payment.fraudaccountId, kind (efw or dispute), reason, linkedAccounts. An early fraud warning or a fraudulent dispute.
payment.holdaccountId, state (held, frozen or released), cap, until, reason. Paid credits held until the money settles.
testmessage: 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.