Agent skill
Tiltify Webhooks Skill
Receive and verify Tiltify webhooks. Use when setting up Tiltify webhook handlers, debugging X-Tiltify-Signature verification (base64 HMAC-SHA256 over "<X-Tiltify-Timestamp>.<raw body>"), or handling donation and fact events like public:direct:donation_updated and public:direct:fact_updated.
Install this skill
npx skills add hookdeck/webhook-skills --skill tiltify-webhooks
When to Use This Skill
- How do I receive Tiltify webhooks?
- How do I verify the
X-Tiltify-Signatureheader on a Tiltify webhook? - Why does my Tiltify webhook signature verification fail?
- How do I handle
public:direct:donation_orupdated public:direct:fact_events?updated - Where do I find the Tiltify Secret Webhook Signing Key?
- Why did Tiltify deactivate my webhook endpoint?
Verification (core)
Every delivery carries these headers:
X-Tiltify-Signature: <base64 HMAC-SHA256 digest, padded, no prefix>
X-Tiltify-Timestamp: <ISO-8601, e.g. 2023-04-18T16:49:00.617031Z>
The signature is the standard base64 (padded, 44 characters) HMAC-SHA256 of <X-Tiltify-Timestamp>.<raw body>, keyed with the endpoint's Secret Webhook Signing Key as UTF-8 text (a 64-character hex string that is not hex-decoded). Build the message as bytes, never decode and re-encode the body, and don't parse the JSON before verifying. Check the signature is exactly [A-Za-z0-9+/]{43}=, compare in constant time, and respond 401 on any failure.
const signed = Buffer.concat([Buffer.from(timestamp, 'latin1'), Buffer.from('.'), rawBody]);
const expected = crypto.createHmac('sha256', Buffer.from(secret, 'utf8')).update(signed).digest('base64');
if (!/^[A-Za-z0-9+/]{43}=$/.test(signature)) return false; // validate the format first
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature)); // compare the base64 text
signed = b"".join([timestamp.encode("latin-1"), b".", raw_body])
expected = base64.b64encode(hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).digest())
if not re.fullmatch(r"[A-Za-z0-9+/]{43}=", signature): # validate the format first
return False
return hmac.compare_digest(expected, signature.encode("ascii")) # compare the base64 text
For complete handlers with tests, see examples/express/, examples/nextjs/, examples/fastapi/.
Tiltify has no official webhook SDK, so verification is manual in every framework. The examples' tests include Tiltify's published worked example (secret 13c3b689…2d9d00, signature 4OSwlhTt0EcrlSQFlqgE18FOtT+EKX4qTJdJeC8oV/o=); see references/verification.md.
Timestamp tolerance is opt-in
Tiltify's guide says the timestamp should be within a minute of now. The examples do not enforce this by default: Hookdeck retries and replays resend the original headers and timestamp, and Hookdeck itself applies no tolerance, so a fixed window would reject legitimate retries. Set TILTIFY_ (for example 300) to turn it on; unset or 0 means off. X-Tiltify-Timestamp is ISO-8601 with microseconds.
Read the raw body
Express: express.raw({ type: '*/*' }). Next.js: await request.arrayBuffer(). FastAPI: await request.body(). Parse the JSON only after the signature check.
Common Event Types
Dispatch on the body's meta.event_type. The format is <scope>:<locality>:<event>.
| Event | Triggered when |
|---|---|
public:direct:donation_ | A donation to the subscribed fact is created, moderated or refunded (public data) |
public:direct:fact_ | The subscribed fact changes, including its amount_raised |
private:direct:donation_ | As above, plus sensitive fields such as donor_email (Developer Dashboard only) |
private:direct:fact_ | As public:direct:fact_, with private data |
public:indirect:donation_ | A donation to a child of the subscribed fact |
public:indirect:fact_ | A child of the subscribed fact changes |
private:indirect:donation_ | Indirect donation update with private data |
private:indirect:fact_ | Indirect fact update with private data |
For payload notes and delivery details, see references/overview.md and the Tiltify webhook docs.
Delivery Notes
- Method:
POSTwith a JSON body (Content-Type: application/json). - Return a
2xxto acknowledge. Failures are retried 12 times (2 s doubling to 512 s, then 900 s; about an hour in all). After the 12th retry Tiltify deactivates the endpoint, and you must re-enable it in the dashboard or through the API. - Return
2xxfor event types you don't handle, so unknown events never count as failures. meta.ididentifies the event; deduplicate on it.
Environment Variables
TILTIFY_WEBHOOK_SECRET=your_tiltify_secret_webhook_signing_key
# Optional: reject timestamps older than this many seconds (unset or 0 = off)
TILTIFY_WEBHOOK_TOLERANCE_SECONDS=300
Local Development
# Start tunnel (no account needed)
npx hookdeck-cli listen 3000 tiltify --path /webhooks/tiltify
Use the printed URL as the Endpoint URL of your Tiltify webhook endpoint (Tiltify can't deliver to localhost).
Reference Materials
- references/overview.md - What Tiltify webhooks are, events, payloads, delivery
- references/setup.md - Create an endpoint, find the signing key, subscribe
- references/verification.md - Signature details, test vector, gotchas, debugging