API examples
The whole flow in curl, Node and Python - open a case, check its status, and receive and verify the webhook.
Each example does the same three things:
- Open a case for a URL, with an
Idempotency-Keyso a retry is safe. - Read the case back to check its status.
- Receive
takedown.status_changedwebhooks and verify their signature.
They read the key from OPENBAIT_API_KEY and the webhook secret from OPENBAIT_WEBHOOK_SECRET. See Authentication and Webhooks for where to get them.
curl
BASE=https://openbait.com/api/v1/ext
# 1. Open a case
curl -sS -X POST "$BASE/cases" \
-H "Authorization: Bearer $OPENBAIT_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: ticket-4521" \
-d '{"url": "https://example-copy.shop/checkout", "note": "Ticket 4521"}'
# => 201 {"id":"d3a9m7f05n8u7r1m4r20", "status":"opened", ...}
# 2. Check its status
curl -sS "$BASE/cases/d3a9m7f05n8u7r1m4r20" \
-H "Authorization: Bearer $OPENBAIT_API_KEY"
# Every case that has gone offline, 50 at a time
curl -sS "$BASE/cases?status=down&limit=50" \
-H "Authorization: Bearer $OPENBAIT_API_KEY"- To check your webhook receiver by hand, sign a body the same way we do and send it:
BODY='{"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"}}'
TS=$(date +%s)
SIG=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$OPENBAIT_WEBHOOK_SECRET" -hex | sed 's/^.* //')
curl -sS -X POST https://your-server.example/openbait/webhook \
-H "Content-Type: application/json" \
-H "X-Webhook-Timestamp: $TS" \
-H "X-Webhook-Signature: sha256=$SIG" \
-d "$BODY"Node.js (fetch)
Node 18 or later; fetch is built in.
import crypto from "node:crypto"
import express from "express"
const BASE = "https://openbait.com/api/v1/ext"
const KEY = process.env.OPENBAIT_API_KEY
const WEBHOOK_SECRET = process.env.OPENBAIT_WEBHOOK_SECRET
async function api(path, init = {}) {
const res = await fetch(BASE + path, {
...init,
headers: {
Authorization: `Bearer ${KEY}`,
"Content-Type": "application/json",
...init.headers,
},
})
// Check the status and Content-Type first: a 504 has no JSON body.
const isJSON = (res.headers.get("Content-Type") || "").includes("application/json")
const body = isJSON ? await res.json() : null
if (!res.ok) {
const err = new Error(body?.error?.message ?? `HTTP ${res.status}`)
err.code = body?.error?.code // e.g. "quota_exhausted"; undefined for a 504
err.status = res.status
err.retryAfter = res.headers.get("Retry-After")
throw err
}
return body
}
// 1. Open a case. Reuse the same Idempotency-Key when you retry.
export async function openCase(url, ticketId) {
return api("/cases", {
method: "POST",
headers: { "Idempotency-Key": `ticket-${ticketId}` },
body: JSON.stringify({ url, note: `Ticket ${ticketId}` }),
})
}
// 2. Check its status.
export async function getCase(id) {
return api(`/cases/${encodeURIComponent(id)}`)
}
// 3. Receive and verify webhooks.
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 > 300) return false
const expected =
"sha256=" +
crypto.createHmac("sha256", WEBHOOK_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()
app.post("/openbait/webhook", express.raw({ type: "application/json" }), (req, res) => {
if (!verify(req.body, req.get("X-Webhook-Timestamp"), req.get("X-Webhook-Signature"))) {
return res.sendStatus(401)
}
const event = JSON.parse(req.body.toString("utf8"))
res.sendStatus(204)
if (event.event === "takedown.status_changed") {
console.log(`case ${event.data.caseId} is now ${event.data.status}`)
}
})
app.listen(3000)
const created = await openCase("https://example-copy.shop/checkout", 4521)
console.log(created.id, created.status)
console.log((await getCase(created.id)).status)Python (requests)
import hashlib
import hmac
import json
import os
import time
import requests
from flask import Flask, abort, request
BASE = "https://openbait.com/api/v1/ext"
KEY = os.environ["OPENBAIT_API_KEY"]
WEBHOOK_SECRET = os.environ["OPENBAIT_WEBHOOK_SECRET"].encode()
session = requests.Session()
session.headers["Authorization"] = f"Bearer {KEY}"
class OpenBaitError(Exception):
def __init__(self, status, code, message, retry_after=None):
super().__init__(message)
self.status, self.code, self.retry_after = status, code, retry_after
def api(method, path, **kwargs):
res = session.request(method, BASE + path, timeout=30, **kwargs)
# Check the status and Content-Type first: a 504 has no JSON body.
is_json = "application/json" in res.headers.get("Content-Type", "")
body = res.json() if is_json else None
if not res.ok:
err = (body or {}).get("error", {})
raise OpenBaitError(
res.status_code,
err.get("code"), # e.g. "quota_exhausted"; None for a 504
err.get("message", f"HTTP {res.status_code}"),
res.headers.get("Retry-After"),
)
return body
# 1. Open a case. Reuse the same Idempotency-Key when you retry.
def open_case(url, ticket_id):
return api(
"POST",
"/cases",
json={"url": url, "note": f"Ticket {ticket_id}"},
headers={"Idempotency-Key": f"ticket-{ticket_id}"},
)
# 2. Check its status.
def get_case(case_id):
return api("GET", f"/cases/{case_id}")
# 3. Receive and verify webhooks.
app = Flask(__name__)
def verify(raw_body, timestamp, signature):
if not timestamp or not signature:
return False
try:
if abs(int(time.time()) - int(timestamp)) > 300:
return False
except ValueError:
return False
mac = hmac.new(WEBHOOK_SECRET, timestamp.encode() + b"." + raw_body, hashlib.sha256)
return hmac.compare_digest("sha256=" + mac.hexdigest(), signature)
@app.post("/openbait/webhook")
def openbait_webhook():
raw = request.get_data()
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 "", 204
if __name__ == "__main__":
case = open_case("https://example-copy.shop/checkout", 4521)
print(case["id"], case["status"])
print(get_case(case["id"])["status"])
app.run(port=3000)Handling the month's limit
When POST /cases answers 402 quota_exhausted, the month's takedowns are used up. Tell someone on your team: to take the site down now, report the URL in the console, then add a $199 takedown on its case page. Don't retry the call until next month; it gets the same answer.
A 201 or 200 with blocklistOnly: true means the case was added to the browser warning lists only and used none of the month's takedowns. A takedown can be added on its case page in the console.