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

Store the returned secret securely. Never put it in client-side code.

Events

issue.createdissue.updatedissue.deletedcomment.createdcomment.updatedcomment.deletedproject.createdproject.updatedproject.deletedlabel.createdlabel.updatedlabel.deletedteam.createdteam.updatedteam.deletedmember.addedmember.updatedmember.removedwebhook.disabled

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_ID

Inspect and retry a delivery through the webhook API. Redelivery keeps the event ID, so it must use the same dedupe rule.