SafeToOpenBrowser Security Docs

SafeToOpen Browser Security

Webhook and API Reference

Payloads, headers, signature verification, polling and error codes

Guide 4 of 4 · September 2026

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#

PropertyValue
Method / bodyPOST, application/json, UTF-8
User-AgentSafeToOpen-Webhook/1.0
X-STO-Eventbrowser_security.flagged_event
X-STO-Signaturet=<unix seconds>,v1=<hex> where v1 = HMAC-SHA256(secret, t + "." + body)
AuthenticationNone besides the signature. Credentials in the URL are rejected when the webhook is created.
Timeouts5 s to connect, 10 s total. Redirects are not followed.
SuccessAny 2xx response. The body is ignored.
RetriesOn 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.
OrderingDeliveries 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.
SourceRequests 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"
  }
}
FieldTypeMeaning
typestringAlways browser_security.flagged_event. A console test adds "test": true and sends event id 0 with kind test_event; ignore such payloads.
versionintegerPayload schema version, currently 1. New fields may be added without a version change; unknown fields must be ignored.
sent_atISO 8601When the payload was built (UTC).
business_idintegerYour organisation id in SafeToOpen.
event.idintegerUnique, append-only event id. Deduplicate on it. It is also the id in console links and in the review endpoint.
event.occurred_atstringUTC, YYYY-MM-DD HH:MM:SS, as reported by the browser and clamped to a sane window.
event.event_kindstringWhat happened; see section 5. Treat as an open set and map unknown kinds to a default.
event.severityenumcritical, high, medium, low, weak, info.
event.workspacestring or nullWorkspace of the member. Null means the organisation default workspace.
event.member_emailstringThe protected user.
event.urlstringThe flagged URL. Hostile data: never render as a link, never fetch automatically.
event.url_hoststringHost part of the URL.
event.url_sha256hexSHA-256 of the URL; safe to log and correlate.
event.trust_scoreinteger or null0–100, lower is worse.
event.finding_id, finding_reasonstring or nullDetection rule id and human-readable explanation.
event.brand_matchstring or nullBrand the page imitates, when detected.
event.page_title, referrer_hoststring or nullPage 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 "", 200

Node.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#

2. Export API (pull)#

2.1 Authentication and limits#

PropertyValue
HeaderAuthorization: Bearer sto_live_<48 hex>
Scoperead (default) can call the export and STIX endpoints. read_write (“Allow write-back” in the console) can also call review and action.
Workspace scopeA token restricted to workspaces only sees those workspaces’ events. Org-level resources (audit export, URL actions) require an unscoped token.
IP allowlistOptional per token; exact IPv4/IPv6 or IPv4 CIDR, evaluated on the connecting address only.
Rate limit240 requests per hour per token (actions additionally 60 per hour). HTTP 429 when exceeded.
RetentionEvents are exported for 365 days after they occurred.

2.2 GET /integrations/events#

ParameterValuesNotes
since_idinteger, default 0Return events with id greater than this. Persist next_since_id from each response and send it back.
limit1–500, default 200Page size.
typeevents (default) or auditaudit returns the administrative audit trail; unscoped tokens only, JSON only.
formatjson (default), ocsf, ecs, cefSchema of each item (events only).
workspaceworkspace nameFilter; must be inside the token scope.
severityone severity valueFilter.

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"]:
        break

Schema variants#

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#

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 kindMeaning
threat_blockedA known-malicious page was blocked before it loaded.
malicious_fqdn_visitA member visited a host classified as malicious.
malicious_keyedA member typed into a form on a malicious page (credential theft in progress).
malicious_submissionData was submitted to a malicious page.
suspicious_keyedA member typed into a form on a page flagged as suspicious.
benign_url_becomes_maliciousA page that was allowed turned malicious after loading.
USER_IGNORED_MALICIOUSThe member dismissed a malicious warning and continued.
USER_TRUSTED_MALICIOUS, USER_TRUSTED_AND_REPORTED_MALICIOUSThe member marked a malicious page as trusted (and optionally reported it).
USER_TRUSTED_SUSPICIOUS, USER_TRUSTED_AND_REPORTED_SUSPICIOUSThe member marked a suspicious page as trusted.
test_eventConsole 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#

HTTPBody `error`Meaning
401missing_or_malformed_token, invalid_tokenHeader absent, malformed, or the token was revoked or rotated.
403ip_not_allowedConnecting IP not in the token’s allowlist.
403read_only_tokenWrite-back endpoint called with a read token.
403workspace_outside_token_scopeworkspace filter outside the token scope.
403audit_requires_unscoped_token, org_level_action_requires_unscoped_tokenOrg-level resource requested with a scoped token.
404not_foundUnknown event, or outside the token scope (review).
409extension_not_linkedOrganisation has no linked extension backend (action).
422validation messageMissing or invalid field, for example a URL without http(s).
429rate_limitedPer-token limit exceeded.
502messageThe upstream URL service did not accept the entry (action).
503integration_not_available, workspace_scoping_unavailableTemporary; retry later.

7. Security notes#