Guide

Webhooks

Every Tidy event can be delivered to an outbound HTTPS endpoint. Payloads match the AsyncAPI spec; each delivery carries a Tidy-Signature header you MUST verify before trusting the payload.

1. Subscribe

Create a subscription with the event types you care about. The response includes a signing secret — it's shown exactly once.

curl -X POST https://qa.tidy.engineering/api/v1/event_subscriptions \
  -H "Authorization: Bearer $TIDY_PAT" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://your-server.example/tidy-webhook",
    "event_types": ["email.created", "task.updated"]
  }'

2. Receive deliveries

Tidy POSTs JSON to your URL. The Tidy-Signature header looks like:

Tidy-Signature: t=1714000000,v1=a3f9c8d2...

The HMAC input is `${t}.` + raw_body_bytes. The receiver MUST hash the exact bytes we sent — re-serializing the JSON will break the signature (key order and whitespace matter).

3. Verify the signature

These reference implementations mirror the Tidy dispatcher's signing function. Each is unit-tested against real signatures produced by the server-side code. Copy the one for your stack:

"""
Tidy webhook signature verification (Python reference implementation).

Mirrors services/event-dispatcher/src/event_dispatcher/signing.py so that a
partner who copy-pastes this file can verify real Tidy deliveries.

Header format:
    Tidy-Signature: t=<unix_seconds>,v1=<hex_hmac_sha256>

HMAC input:  f"{t}.".encode() + raw_body_bytes
Secret:      the webhook subscription's current signing secret.
             During 7-day rotation we accept both current and previous.
"""

from __future__ import annotations

import hashlib
import hmac
import time


def verify_tidy_signature(
    header_value: str,
    raw_body: bytes,
    secrets: list[str],
    *,
    now: int | None = None,
    tolerance_seconds: int = 300,
) -> bool:
    """Return True if the signature is valid.

    Parameters
    ----------
    header_value:
        The raw value of the `Tidy-Signature` HTTP header.
    raw_body:
        The exact bytes of the HTTP request body. Do NOT re-serialize JSON —
        the HMAC is over the bytes we sent, byte-for-byte.
    secrets:
        A list of signing secrets accepted for this subscription. During a
        rotation window this contains both the current and previous secret.
    now:
        Current unix time in seconds (override for tests).
    tolerance_seconds:
        Replay window. Defaults to 5 minutes.
    """
    if now is None:
        now = int(time.time())

    try:
        parts = dict(p.split("=", 1) for p in header_value.split(","))
        t = int(parts["t"])
        v1 = parts["v1"]
    except (ValueError, KeyError):
        return False

    if abs(now - t) > tolerance_seconds:
        return False

    mac_input = f"{t}.".encode("utf-8") + raw_body
    for secret in secrets:
        expected = hmac.new(
            secret.encode("utf-8"), mac_input, hashlib.sha256
        ).hexdigest()
        if hmac.compare_digest(expected, v1):
            return True

    return False


# Flask example:
#
#     from flask import request, abort
#
#     @app.post("/tidy-webhook")
#     def receive():
#         sig = request.headers.get("Tidy-Signature", "")
#         if not verify_tidy_signature(sig, request.get_data(), [SIGNING_SECRET]):
#             abort(401)
#         event = request.get_json()
#         handle(event)
#         return "", 204

Replay protection

Reject deliveries where |now - t| > 300 seconds. This kills replay attacks even if a secret leaks into your logs.

Secret rotation

Rotate your signing secret at POST /api/v1/event_subscriptions/{id}/rotate-secret. Both the old and new secret are accepted for 7 days so in-flight retries validate cleanly. Pass both secrets to your verification function during that window.

Delivery guarantees

  • At-least-once with exponential backoff. Idempotency is your responsibility — event IDs are stable, so dedupe on event.id.
  • Timeout: return a 2xx within 10 seconds. Anything else retries.
  • Permanent failure: after 24 hours of consecutive failures the subscription is paused and you receive a subscription.paused event on your in-app notifications feed.

Testing deliveries in sandbox

Sandbox workspaces are allowed to subscribe to webhook.site and to requestbin — these are on the sandbox egress allowlist so deliveries succeed. In production the egress-proxy denies webhook URLs that fail SSRF / allowlist checks.