Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ follows [Semantic Versioning](https://semver.org/).

## [Unreleased]

### Changed

- `DetectionFlags` follows webhook schema `2026-10-13`: 20 public booleans. `vpn` covers a network VPN and an in-browser VPN or proxy. `os_mismatch` covers either operating-system mismatch check. The public set adds `ws_tcp_latency`, `device_spoofing`, `rate_limited`, `ai_bot` and `ai_browser`.

### Added

- `sync.sh` downloads the OpenAPI description and `generate.sh` rebuilds `generated/` from it. The supported client is unchanged.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -291,7 +291,7 @@ The result is HMAC-SHA256 as 64 lowercase hex characters: stable for a user and
- Events: `IdentificationScoredEvent` (`$event->data` is an `Identification`), `WebhookPingEvent` (sent when you verify an endpoint) and `UnknownWebhookEvent` for event types newer than this SDK (acknowledge them with a 2xx). `SignatureVerificationException` means the delivery is not authentic; `WebhookParseException` means a correctly signed body is not a valid event.
- Today each identification is delivered once per endpoint: one attempt with a 1-second timeout and no retries. Respond with a 2xx quickly and do slow work afterwards. Future retries will resend identical bytes, so make handlers idempotent on `data.request_id` (`$event->data->request_id`) now.
- For guaranteed reads, use the History API: a missed delivery is not sent again, and a History row can be refined after its webhook was sent.
- The "Test" button in the analytics dashboard sends a sample with 17 of the 19 flags and second-precision timestamps. It parses normally; missing flags are false.
- The "Test" button in the analytics dashboard sends a sample event. It parses normally; missing flags are false.
- `Webhook::verifySignature()` returns a boolean without parsing, for custom flows.

### Domain profile (Management API)
Expand Down
2 changes: 1 addition & 1 deletion src/Event/WebhookEvent.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ abstract class WebhookEvent
{
/**
* @param string $event_type for example "identification.scored"
* @param string $schema_version payload schema version, "2026-06-01" today (other values are accepted)
* @param string $schema_version payload schema version, "2026-10-13" today (other values are accepted)
* @param \DateTimeImmutable|null $created_at when the event was created (UTC)
* @param array<mixed> $raw the decoded body as received
*/
Expand Down
45 changes: 25 additions & 20 deletions src/Internal/Normalizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,12 @@
*/
final class Normalizer
{
/** The 19 detection flags, in wire order. */
/** The 20 public detection flags, in wire order. */
public const FLAG_KEYS = [
'vpn', 'privacy_relay', 'browser_vpn_proxy', 'tor', 'proxy', 'datacenter_ip', 'abuser',
'os_mismatch', 'os_not_detected', 'timezone_mismatch', 'anti_detect_browser',
'browser_automation', 'ip_mismatch', 'incognito', 'search_bot', 'suspicious_paid_click',
'javascript_disabled', 'stun_not_checked', 'check_incomplete',
'vpn', 'proxy', 'privacy_relay', 'check_incomplete', 'stun_not_checked', 'os_not_detected',
'timezone_mismatch', 'javascript_disabled', 'os_mismatch', 'ip_mismatch', 'ws_tcp_latency',
'anti_detect_browser', 'device_spoofing', 'rate_limited', 'incognito', 'search_bot', 'ai_bot',
'browser_automation', 'ai_browser', 'tor',
];

public const TRAFFIC_KEYS = [
Expand All @@ -31,23 +31,23 @@ final class Normalizer

/** Normalized flag name to History row column. */
private const HISTORY_FLAG_MAP = [
'vpn' => 'is_vpn',
'privacy_relay' => 'is_privacy_relay',
'tor' => 'is_tor',
'proxy' => 'is_proxy',
'datacenter_ip' => 'is_datacenter',
'abuser' => 'is_abuser',
'os_mismatch' => 'is_os_mismatch',
'privacy_relay' => 'is_privacy_relay',
'check_incomplete' => 'check_incomplete',
'stun_not_checked' => 'is_stun_not_checked',
'os_not_detected' => 'is_os_not_detected',
'timezone_mismatch' => 'is_timezone_mismatch',
'javascript_disabled' => 'is_js_disabled',
'ws_tcp_latency' => 'is_latency_test',
'anti_detect_browser' => 'is_antidetect',
'browser_automation' => 'is_browser_automation',
'device_spoofing' => 'is_device_spoof',
'rate_limited' => 'is_rate_limited',
'incognito' => 'is_incognito',
'search_bot' => 'is_search_bot',
'suspicious_paid_click' => 'is_suspicious_paid_click',
'javascript_disabled' => 'is_js_disabled',
'stun_not_checked' => 'is_stun_not_checked',
'check_incomplete' => 'check_incomplete',
'ai_bot' => 'is_ai_bot',
'browser_automation' => 'is_browser_automation',
'ai_browser' => 'is_ai_browser',
'tor' => 'is_tor',
];

private const EXACT_SLUGS = [
Expand All @@ -62,7 +62,7 @@ final class Normalizer
'UA OS is not detected' => 'os_not_detected',
'Network OS is not detected' => 'os_not_detected',
'Browser timezone ≠ IP-timezone' => 'timezone_mismatch',
'Browser VPN/Proxy' => 'browser_vpn_proxy',
'Browser VPN/Proxy' => 'vpn',
'Browser Automation' => 'browser_automation',
'Port scan routed via proxy (antidetect browser pattern)' => 'proxy_routed_antidetect',
'User has been banned 1H, to many requests' => 'rate_limited',
Expand All @@ -71,7 +71,7 @@ final class Normalizer
private const PREFIX_SLUGS = [
['Antidetect browser', 'antidetect_browser'],
['Os_mismatch', 'os_mismatch'],
['OS mismatch2', 'os_mismatch2'],
['OS mismatch2', 'os_mismatch'],
['TCP handshake', 'tcp_handshake_v2'],
['Latency test', 'ws_tcp_latency'],
['JavaScript disabled', 'javascript_disabled'],
Expand Down Expand Up @@ -227,8 +227,13 @@ public static function fromHistoryRow(array $row): array
$searchBot = self::truthy($row['is_search_bot'] ?? null);
$flags = [];
foreach (self::FLAG_KEYS as $key) {
if ($key === 'browser_vpn_proxy') {
$flags[$key] = ($row['connection_type'] ?? null) === 'browser_vpn_proxy';
if ($key === 'vpn') {
$flags[$key] = self::truthy($row['is_vpn'] ?? null)
|| self::truthy($row['is_browser_vpn_proxy'] ?? null)
|| ($row['connection_type'] ?? null) === 'browser_vpn_proxy';
} elseif ($key === 'os_mismatch') {
$flags[$key] = self::truthy($row['is_os_mismatch'] ?? null)
|| self::truthy($row['is_os_mismatch2'] ?? null);
} elseif ($key === 'ip_mismatch') {
$flags[$key] = !$searchBot
&& ($ipLeakDetail || ($publicIp !== '' && $localIp !== '' && $publicIp !== $localIp));
Expand Down
85 changes: 44 additions & 41 deletions src/Model/DetectionFlags.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,38 +7,39 @@
use ShieldLabs\Exception\ValidationException;

/**
* The 19 stable detection flags. A flag missing from the payload is false.
* The 20 public detection flags. A flag missing from the payload is false.
*/
final class DetectionFlags implements \JsonSerializable
{
/** Flag names in wire order. */
public const KEYS = [
'vpn', 'privacy_relay', 'browser_vpn_proxy', 'tor', 'proxy', 'datacenter_ip', 'abuser',
'os_mismatch', 'os_not_detected', 'timezone_mismatch', 'anti_detect_browser',
'browser_automation', 'ip_mismatch', 'incognito', 'search_bot', 'suspicious_paid_click',
'javascript_disabled', 'stun_not_checked', 'check_incomplete',
'vpn', 'proxy', 'privacy_relay', 'check_incomplete', 'stun_not_checked', 'os_not_detected',
'timezone_mismatch', 'javascript_disabled', 'os_mismatch', 'ip_mismatch', 'ws_tcp_latency',
'anti_detect_browser', 'device_spoofing', 'rate_limited', 'incognito', 'search_bot', 'ai_bot',
'browser_automation', 'ai_browser', 'tor',
];

public function __construct(
public readonly bool $vpn = false,
public readonly bool $privacy_relay = false,
public readonly bool $browser_vpn_proxy = false,
public readonly bool $tor = false,
public readonly bool $proxy = false,
public readonly bool $datacenter_ip = false,
public readonly bool $abuser = false,
public readonly bool $os_mismatch = false,
public readonly bool $privacy_relay = false,
public readonly bool $check_incomplete = false,
public readonly bool $stun_not_checked = false,
public readonly bool $os_not_detected = false,
public readonly bool $timezone_mismatch = false,
public readonly bool $anti_detect_browser = false,
public readonly bool $browser_automation = false,
public readonly bool $javascript_disabled = false,
public readonly bool $os_mismatch = false,
public readonly bool $ip_mismatch = false,
public readonly bool $ws_tcp_latency = false,
public readonly bool $anti_detect_browser = false,
public readonly bool $device_spoofing = false,
public readonly bool $rate_limited = false,
public readonly bool $incognito = false,
public readonly bool $search_bot = false,
public readonly bool $suspicious_paid_click = false,
public readonly bool $javascript_disabled = false,
public readonly bool $stun_not_checked = false,
public readonly bool $check_incomplete = false,
public readonly bool $ai_bot = false,
public readonly bool $browser_automation = false,
public readonly bool $ai_browser = false,
public readonly bool $tor = false,
) {}

/**
Expand All @@ -50,24 +51,25 @@ public static function fromArray(array $flags): self

return new self(
vpn: $value('vpn'),
privacy_relay: $value('privacy_relay'),
browser_vpn_proxy: $value('browser_vpn_proxy'),
tor: $value('tor'),
proxy: $value('proxy'),
datacenter_ip: $value('datacenter_ip'),
abuser: $value('abuser'),
os_mismatch: $value('os_mismatch'),
privacy_relay: $value('privacy_relay'),
check_incomplete: $value('check_incomplete'),
stun_not_checked: $value('stun_not_checked'),
os_not_detected: $value('os_not_detected'),
timezone_mismatch: $value('timezone_mismatch'),
anti_detect_browser: $value('anti_detect_browser'),
browser_automation: $value('browser_automation'),
javascript_disabled: $value('javascript_disabled'),
os_mismatch: $value('os_mismatch'),
ip_mismatch: $value('ip_mismatch'),
ws_tcp_latency: $value('ws_tcp_latency'),
anti_detect_browser: $value('anti_detect_browser'),
device_spoofing: $value('device_spoofing'),
rate_limited: $value('rate_limited'),
incognito: $value('incognito'),
search_bot: $value('search_bot'),
suspicious_paid_click: $value('suspicious_paid_click'),
javascript_disabled: $value('javascript_disabled'),
stun_not_checked: $value('stun_not_checked'),
check_incomplete: $value('check_incomplete'),
ai_bot: $value('ai_bot'),
browser_automation: $value('browser_automation'),
ai_browser: $value('ai_browser'),
tor: $value('tor'),
);
}

Expand Down Expand Up @@ -103,24 +105,25 @@ public function toArray(): array
{
return [
'vpn' => $this->vpn,
'privacy_relay' => $this->privacy_relay,
'browser_vpn_proxy' => $this->browser_vpn_proxy,
'tor' => $this->tor,
'proxy' => $this->proxy,
'datacenter_ip' => $this->datacenter_ip,
'abuser' => $this->abuser,
'os_mismatch' => $this->os_mismatch,
'privacy_relay' => $this->privacy_relay,
'check_incomplete' => $this->check_incomplete,
'stun_not_checked' => $this->stun_not_checked,
'os_not_detected' => $this->os_not_detected,
'timezone_mismatch' => $this->timezone_mismatch,
'anti_detect_browser' => $this->anti_detect_browser,
'browser_automation' => $this->browser_automation,
'javascript_disabled' => $this->javascript_disabled,
'os_mismatch' => $this->os_mismatch,
'ip_mismatch' => $this->ip_mismatch,
'ws_tcp_latency' => $this->ws_tcp_latency,
'anti_detect_browser' => $this->anti_detect_browser,
'device_spoofing' => $this->device_spoofing,
'rate_limited' => $this->rate_limited,
'incognito' => $this->incognito,
'search_bot' => $this->search_bot,
'suspicious_paid_click' => $this->suspicious_paid_click,
'javascript_disabled' => $this->javascript_disabled,
'stun_not_checked' => $this->stun_not_checked,
'check_incomplete' => $this->check_incomplete,
'ai_bot' => $this->ai_bot,
'browser_automation' => $this->browser_automation,
'ai_browser' => $this->ai_browser,
'tor' => $this->tor,
];
}

Expand Down
2 changes: 1 addition & 1 deletion src/Webhook.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
final class Webhook
{
public const SIGNATURE_HEADER = 'X-Shield-Signature';
public const SCHEMA_VERSION = '2026-06-01';
public const SCHEMA_VERSION = '2026-10-13';

private const SIGNATURE_PREFIX = 'sha256=';

Expand Down
2 changes: 1 addition & 1 deletion tests/Unit/ModelTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ public function testDetectionFlagsLookup(): void
self::assertSame(['vpn', 'check_incomplete'], $flags->active());
self::assertSame(DetectionFlags::KEYS, array_keys($flags->toArray()));
self::assertSame($flags->toArray(), $flags->jsonSerialize());
self::assertCount(19, DetectionFlags::KEYS);
self::assertCount(20, DetectionFlags::KEYS);

$this->expectException(ValidationException::class);
$flags->get('bot');
Expand Down
8 changes: 4 additions & 4 deletions tests/Unit/NormalizerEdgeCaseTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -114,8 +114,8 @@ public function testLocalIpPrefersTheLeakSource(): void

public function testBrowserVpnProxyComesFromTheConnectionType(): void
{
self::assertTrue(Identification::fromHistoryRow(['connection_type' => 'browser_vpn_proxy'])->detection_flags->browser_vpn_proxy);
self::assertFalse(Identification::fromHistoryRow(['connection_type' => 'vpn'])->detection_flags->browser_vpn_proxy);
self::assertTrue(Identification::fromHistoryRow(['connection_type' => 'browser_vpn_proxy'])->detection_flags->vpn);
self::assertFalse(Identification::fromHistoryRow(['connection_type' => 'vpn'])->detection_flags->vpn);
}

public function testSiteDomainWinsOverTheRequestHost(): void
Expand All @@ -126,9 +126,9 @@ public function testSiteDomainWinsOverTheRequestHost(): void

public function testFlagTruthinessFollowsJson(): void
{
$identification = Identification::fromHistoryRow(['is_vpn' => 'false', 'is_tor' => 0, 'is_proxy' => '', 'is_abuser' => 1, 'is_datacenter' => null]);
$identification = Identification::fromHistoryRow(['is_vpn' => 'false', 'is_tor' => 0, 'is_proxy' => '', 'is_incognito' => 1, 'is_datacenter' => null]);

self::assertSame(['vpn', 'abuser'], $identification->detection_flags->active());
self::assertSame(['vpn', 'incognito'], $identification->detection_flags->active());
}

public function testToleratesWrongTypes(): void
Expand Down
6 changes: 3 additions & 3 deletions tests/Unit/WebhookEventTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -49,12 +49,12 @@ public function testParsesTheScoredEventFromTheExactBytes(): void

self::assertInstanceOf(IdentificationScoredEvent::class, $event);
self::assertSame('identification.scored', $event->event_type);
self::assertSame('2026-06-01', $event->schema_version);
self::assertSame('2026-10-13', $event->schema_version);
self::assertSame('2026-09-30T12:34:57.482913+00:00', $event->created_at?->format('Y-m-d\TH:i:s.uP'));
self::assertSame(self::expected('webhook_scored'), $event->data->toArray());
self::assertSame('https://shop.example.com/signup?utm_source=google&utm_medium=cpc&gclid=abc123', $event->data->traffic_source->landing_url);
self::assertSame(RiskBand::Dangerous, $event->data->riskBand());
self::assertSame(['proxy', 'datacenter_ip', 'anti_detect_browser', 'ip_mismatch', 'suspicious_paid_click'], $event->data->detection_flags->active());
self::assertSame(['proxy', 'ip_mismatch', 'anti_detect_browser'], $event->data->detection_flags->active());
self::assertIsArray($event->raw['data']);
self::assertSame($event->raw['data'], $event->data->raw);
}
Expand Down Expand Up @@ -113,7 +113,7 @@ public function testParsesTheDashboardTestDeliveryWithSeventeenFlags(): void
$body = Fixtures::raw('webhook-test-delivery.json');
$decoded = Fixtures::json('webhook-test-delivery.json');
\assert(\is_array($decoded['data']) && \is_array($decoded['data']['detection_flags']));
self::assertCount(17, $decoded['data']['detection_flags']);
self::assertCount(20, $decoded['data']['detection_flags']);

$event = Webhook::constructEvent($body, self::sign($body), self::SECRET);

Expand Down
Loading
Loading