Webhooks
Admins add webhooks under Webhooks. StuckBot sends a signed POST for each event you pick. Webhook URLs must use https and a public address (see below).
Events
ticket.created, ticket.claimed, ticket.released, ticket.state_changed, ticket.evidence_added, ticket.handed_off, ticket.comment_added, agent.handoff_requested, agent.stop_requested, verification.requested, verification.decided.
Body
{
"id": "evt_1842",
"type": "ticket.state_changed",
"created_at": "2026-10-17T14:03:11Z",
"org_id": "…",
"data": {"ticket_id": "…", "ticket_ref": "SB-42", "from_state": "working", "to_state": "blocker_pending_verification", "actor": {"type": "agent", "id": "…"}}
}Each request also has these headers:
- StuckBot-Event
- The event type, the same as
typein the body. - StuckBot-Delivery
- The id of this delivery. Retries of a delivery keep it. A replay from the dashboard is a new delivery with a new id.
- StuckBot-Signature
- The signature, described below.
The id in the body is the event id. It stays the same across retries and replays, so use it to skip duplicates.
Events are not guaranteed to arrive in order. Each delivery is retried on its own, so a retried event can arrive after newer ones. If order matters, fetch the ticket and act on its current state.
Checking the signature
Each request has a StuckBot-Signature header like t=1792245791,v1=5f2c…. Compute an HMAC-SHA256 of t + "." + raw body with your webhook secret and compare it to v1 in constant time. Every attempt, retries included, is signed with the time it was sent, so you can reject requests where t is more than five minutes old.
import hmac, hashlib, time
def verify(secret, header, body):
try:
parts = dict(p.strip().split("=", 1) for p in header.split(","))
t = int(parts["t"])
sent = parts["v1"]
except (KeyError, ValueError):
return False
if abs(time.time() - t) > 300:
return False
mac = hmac.new(secret.encode(), f"{t}.".encode() + body, hashlib.sha256).hexdigest()
return hmac.compare_digest(mac, sent)Retries
Answer with any 2xx within 10 seconds. The answer body is ignored. Other answers, timeouts and connection errors are retried after 10 seconds, 30 seconds, 1, 2, 5, 10 and 30 minutes, then every hour, until 24 hours have passed since the first attempt. Then the delivery is marked dead. Redirects are not followed. The dashboard shows every delivery and can replay one. Deliveries that arrived or died are kept for 30 days.
Turning a webhook off or deleting it
While a webhook is off, new events are not queued for it. Retries that were still pending are dropped at their next attempt and marked dead, and turning the webhook back on does not resume them. Replay them from the dashboard if you need them. Deleting a webhook deletes its pending retries and its delivery history.
Which addresses are allowed
The URL must use https, carry no username or password, and name a host that resolves only to public addresses. Names ending in .local, .internal, .lan, .localhost, .home, .corp or .intranet are refused. So are private, loopback, link-local and other reserved addresses, including:
- Shared address space,
100.64.0.0/10, and the benchmarking range,198.18.0.0/15. - IPv6 unique local addresses,
fc00::/7, and the old site-local range,fec0::/10. - NAT64 (
64:ff9b::) and 6to4 (2002::/16) addresses, which can wrap a private IPv4 address.
The address is checked when you add the webhook and again before every attempt, and the request goes to the address that was checked, so a later DNS change cannot point it at a private network.