# Tiltify Webhooks

## When to Use This Skill

* How do I receive Tiltify webhooks?
* How do I verify the `X-Tiltify-Signature` header on a Tiltify webhook?
* Why does my Tiltify webhook signature verification fail?
* How do I handle `public:direct:donation_updated` or `public:direct:fact_updated` events?
* 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.

```javascript
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

```

```python
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/](https://github.com/hookdeck/webhook-skills/tree/main/skills/tiltify-webhooks/examples/express/), [examples/nextjs/](https://github.com/hookdeck/webhook-skills/tree/main/skills/tiltify-webhooks/examples/nextjs/), [examples/fastapi/](https://github.com/hookdeck/webhook-skills/tree/main/skills/tiltify-webhooks/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](https://github.com/hookdeck/webhook-skills/blob/main/skills/tiltify-webhooks/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_WEBHOOK_TOLERANCE_SECONDS` (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_updated` | A donation to the subscribed fact is created, moderated or refunded (public data) |
| `public:direct:fact_updated` | The subscribed fact changes, including its `amount_raised` |
| `private:direct:donation_updated` | As above, plus sensitive fields such as `donor_email` (Developer Dashboard only) |
| `private:direct:fact_updated` | As `public:direct:fact_updated`, with private data |
| `public:indirect:donation_updated` | A donation to a child of the subscribed fact |
| `public:indirect:fact_updated` | A child of the subscribed fact changes |
| `private:indirect:donation_updated` | Indirect donation update with private data |
| `private:indirect:fact_updated` | Indirect fact update with private data |

> **For payload notes and delivery details**, see [references/overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/tiltify-webhooks/references/overview.md) and the [Tiltify webhook docs](https://developers.tiltify.com/docs/webhooks/receiving-webhooks).

## Delivery Notes

* Method: `POST` with a JSON body (`Content-Type: application/json`).
* Return a `2xx` to 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 `2xx` for event types you don't handle, so unknown events never count as failures.
* `meta.id` identifies the event; deduplicate on it.

## Environment Variables

```bash
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

```bash
# 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](https://github.com/hookdeck/webhook-skills/blob/main/skills/tiltify-webhooks/references/overview.md) - What Tiltify webhooks are, events, payloads, delivery
* [references/setup.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/tiltify-webhooks/references/setup.md) - Create an endpoint, find the signing key, subscribe
* [references/verification.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/tiltify-webhooks/references/verification.md) - Signature details, test vector, gotchas, debugging