Webhooks
Keep your tools in sync.
Receive a signed HTTPS request when work changes. Create a workspace webhook through the API or CLI and select its events.
todos webhook create \
--url https://example.com/hooks/todos \
--events issue.created,issue.updatedStore the returned secret securely. Never put it in client-side code.
Events
The API also accepts resource filters such as issue.*. Event bodies contain id, type, created_at, workspace, actor, and data.
Archiving or unarchiving an issue, project, or team sends its .updated event with archived_at set or cleared. A delete sends its .deleted event with the record as it was just before the delete.
Archiving an issue also archives its live sub-issues at any depth. Each sub-issue sends its own issue.updated event, with archived_with set to the issue that you archived. Unarchiving that issue restores the same sub-issues, and each one sends its event again.
Verify before you parse
The Todos-Signature header has the form t=UNIX_SECONDS,v1=HEX_DIGEST. The digest is HMAC-SHA256 over the timestamp, a period, and the exact request body. Read the raw body before JSON parsing. These examples reject timestamps more than five minutes away and use constant-time verification.
TypeScript
Pass await request.text() and the signature header to verify. Download the example.
import * as hmac from "starship/crypto/hmac";
import * as hex from "starship/encoding/hex";
import * as utf8 from "starship/encoding/utf8";
/** Verify a raw webhook body; use `await verify(secret, body, signature)`. */
export async function verify(
secret: string,
body: string,
signature: string,
now = Math.floor(Date.now() / 1000),
): Promise<boolean> {
let match = /^t=(\d+),v1=([a-f0-9]{64})$/.exec(signature);
if (!match?.[1] || !match[2]) return false;
let timestamp = Number(match[1]);
if (!Number.isSafeInteger(timestamp) || Math.abs(now - timestamp) > 300)
return false;
return hmac
.SHA256(utf8.parse(secret))
.verify(utf8.parse(`${match[1]}.${body}`), hex.parse(match[2]).buffer);
}
Python
Pass the raw request bytes and the signature header to verify. Download the example.
import hashlib
import hmac
import re
import time
def verify(secret: str, body: bytes, signature: str, now: int | None = None) -> bool:
match = re.fullmatch(r"t=(\d+),v1=([a-f0-9]{64})", signature)
if match is None:
return False
timestamp, digest = match.groups()
current = int(time.time()) if now is None else now
if abs(current - int(timestamp)) > 300:
return False
payload = timestamp.encode("ascii") + b"." + body
expected = hmac.new(secret.encode("utf-8"), payload, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, digest)
Delivery and retries
Save the event durably, then return a 2xx response within 10 seconds. Dedupe with Todos-Event-Id or the body’s id. Delivery is at least once; do not depend on order.
Failed deliveries retry after 1 minute, 5 minutes, 30 minutes, 2 hours, and 12 hours. After 20 consecutive failures, the endpoint is disabled and a webhook.disabled event is emitted.
todos webhook test WEBHOOK_ID
todos webhook deliveries WEBHOOK_IDInspect and retry a delivery through the webhook API. Redelivery keeps the event ID, so it must use the same dedupe rule.