SafeToOpen Browser Security
Webhook and API Reference
Payloads, headers, signature verification, polling and error codes
This reference is for engineers building a receiver, relay, SOAR playbook or SIEM collector. The base URL for every endpoint is https://plus.safetoopen.com/api. A machine-readable OpenAPI 3.0 description is published at https://plus.safetoopen.com/api/integrations/openapi.
1. Webhook deliveries (push)#
1.1 Request#
| Property | Value |
|---|---|
| Method / body | POST, application/json, UTF-8 |
User-Agent | SafeToOpen-Webhook/1.0 |
X-STO-Event | browser_security.flagged_event |
X-STO-Signature | t=<unix seconds>,v1=<hex> where v1 = HMAC-SHA256(secret, t + "." + body) |
| Authentication | None besides the signature. Credentials in the URL are rejected when the webhook is created. |
| Timeouts | 5 s to connect, 10 s total. Redirects are not followed. |
| Success | Any 2xx response. The body is ignored. |
| Retries | On non-2xx, timeout or connection error: after 1 min, 5 min, 30 min and 2 h (5 attempts in total). 20 consecutive failures disable the webhook. |
| Ordering | Deliveries are queued per event and sent by a scheduler roughly once a minute; order is not guaranteed across retries. Use event.id or occurred_at. |
| Source | Requests originate from plus.safetoopen.com. Contact support if you need the fixed egress address for a firewall rule. |
1.2 Payload#
{
"type": "browser_security.flagged_event",
"version": 1,
"sent_at": "2026-09-02T10:15:07+00:00",
"business_id": 42,
"event": {
"id": 2640,
"occurred_at": "2026-09-02 10:15:00",
"event_kind": "malicious_keyed",
"severity": "critical",
"workspace": "acme.com",
"member_email": "[email protected]",
"url": "https://login-micros0ft.example/verify",
"url_host": "login-micros0ft.example",
"url_sha256": "9f2c0e…",
"trust_score": 4,
"finding_id": "brand_lookalike",
"finding_reason": "Login page imitating Microsoft on an untrusted domain",
"brand_match": "microsoft",
"page_title": "Sign in to your account",
"referrer_host": "mail.google.com"
}
}| Field | Type | Meaning |
|---|---|---|
type | string | Always browser_security.flagged_event. A console test adds "test": true and sends event id 0 with kind test_event; ignore such payloads. |
version | integer | Payload schema version, currently 1. New fields may be added without a version change; unknown fields must be ignored. |
sent_at | ISO 8601 | When the payload was built (UTC). |
business_id | integer | Your organisation id in SafeToOpen. |
event.id | integer | Unique, append-only event id. Deduplicate on it. It is also the id in console links and in the review endpoint. |
event.occurred_at | string | UTC, YYYY-MM-DD HH:MM:SS, as reported by the browser and clamped to a sane window. |
event.event_kind | string | What happened; see section 5. Treat as an open set and map unknown kinds to a default. |
event.severity | enum | critical, high, medium, low, weak, info. |
event.workspace | string or null | Workspace of the member. Null means the organisation default workspace. |
event.member_email | string | The protected user. |
event.url | string | The flagged URL. Hostile data: never render as a link, never fetch automatically. |
event.url_host | string | Host part of the URL. |
event.url_sha256 | hex | SHA-256 of the URL; safe to log and correlate. |
event.trust_score | integer or null | 0–100, lower is worse. |
event.finding_id, finding_reason | string or null | Detection rule id and human-readable explanation. |
event.brand_match | string or null | Brand the page imitates, when detected. |
event.page_title, referrer_host | string or null | Page context. |
1.3 Verifying the signature#
Compute the HMAC over the exact bytes of the request body, prefixed by the timestamp and a dot. Compare in constant time and reject timestamps more than 5 minutes from your clock. Do not parse and re-serialise the JSON before verifying; any change in whitespace or key order breaks the signature.
Python (Flask)#
import hmac, hashlib, time, json, os
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ["STO_WEBHOOK_SECRET"].encode()
@app.post("/sto/webhook")
def sto_webhook():
raw = request.get_data() # exact bytes
parts = dict(kv.split("=", 1) for kv in request.headers.get("X-STO-Signature", "").split(",") if "=" in kv)
t, v1 = parts.get("t", ""), parts.get("v1", "")
if not t.isdigit() or abs(time.time() - int(t)) > 300:
abort(401)
expected = hmac.new(SECRET, t.encode() + b"." + raw, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, v1):
abort(401)
data = json.loads(raw)
if data.get("test"):
return "", 200
handle_event(data["event"]) # idempotent on event["id"]
return "", 200Node.js (Express)#
const crypto = require('crypto');
const express = require('express');
const app = express();
app.post('/sto/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const sig = Object.fromEntries((req.get('X-STO-Signature') || '').split(',').map(kv => kv.split('=')));
if (!/^\d+$/.test(sig.t || '') || Math.abs(Date.now() / 1000 - Number(sig.t)) > 300) return res.sendStatus(401);
const expected = crypto.createHmac('sha256', process.env.STO_WEBHOOK_SECRET)
.update(sig.t + '.').update(req.body).digest('hex');
const a = Buffer.from(expected), b = Buffer.from(String(sig.v1 || ''));
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return res.sendStatus(401);
const data = JSON.parse(req.body.toString('utf8'));
if (!data.test) handleEvent(data.event);
res.sendStatus(200);
});PowerShell (Azure Functions)#
param($Request, $TriggerMetadata)
$raw = $Request.RawBody # string of the exact body
$hdr = $Request.Headers['X-STO-Signature']
$t = ($hdr -split ',' | Where-Object { $_ -like 't=*' }) -replace '^t=', ''
$v1 = ($hdr -split ',' | Where-Object { $_ -like 'v1=*' }) -replace '^v1=', ''
$now = [int][double]::Parse((Get-Date -UFormat %s))
if (-not ($t -match '^\d+$') -or [math]::Abs($now - [int]$t) -gt 300) { Push-OutputBinding -Name Response -Value @{ StatusCode = 401 }; return }
$hmac = New-Object System.Security.Cryptography.HMACSHA256
$hmac.Key = [Text.Encoding]::UTF8.GetBytes($env:STO_WEBHOOK_SECRET)
$hash = $hmac.ComputeHash([Text.Encoding]::UTF8.GetBytes("$t.$raw"))
$expected = ($hash | ForEach-Object { $_.ToString('x2') }) -join ''
if ($expected -ne $v1.ToLower()) { Push-OutputBinding -Name Response -Value @{ StatusCode = 401 }; return }
$data = $raw | ConvertFrom-Json
if (-not $data.test) { Handle-Event $data.event }
Push-OutputBinding -Name Response -Value @{ StatusCode = 200 }The Reference relay in the Jira guide and the Scripted REST script in the ServiceNow guide show the same check in those environments.
1.4 Receiver checklist#
- Respond 2xx quickly (under 10 s) and do the work asynchronously if ticket creation is slow.
- Deduplicate on
event.id; a retried delivery carries the same payload. - Return 5xx when you want SafeToOpen to retry, 2xx when you have accepted the event even if you chose to ignore it.
- Ignore payloads with
"test": true.
2. Export API (pull)#
2.1 Authentication and limits#
| Property | Value |
|---|---|
| Header | Authorization: Bearer sto_live_<48 hex> |
| Scope | read (default) can call the export and STIX endpoints. read_write (“Allow write-back” in the console) can also call review and action. |
| Workspace scope | A token restricted to workspaces only sees those workspaces’ events. Org-level resources (audit export, URL actions) require an unscoped token. |
| IP allowlist | Optional per token; exact IPv4/IPv6 or IPv4 CIDR, evaluated on the connecting address only. |
| Rate limit | 240 requests per hour per token (actions additionally 60 per hour). HTTP 429 when exceeded. |
| Retention | Events are exported for 365 days after they occurred. |
2.2 GET /integrations/events#
| Parameter | Values | Notes |
|---|---|---|
since_id | integer, default 0 | Return events with id greater than this. Persist next_since_id from each response and send it back. |
limit | 1–500, default 200 | Page size. |
type | events (default) or audit | audit returns the administrative audit trail; unscoped tokens only, JSON only. |
format | json (default), ocsf, ecs, cef | Schema of each item (events only). |
workspace | workspace name | Filter; must be inside the token scope. |
severity | one severity value | Filter. |
Response:
{
"events": [ { "id": 2640, "type": "browser_security.flagged_event", "occurred_at": "2026-09-02 10:15:00",
"received_at": "2026-09-02 10:15:03", "event_kind": "malicious_keyed", "severity": "critical",
"workspace": "acme.com", "member_email": "[email protected]", "member_name": "Jane Doe",
"url": "https://login-micros0ft.example/verify", "url_host": "login-micros0ft.example",
"url_sha256": "9f2c0e…", "trust_score": 4, "finding_id": "brand_lookalike",
"finding_reason": "Login page imitating Microsoft on an untrusted domain",
"brand_match": "microsoft", "page_title": "Sign in to your account",
"referrer_host": "mail.google.com", "review_status": "open",
"client_ip": "203.0.113.7", "member_redacted": false } ],
"next_since_id": 2640,
"has_more": false
}Compared with the webhook payload, the export adds received_at, member_name, review_status (open, ok, issue), client_ip and member_redacted. For members redacted in the console, identity and page fields are null and member_redacted is true.
Polling loop#
#!/usr/bin/env python3
# Poll SafeToOpen every few minutes; keeps the cursor in a file. Run from cron.
import json, os, sys, urllib.request
TOKEN = os.environ["STO_TOKEN"]; STATE = os.path.expanduser("~/.sto_since_id")
since = int(open(STATE).read() or 0) if os.path.exists(STATE) else 0
while True:
req = urllib.request.Request("https://plus.safetoopen.com/api/integrations/events?since_id=%d&limit=500" % since,
headers={"Authorization": "Bearer " + TOKEN})
with urllib.request.urlopen(req, timeout=30) as resp:
page = json.load(resp)
for ev in page["events"]:
print(json.dumps(ev)) # or forward to your ticketing / SIEM
since = page["next_since_id"]
open(STATE, "w").write(str(since))
if not page["has_more"]:
breakSchema variants#
- OCSF —
format=ocsf— OCSF 1.1 Detection Finding (class_uid 2004). Severity maps toseverity_id1–5,statusis New or Resolved, the URL is inresources[0].data.url_string, SafeToOpen-specific fields inunmapped. - ECS —
format=ecs— Elastic Common Schema documents with@timestamp,event.kind: alert,event.severity10–99,url.full,user.email,labels.workspaceand asafetoopenobject. - CEF —
format=cef— each item is a string:CEF:0|SafeToOpen|BrowserSecurity|1.0|<event_kind>|<reason>|<1-10>|externalId=… request=… dhost=… suser=… cs1Label=workspace cs1=….
2.3 Audit export#
GET /integrations/events?type=audit&since_id=… returns administrative actions for compliance monitoring, each as { id, type: "browser_security.audit", occurred_at, action, actor, actor_ip, detail }. Actions include policy changes, URL list changes, member and workspace changes, analyst grants, token and webhook changes, and every review made through the API. Requires an unscoped token.
3. Write-back#
3.1 POST /integrations/review#
Marks an event reviewed from your ticketing system. Requires a token with write-back.
POST /api/integrations/review
Authorization: Bearer sto_live_…
Content-Type: application/json
{ "event_id": 2640, "status": "issue" } // "ok" = reviewed, no issue; "open" = reopen
200 { "success": true, "event_id": 2640, "status": "issue" }The review appears in the console and in the Activity log as “event reviewed via api:<token name>”. A workspace-scoped token receives 404 for events outside its scope, so scopes never leak existence.
3.2 POST /integrations/action#
Adds a URL to the organisation’s block or allow list, the same path an administrator uses in the console. Requires an unscoped write-back token; limited to 60 calls per hour.
POST /api/integrations/action
Authorization: Bearer sto_live_…
Content-Type: application/json
{ "action": "block_url", "url": "https://login-micros0ft.example/verify", "category": "phishing" }
200 { "success": true, "action": "block_url", "url": "https://login-micros0ft.example/verify" }category is free text (default soar_playbook) and appears in the console’s URL list and Activity log. allow_url works the same way.
4. Threat intelligence#
- STIX bundle —
GET /integrations/stix?since_id=&limit=returns a STIX 2.1 bundle of hostile-URL indicators for critical and high events. Response headersX-STO-Next-Since-IdandX-STO-Has-Moredrive incremental pulls. - TAXII 2.1 — Discovery URL
https://plus.safetoopen.com/api/taxii2/, one collection namedindicators. Authenticate with HTTP Basic using any username and the token as the password, or a Bearer header. MISP, OpenCTI and similar platforms subscribe directly.
5. Event kinds and severities#
Event kinds are strings up to 48 characters set by the detection engine. Map the ones below explicitly and treat anything else with a default mapping; new kinds may appear as detection improves.
| Event kind | Meaning |
|---|---|
threat_blocked | A known-malicious page was blocked before it loaded. |
malicious_fqdn_visit | A member visited a host classified as malicious. |
malicious_keyed | A member typed into a form on a malicious page (credential theft in progress). |
malicious_submission | Data was submitted to a malicious page. |
suspicious_keyed | A member typed into a form on a page flagged as suspicious. |
benign_url_becomes_malicious | A page that was allowed turned malicious after loading. |
USER_IGNORED_MALICIOUS | The member dismissed a malicious warning and continued. |
USER_TRUSTED_MALICIOUS, USER_TRUSTED_AND_REPORTED_MALICIOUS | The member marked a malicious page as trusted (and optionally reported it). |
USER_TRUSTED_SUSPICIOUS, USER_TRUSTED_AND_REPORTED_SUSPICIOUS | The member marked a suspicious page as trusted. |
test_event | Console test delivery; ignore. |
Suggested priority mapping: critical → P1, high → P2, medium → P3, low, weak, info → P4. Webhooks can be limited to high and critical in the console.
6. Error codes#
| HTTP | Body `error` | Meaning |
|---|---|---|
| 401 | missing_or_malformed_token, invalid_token | Header absent, malformed, or the token was revoked or rotated. |
| 403 | ip_not_allowed | Connecting IP not in the token’s allowlist. |
| 403 | read_only_token | Write-back endpoint called with a read token. |
| 403 | workspace_outside_token_scope | workspace filter outside the token scope. |
| 403 | audit_requires_unscoped_token, org_level_action_requires_unscoped_token | Org-level resource requested with a scoped token. |
| 404 | not_found | Unknown event, or outside the token scope (review). |
| 409 | extension_not_linked | Organisation has no linked extension backend (action). |
| 422 | validation message | Missing or invalid field, for example a URL without http(s). |
| 429 | rate_limited | Per-token limit exceeded. |
| 502 | message | The upstream URL service did not accept the entry (action). |
| 503 | integration_not_available, workspace_scoping_unavailable | Temporary; retry later. |
7. Security notes#
- Webhook destinations must be public HTTPS; SafeToOpen resolves the host, refuses private and reserved addresses, and pins the connection to the vetted IP so DNS changes cannot redirect a delivery.
- Secrets and tokens are shown once and stored hashed. Revoking a token or deleting a webhook takes effect immediately.
- Every call that changes state (review, action) and every token or webhook change is written to the Activity log attributed to
api:<token name>or the administrator. - The flagged URL is the indicator your SOC needs, which is why it is included in clear text. Handle it as hostile: defang it in tickets and never let automation open it.