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