Docs menu
Guides

Webhooks: events and signature verification

InlineShield sends signed JSON events to your endpoint (use HTTPS) after live screenings and review decisions. Verify the signature of every delivery before you act on it.

Last updated

Events

EventWhen
registration.allowed

Sent after each live screening (/verify or /check) with the verdict ALLOWED, to endpoints subscribed to it (subscribed by default). Contains the player's email and IP. Signed with your endpoint's secret; see X-InlineShield-Signature. Live screenings only: test mode sends no webhooks.

registration.blocked

Sent after each live screening whose verdict is not ALLOWED: BLOCKED and also QUARANTINED (read data.decision). Subscribed by default. Contains the player's email and IP. A held sign-up is later resolved by signup.approved or signup.rejected. Signed with your endpoint's secret; see X-InlineShield-Signature. Live screenings only: test mode sends no webhooks.

signup.approved

Sent when a held sign-up is approved in the console. Sent to every enabled endpoint, whatever its subscriptions. No email or IP. Signed with your endpoint's secret; see X-InlineShield-Signature. Live screenings only: test mode sends no webhooks.

signup.rejected

Sent when a held sign-up is rejected in the console, or expires undecided (decisionReason: "expired"). Sent to every enabled endpoint. No email or IP. Signed with your endpoint's secret; see X-InlineShield-Signature. Live screenings only: test mode sends no webhooks.

test.ping

Sent by "Send test ping" in the console (Settings › Webhook), signed like every delivery. Its body has a different shape from the other events: no id, apiVersion or createdAt, and a numeric timestamp (the same value as t in the signature). Sent once, never retried.

A held sign-up (QUARANTINED) is sent as registration.blocked, with data.decision: "QUARANTINED", and is resolved later by signup.approved or signup.rejected.

incident.created and registration.review_required can appear in an endpoint's subscription list (incident.created is there by default), but they are never sent.

Test mode sends no webhooks. Configure the endpoint URL and get its signing secret (whsec_…) in the console under Settings › Webhook.

Payloads

Every event but test.ping has the same envelope, with the event data in data:

FieldTypeRequiredDescription
idstringyesThe delivery id (also in X-InlineShield-Delivery). Retries of a delivery keep it.matches ^del_
eventstringyes
apiVersion"2026-10-25"yes
createdAtstring (date-time)yes
organizationIdstringyes
dataobjectyes

registration.allowed and registration.blocked: data

These contain the player's email and IP address.

FieldTypeRequiredDescription
requestIdstringyesThe screening's id. Use it for feedback and support.matches ^AUD-
emailstringyes
ipstringyes
decisionstringyesALLOWED: let the player in. QUARANTINED: hold for review. BLOCKED: refuse.Values: ALLOWED, QUARANTINED, BLOCKED
verdictstringyesALLOWED: let the player in. QUARANTINED: hold for review. BLOCKED: refuse.Values: ALLOWED, QUARANTINED, BLOCKED
riskScorenumberyes0 (lowest risk) to 100.0 to 100
reasonsarray of stringyesPlain-language reasons for the result.
triggersarray of stringyesMachine-readable signal codes (e.g. DISPOSABLE_DOMAIN, TEST_MAGIC_ADDRESS). New codes may be added.
timestampstring (date-time)yes

signup.approved and signup.rejected: data

No email or IP address.

FieldTypeRequiredDescription
eventstringyesValues: signup.approved, signup.rejected
reviewIdstringyesmatches ^rev_
requestIdstringyesThe requestId the screening response carried.
statusstringyesValues: approved, rejected
decisionReasonstring or nullyesValues: operator, expired, null
decidedAtstring (date-time) or nullyes
recommendedActionstringyessignup.rejected: block. signup.approved: require_kyc when the hold's signals call for it, else allow.Values: allow, require_kyc, block

test.ping

test.ping has a different shape: no id, apiVersion or createdAt, and a numeric timestamp (the same value as t in the signature). It has no Delivery or Attempt header. A receiver that parses the envelope strictly should accept it too.

{
  "id": "del_8c1e4f7a2b9d3e05",
  "event": "registration.allowed",
  "apiVersion": "2026-10-25",
  "createdAt": "2026-11-02T12:00:01.000Z",
  "organizationId": "org_7d1c2b3a",
  "data": {
    "requestId": "AUD-MHH3K2QZ-4F1C9B2E7D10",
    "email": "player0@example.com",
    "ip": "203.0.113.24",
    "decision": "ALLOWED",
    "verdict": "ALLOWED",
    "riskScore": 4,
    "reasons": [],
    "triggers": [],
    "timestamp": "2026-11-02T12:00:00.912Z"
  }
}

The signature

Each delivery carries X-InlineShield-Signature: t=<unix seconds>,v1=<hex>. To verify it:

  1. Read the raw request body, before any JSON parsing.
  2. Compute HMAC-SHA256 with your endpoint's signing secret (the whole whsec_… string is the key) over <t>.<raw body>, hex-encoded.
  3. Compare it with v1 in constant time. Reject on a mismatch.
  4. Reject a t too far from your clock. The code below allows 300 seconds.

t=<unix seconds>,v1=<hex HMAC-SHA256>. The HMAC is computed with your endpoint's signing secret (whsec_…, the whole string as the key) over <t>.<raw request body>. Compare it in constant time. t is the time the delivery was first signed: automatic retries resend the same signature (so t can be a few seconds old), and a delivery resent later (for example one interrupted by a restart on our side) is signed again with a new t: its content and id are the same, but the bytes can differ.

Every header is also sent under its old name with the same value, for example X-BetShield-Signature.

Worked example

Secret whsec_ZXhhbXBsZS1zZWNyZXQtZG8tbm90LXVzZS1pbi1wcm9kdWN0aW9u, timestamp 1793620805, and this body:

Raw body
{"id":"del_5f2c9a1e7b3d4c60","event":"signup.approved","apiVersion":"2026-10-25","createdAt":"2026-11-02T12:00:05.000Z","organizationId":"org_7d1c2b3a","data":{"event":"signup.approved","reviewId":"rev_Hk3pQ9xZ2mV7cN4bL1kJ8hG5","requestId":"AUD-MHH3K2QZ-4F1C9B2E7D10","status":"approved","decisionReason":"operator","decidedAt":"2026-11-02T12:00:04.000Z","recommendedAction":"allow"}}

give the header:

X-InlineShield-Signature
t=1793620805,v1=0b14a41708b9a46125d31465e106465d80990634e175f4d310bc939e1ba7fa7a

Verification code

Receivers in five languages, the same as the console's Setup page. Each checks the signature in constant time and refuses to run without a secret.

// Express. X-InlineShield-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>">
const express = require('express');
const crypto = require('crypto');
const app = express();

// express.raw keeps the exact bytes InlineShield signed: do NOT use express.json() on this route.
app.post('/inlineshield/webhook', express.raw({ type: '*/*' }), (req, res) => {
  const secret = process.env.INLINESHIELD_WEBHOOK_SECRET;
  if (!secret) return res.status(500).send('webhook secret not configured'); // never verify with an empty key
  const m = /^t=(\d+),v1=([0-9a-f]{64})$/.exec(req.get('X-InlineShield-Signature') || '');
  if (!m || !Buffer.isBuffer(req.body) || Math.abs(Date.now() / 1000 - Number(m[1])) > 300) {
    return res.status(400).send('bad signature'); // malformed header or older/newer than 300 s
  }
  const expected = crypto.createHmac('sha256', secret)
    .update(m[1] + '.').update(req.body).digest();
  const given = Buffer.from(m[2], 'hex');
  if (given.length !== expected.length || !crypto.timingSafeEqual(given, expected)) { // constant-time
    return res.status(400).send('bad signature');
  }

  const event = JSON.parse(req.body.toString('utf8'));
  const d = event.data;
  if (event.event === 'signup.approved') {
    // Activate the account that has reviewId d.reviewId (status "approved").
  } else if (event.event === 'signup.rejected') {
    // Delete or disable the account with reviewId d.reviewId (see d.decisionReason).
  }
  // Deliveries can repeat: make the handler idempotent (key on event.id).
  res.sendStatus(200);
});

Delivery and retries

  • Answer with any 2xx status, quickly: that means delivered. The response body is not read.
  • A redirect is not followed and, like a 4xx other than 429, is not retried.
  • 5xx, 429, timeouts and network errors are retried with a short backoff (1, then 2 seconds), for up to 3 attempts by default. Each attempt waits up to 8 seconds by default.
  • Automatic retries resend the same body and the same signature, so t can be a few seconds old.
  • A delivery that is resent later (for example one interrupted by a restart on our side) is signed again with a new t and carries X-InlineShield-Retry: true instead of an Attempt header. Its content and id are the same, but the bytes can differ, so always verify the raw body you received.
  • Deliveries can repeat: make your handler idempotent, keyed on the body's id (also in X-InlineShield-Delivery).
  • Endpoints on private or reserved addresses are refused.
  • test.ping is sent once and never retried.