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:

  1. Open a case for a URL, with an Idempotency-Key so a retry is safe.
  2. Read the case back to check its status.
  3. Receive takedown.status_changed webhooks 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

bash
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"
  1. To check your webhook receiver by hand, sign a body the same way we do and send it:
bash
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.

js
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)

python
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.