API webhooks
Get takedown status changes pushed to your server, verify each request's signature, and handle retries.
Instead of polling GET /cases, add a webhook and we send a request to your server when a case changes status.
Add a webhook
- In the console, go to Settings > Notifications and click Add webhook in the webhook section.
- Enter a name and your endpoint's URL. It must start with
https://. - Choose Standard JSON. (The Slack, Teams and Feishu options send chat messages, not the payload below.)
- Copy the signing secret shown when the webhook is created. It is shown once; keep it with your other server secrets.
Each webhook receives all of your organization's events, including threat.detected and honeytoken.triggered; a webhook can't be limited to one type. Filter on the event field and ignore the types you don't handle. Case status changes are takedown.status_changed.
Payload
POST /your-endpoint HTTP/1.1
Content-Type: application/json
User-Agent: OpenBait-Webhook/1.0
X-Webhook-Timestamp: 1792670520
X-Webhook-Signature: sha256=5d1f0c3e...
{
"event": "takedown.status_changed",
"timestamp": "2026-10-22T12:02:00Z",
"data": {
"caseId": "d3a9m7f05n8u7r1m4r20",
"domain": "example-copy[.]shop",
"brand": "Example Brand",
"status": "down",
"url": "https://openbait.com/console/takedown/d3a9m7f05n8u7r1m4r20"
}
}| Field | Type | Description |
|---|---|---|
event | string | takedown.status_changed |
timestamp | string (RFC 3339) | When the status changed. A retry carries the same value |
data.caseId | string | The case ID: the id of the Case object |
data.domain | string | The site's domain, defanged: every . is written [.] so the address can't be clicked by accident. Replace [.] with . to get the domain back |
data.brand | string | Your brand's name in OpenBait |
data.status | string | The new status. See Case statuses |
data.url | string | The case page in the console (the same as the Case's consoleUrl) |
data.status uses the Case statuses, plus one more: awaiting_confirmation, sent when a visitor reports a site through your report page and it is waiting for you to confirm it in the console. It appears only in webhooks: a Case never has it, and GET /cases?status= doesn't accept it.
The payload is a notice, not the full record. To get the rest, call GET /cases/{caseId}.
Verify the signature
Each request is signed with your webhook's secret:
| Header | Value |
|---|---|
X-Webhook-Timestamp | The time we sent this request, in UNIX seconds |
X-Webhook-Signature | sha256= followed by the lowercase hex HMAC-SHA256 |
The signed string is the timestamp, a period, and the raw request body:
HMAC_SHA256(secret, X-Webhook-Timestamp + "." + raw body)To verify a request:
- Read the raw body bytes, before any JSON parsing. Parsing and re-serializing changes the bytes and the signature won't match.
- Reject the request if either header is missing. A webhook with a secret always sends both. A webhook created before webhook URLs had to use
httpsmay have no secret, and then its requests carry no signature headers: in Settings > Notifications, click Regenerate signing secret on it to get one. - Reject it if
X-Webhook-Timestampis more than 5 minutes away from your clock. This stops someone replaying an old request. - Compute the HMAC as above and compare it with the header using a constant-time comparison.
- Only then parse the JSON.
Node.js (Express)
import crypto from "node:crypto"
import express from "express"
const SECRET = process.env.OPENBAIT_WEBHOOK_SECRET
const TOLERANCE_SECONDS = 5 * 60
function verify(rawBody, timestamp, signature) {
if (!timestamp || !signature) return false
const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp))
if (!Number.isFinite(age) || age > TOLERANCE_SECONDS) return false
const expected =
"sha256=" +
crypto.createHmac("sha256", SECRET).update(`${timestamp}.`).update(rawBody).digest("hex")
const a = Buffer.from(expected)
const b = Buffer.from(signature)
return a.length === b.length && crypto.timingSafeEqual(a, b)
}
const app = express()
// express.raw keeps the body as the exact bytes we signed.
app.post("/openbait/webhook", express.raw({ type: "application/json" }), (req, res) => {
const ok = verify(
req.body,
req.get("X-Webhook-Timestamp"),
req.get("X-Webhook-Signature"),
)
if (!ok) return res.sendStatus(401)
const event = JSON.parse(req.body.toString("utf8"))
res.sendStatus(204) // answer first, then do the work
if (event.event === "takedown.status_changed") {
console.log(`case ${event.data.caseId} is now ${event.data.status}`)
}
})
app.listen(3000)Python (Flask)
import hashlib
import hmac
import json
import os
import time
from flask import Flask, abort, request
SECRET = os.environ["OPENBAIT_WEBHOOK_SECRET"].encode()
TOLERANCE_SECONDS = 5 * 60
app = Flask(__name__)
def verify(raw_body: bytes, timestamp: str | None, signature: str | None) -> bool:
if not timestamp or not signature:
return False
try:
age = abs(int(time.time()) - int(timestamp))
except ValueError:
return False
if age > TOLERANCE_SECONDS:
return False
mac = hmac.new(SECRET, timestamp.encode() + b"." + raw_body, hashlib.sha256)
expected = "sha256=" + mac.hexdigest()
return hmac.compare_digest(expected, signature)
@app.post("/openbait/webhook")
def openbait_webhook():
raw = request.get_data() # the exact bytes we signed
if not verify(
raw,
request.headers.get("X-Webhook-Timestamp"),
request.headers.get("X-Webhook-Signature"),
):
abort(401)
event = json.loads(raw)
if event["event"] == "takedown.status_changed":
print(f"case {event['data']['caseId']} is now {event['data']['status']}")
return "", 204Delivery and retries
- Answer with any
2xxstatus within 10 seconds. Do slow work after you answer, or in a queue. - Anything else counts as a failure: a non-
2xxstatus, a timeout, or a connection error. - A failed request is tried 5 times in all: the first send, then retries about 1 minute, 15 minutes, 1 hour and 6 hours after each failure. Retries go out on a 10-minute schedule, so each can arrive up to 10 minutes later than that. After the fifth failure the event is dropped.
- A retry sends the same body, with a new
X-Webhook-Timestampand signature. So the 5-minute check works on retries too. - If you switch the webhook off, its pending retries stop.
- Delivery is at least once, but not guaranteed: an event is dropped after the fifth failure. If you can't miss a change, also call
GET /casesfrom time to time and compare. - The same event can arrive more than once (for example, when your server did the work but answered too slowly). Make your handler idempotent:
caseId+status+timestampidentifies one event. - Events can arrive out of order after a retry. If order matters, compare
timestamp, or callGET /cases/{caseId}for the current status.
In Settings > Notifications, each webhook shows whether its last delivery failed and why. Its test button sends a sample event (webhook.test) so you can check your endpoint.
Case status changes are sent only while Findings and takedown progress is switched on on the same page. It is on by default.