Docs menu
Guides
API reference
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
| Event | When |
|---|---|
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 |
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 ( |
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 |
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:
| Field | Type | Required | Description |
|---|---|---|---|
id | string | yes | The delivery id (also in X-InlineShield-Delivery). Retries of a delivery keep it.matches ^del_ |
event | string | yes | |
apiVersion | "2026-10-25" | yes | |
createdAt | string (date-time) | yes | |
organizationId | string | yes | |
data | object | yes |
registration.allowed and registration.blocked: data
These contain the player's email and IP address.
| Field | Type | Required | Description |
|---|---|---|---|
requestId | string | yes | The screening's id. Use it for feedback and support.matches ^AUD- |
email | string | yes | |
ip | string | yes | |
decision | string | yes | ALLOWED: let the player in. QUARANTINED: hold for review. BLOCKED: refuse.Values: ALLOWED, QUARANTINED, BLOCKED |
verdict | string | yes | ALLOWED: let the player in. QUARANTINED: hold for review. BLOCKED: refuse.Values: ALLOWED, QUARANTINED, BLOCKED |
riskScore | number | yes | 0 (lowest risk) to 100.0 to 100 |
reasons | array of string | yes | Plain-language reasons for the result. |
triggers | array of string | yes | Machine-readable signal codes (e.g. DISPOSABLE_DOMAIN, TEST_MAGIC_ADDRESS). New codes may be added. |
timestamp | string (date-time) | yes |
signup.approved and signup.rejected: data
No email or IP address.
| Field | Type | Required | Description |
|---|---|---|---|
event | string | yes | Values: signup.approved, signup.rejected |
reviewId | string | yes | matches ^rev_ |
requestId | string | yes | The requestId the screening response carried. |
status | string | yes | Values: approved, rejected |
decisionReason | string or null | yes | Values: operator, expired, null |
decidedAt | string (date-time) or null | yes | |
recommendedAction | string | yes | signup.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"
}
}{
"id": "del_8c1e4f7a2b9d3e05",
"event": "registration.blocked",
"apiVersion": "2026-10-25",
"createdAt": "2026-11-02T12:00:01.000Z",
"organizationId": "org_7d1c2b3a",
"data": {
"requestId": "AUD-MHH3K30A-91B7C2D40E55",
"email": "player1@example.com",
"ip": "203.0.113.24",
"decision": "QUARANTINED",
"verdict": "QUARANTINED",
"riskScore": 62,
"reasons": [
"Plus-addressed email (player+bonus@…)."
],
"triggers": [
"PLUS_ALIASED_EMAIL"
],
"timestamp": "2026-11-02T12:00:00.912Z"
}
}{
"id": "del_5f2c9a1e7b3d4c60",
"event": "signup.approved",
"apiVersion": "2026-10-25",
"createdAt": "2026-11-02T14:31:08.000Z",
"organizationId": "org_7d1c2b3a",
"data": {
"event": "signup.approved",
"reviewId": "rev_Hk3pQ9xZ2mV7cN4bL1kJ8hG5",
"requestId": "AUD-MHH3K31C-0C3D5E7F9A21",
"status": "approved",
"decisionReason": "operator",
"decidedAt": "2026-11-02T14:31:07.000Z",
"recommendedAction": "allow"
}
}{
"id": "del_5f2c9a1e7b3d4c60",
"event": "signup.rejected",
"apiVersion": "2026-10-25",
"createdAt": "2026-11-02T14:31:08.000Z",
"organizationId": "org_7d1c2b3a",
"data": {
"event": "signup.rejected",
"reviewId": "rev_Hk3pQ9xZ2mV7cN4bL1kJ8hG5",
"requestId": "AUD-MHH3K31C-0C3D5E7F9A21",
"status": "rejected",
"decisionReason": "operator",
"decidedAt": "2026-11-02T14:31:07.000Z",
"recommendedAction": "block"
}
}{
"event": "test.ping",
"timestamp": 1793620805,
"organizationId": "org_7d1c2b3a",
"data": {
"message": "InlineShield verified test delivery probe"
}
}The signature
Each delivery carries X-InlineShield-Signature: t=<unix seconds>,v1=<hex>. To verify it:
- Read the raw request body, before any JSON parsing.
- Compute HMAC-SHA256 with your endpoint's signing secret (the whole
whsec_…string is the key) over<t>.<raw body>, hex-encoded. - Compare it with
v1in constant time. Reject on a mismatch. - Reject a
ttoo 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:
{"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:
t=1793620805,v1=0b14a41708b9a46125d31465e106465d80990634e175f4d310bc939e1ba7fa7aVerification 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);
});# Flask. X-InlineShield-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>">
import hashlib, hmac, json, os, re, time
from flask import abort, request
# app is your Flask application: app = Flask(__name__)
@app.post("/inlineshield/webhook")
def inlineshield_webhook():
secret = os.environ.get("INLINESHIELD_WEBHOOK_SECRET", "")
if not secret:
abort(500) # never verify with an empty key
raw = request.get_data() # raw bytes, before any JSON parsing
m = re.fullmatch(r"t=(\d+),v1=([0-9a-f]{64})", request.headers.get("X-InlineShield-Signature", ""))
if not m or abs(time.time() - int(m.group(1))) > 300:
abort(400) # malformed header or older/newer than 300 s
expected = hmac.new(secret.encode(),
m.group(1).encode() + b"." + raw, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, m.group(2)): # constant-time
abort(400)
event = json.loads(raw)
d = event["data"]
if event["event"] == "signup.approved":
pass # activate the account that has reviewId d["reviewId"] (status "approved")
elif event["event"] == "signup.rejected":
pass # delete or disable it (see d["decisionReason"])
# Deliveries can repeat: make the handler idempotent (key on event["id"]).
return "", 200<?php
// Webhook receiver. X-InlineShield-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>">
$secret = getenv('INLINESHIELD_WEBHOOK_SECRET');
if (!is_string($secret) || $secret === '') { http_response_code(500); exit; } // never verify with an empty key
$raw = file_get_contents('php://input'); // verify the RAW body, before json_decode
$header = $_SERVER['HTTP_X_INLINESHIELD_SIGNATURE'] ?? '';
if (!preg_match('/^t=(\d+),v1=([0-9a-f]{64})$/D', $header, $m) || abs(time() - (int)$m[1]) > 300) {
http_response_code(400); // malformed header or older/newer than 300 s
exit;
}
$expected = hash_hmac('sha256', $m[1] . '.' . $raw, $secret);
if (!hash_equals($expected, $m[2])) { // constant-time compare
http_response_code(400);
exit;
}
$event = json_decode($raw, true);
$d = $event['data'] ?? [];
if (($event['event'] ?? '') === 'signup.approved') {
// Activate the account that has reviewId $d['reviewId'] (status "approved").
} elseif (($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']).
http_response_code(200);// Spring MVC, Java 17+. X-InlineShield-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>">
@PostMapping("/inlineshield/webhook")
public ResponseEntity<Void> inlineshieldWebhook(
@RequestBody byte[] raw, // raw bytes, before any JSON parsing
@RequestHeader(value = "X-InlineShield-Signature", defaultValue = "") String header) throws Exception {
java.util.regex.Matcher m = java.util.regex.Pattern.compile("t=(\\d{1,12}),v1=([0-9a-f]{64})").matcher(header);
if (!m.matches() || Math.abs(Instant.now().getEpochSecond() - Long.parseLong(m.group(1))) > 300) {
return ResponseEntity.badRequest().build(); // malformed header or older/newer than 300 s
}
String secret = System.getenv("INLINESHIELD_WEBHOOK_SECRET");
if (secret == null || secret.isEmpty()) return ResponseEntity.status(500).build(); // never verify with an empty key
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
mac.update((m.group(1) + ".").getBytes(StandardCharsets.UTF_8));
byte[] expected = mac.doFinal(raw);
if (!MessageDigest.isEqual(expected, HexFormat.of().parseHex(m.group(2)))) { // constant-time
return ResponseEntity.badRequest().build();
}
JsonNode event = new ObjectMapper().readTree(raw); // Jackson, or your JSON library
JsonNode d = event.get("data");
if ("signup.approved".equals(event.path("event").asText())) {
// Activate the account that has reviewId d.get("reviewId") (status "approved").
} else if ("signup.rejected".equals(event.path("event").asText())) {
// Delete or disable it (see d.get("decisionReason")).
}
// Deliveries can repeat: make the handler idempotent (key on event.get("id")).
return ResponseEntity.ok().build();
}// ASP.NET Core minimal API. X-InlineShield-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>">
app.MapPost("/inlineshield/webhook", async (HttpRequest request) =>
{
using var ms = new MemoryStream();
await request.Body.CopyToAsync(ms);
var raw = ms.ToArray(); // raw bytes, before any JSON parsing
var m = Regex.Match(request.Headers["X-InlineShield-Signature"].ToString(), @"\At=(\d{1,12}),v1=([0-9a-f]{64})\z");
if (!m.Success || Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - long.Parse(m.Groups[1].Value)) > 300)
return Results.BadRequest(); // malformed header or older/newer than 300 s
var secret = Environment.GetEnvironmentVariable("INLINESHIELD_WEBHOOK_SECRET");
if (string.IsNullOrEmpty(secret)) return Results.StatusCode(500); // never verify with an empty key
var key = Encoding.UTF8.GetBytes(secret);
var signed = Encoding.UTF8.GetBytes(m.Groups[1].Value + ".").Concat(raw).ToArray();
var expected = HMACSHA256.HashData(key, signed);
if (!CryptographicOperations.FixedTimeEquals(expected, Convert.FromHexString(m.Groups[2].Value))) // constant-time
return Results.BadRequest();
using var doc = JsonDocument.Parse(raw);
var evt = doc.RootElement;
var d = evt.GetProperty("data");
switch (evt.GetProperty("event").GetString())
{
case "signup.approved":
// Activate the account that has reviewId d.GetProperty("reviewId") (status "approved").
break;
case "signup.rejected":
// Delete or disable it (see d.GetProperty("decisionReason")).
break;
}
// Deliveries can repeat: make the handler idempotent (key on evt.GetProperty("id")).
return Results.Ok();
});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
tcan 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
tand carriesX-InlineShield-Retry: trueinstead of an Attempt header. Its content andidare 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 inX-InlineShield-Delivery). - Endpoints on private or reserved addresses are refused.
test.pingis sent once and never retried.