# Guide to Tiltify Webhooks: Features and Best Practices

Tiltify is a fundraising platform used by streamers, creators and charities to run campaigns, often live on stream. Tiltify webhooks tell your application when a donation comes in, is moderated or refunded, or when a campaign's total changes. Those events drive on-stream donation alerts and live goal trackers, and keep a charity's own CRM in step with Tiltify.

This guide covers how Tiltify endpoints and subscriptions are set up, the event types and payloads, how deliveries are signed, and the retry schedule that can switch your endpoint off.

## What are Tiltify webhooks?

Tiltify webhooks have two parts. A **Webhook Endpoint** belongs to a Tiltify Application and holds your URL and a signing key. **Webhook Subscriptions** attach that endpoint to facts, which is Tiltify's term for the things money is raised for: campaigns, team campaigns, fundraising events and so on. An endpoint can have any number of subscriptions.

When a subscribed event happens, Tiltify sends a JSON `POST` to the endpoint. These are the headers from Tiltify's example request:

```text
Content-Type: application/json
User-Agent: Tiltify Outgoing Webhook
X-Tiltify-Timestamp: 2023-04-18T16:06:55.323352Z
X-Tiltify-Signature: FmbJdukjOp0XnyjRm0eFeG7A2SO4q8u0xZjsy36ZTzA=
X-Tiltify-Verision: v5
X-Tiltify-Endpoint: 7a03496d-2f03-4b7c-9f2e-6117ca62bb6e

```

`X-Tiltify-Signature` is a base64 HMAC-SHA256 of the timestamp, a full stop and the raw body. The version header really is spelt `X-Tiltify-Verision` in Tiltify's docs, so match that spelling if you read it.

## Tiltify webhook features

| Feature | Details |
| --- | --- |
| Configuration | Developer Dashboard: your Application > **Webhooks** > **Add Webhook** |
| Endpoint URL | Must be publicly reachable. `localhost` is not allowed |
| Subscriptions | Per fact, in the dashboard or through the Tiltify API |
| Method | `POST`, `Content-Type: application/json` |
| Signature header | `X-Tiltify-Signature`, padded base64, no prefix |
| Timestamp header | `X-Tiltify-Timestamp`, ISO-8601 with microseconds |
| Scheme | HMAC-SHA256, base64 digest |
| Signed content | `{X-Tiltify-Timestamp}.{raw body}` |
| Secret | Secret Webhook Signing Key, 64 hex characters, used as UTF-8 text |
| Payload | `{ "data", "meta" }` |
| Retries | 12, from 2 seconds doubling to 512 seconds, then 900 seconds, about 62 minutes in all |
| After the last retry | The endpoint is deactivated |

## Common events

Every event type is a string in the form `scope:locality:event`:

* **Scope** is `public`, which carries publicly available data, or `private`, which adds sensitive fields such as the donor's email and answers to reward custom questions. Private subscriptions are only available in the Developer Dashboard.
* **Locality** is `direct` for events on the subscribed fact itself, or `indirect` for events on its children, such as the campaigns supporting a team campaign or every fact supporting a fundraising event.
* **Event** is `donation_updated` or `fact_updated`.

That gives eight event types:

| Event | Fires when |
| --- | --- |
| `public:direct:donation_updated` | A donation to the fact is created, moderated or refunded |
| `public:direct:fact_updated` | The fact's details or its amount raised change |
| `public:indirect:donation_updated` | A donation to one of the fact's children changes |
| `public:indirect:fact_updated` | One of the fact's children changes |
| `private:direct:donation_updated` | As the public version, with private donation data |
| `private:direct:fact_updated` | As the public version, with private data |
| `private:indirect:donation_updated` | As the public version, with private donation data |
| `private:indirect:fact_updated` | As the public version, with private data |

`donation_updated` covers new donations as well as changes, so there is no separate "donation created" event. Here is the body from Tiltify's verification example:

```json
{
  "data": {
    "amount": { "currency": "USD", "value": "82.95" },
    "campaign_id": "a4fd5207-bd9f-4712-920a-85f8d92cf4e6",
    "completed_at": "2023-04-18T16:48:26.510702Z",
    "created_at": "2023-04-18T03:36:36.510717Z",
    "donor_comment": "Rerum quo necessitatibus voluptas provident ad molestiae ipsam.",
    "donor_name": "Jirachi",
    "fundraising_event_id": null,
    "id": "dfa25dcc-2026-4320-a5b7-5da076efeb05",
    "legacy_id": 0,
    "poll_id": null,
    "poll_option_id": null,
    "reward_id": null,
    "sustained": false,
    "target_id": null,
    "team_event_id": null
  },
  "meta": {
    "attempted_at": "2023-04-18T16:49:00.617031Z",
    "event_type": "public:direct:donation_updated",
    "generated_at": "2023-04-18T16:48:59.510758Z",
    "id": "d8768e26-1092-4f4c-a829-a2698cd19664",
    "subscription_source_id": "00000000-0000-0000-0000-000000000000",
    "subscription_source_type": "test"
  }
}

```

* `meta.event_type` is the event. Route on it.
* `meta.id` identifies the event. Use it for idempotency.
* `meta.attempted_at` matches `X-Tiltify-Timestamp`, and `meta.generated_at` is when the event was created.
* `data` is the donation or fact. Amounts are objects with a `currency` and a string `value`.

## Setting up Tiltify webhooks

1. In the Tiltify Developer Dashboard, open your Application and choose **Webhooks** in the sidebar.
2. Click **Add Webhook**. Enter the **Endpoint URL**, for example `https://example.com/webhooks/tiltify`, and an optional **Description**, which only you can see.
3. Open the endpoint's **Overview** page and copy the **Secret Webhook Signing Key**. Store it server-side, for example as `TILTIFY_WEBHOOK_SECRET`.
4. Add a Webhook Subscription for each fact you want to follow. You need the fact's ID, which the Campaign Dashboard shows under **Setup > Information**. Choose the scope, locality and events.

Tiltify won't deliver to `localhost`, so for local development you need a tunnel to a public URL.

If you use Hookdeck, create a source with the Tiltify source type, paste the signing key into it exactly as shown, and use the Hookdeck source URL as the Endpoint URL in Tiltify. The source accepts `POST` only, which is the method Tiltify uses.

## Securing Tiltify webhooks

To verify a delivery, read `X-Tiltify-Timestamp` and `X-Tiltify-Signature`. Build the signed content from the timestamp header exactly as received, a `.` and the raw request body. Compute an HMAC-SHA256 with the signing key as the key, encode it as base64 and compare it with the header in constant time.

The signing key is 64 hex characters, but Tiltify uses the string itself as the key. Don't hex-decode it. Tiltify's docs also warn: "You will need the raw string that we send in the request body, do not parse and regenerate the string, as this can change with the spacing and ordering of the string."

Tiltify's verification page uses real values throughout, so you can test against it: the signing key `13c3b68914487acd1c68d85857ee1cfc308f15510f2d8e71273ee0f8a42d9d00`, the timestamp `2023-04-18T16:49:00.617031Z` and the body above, sent exactly as Tiltify shows it on one line, give `4OSwlhTt0EcrlSQFlqgE18FOtT+EKX4qTJdJeC8oV/o=`.

Tiltify says the timestamp "should be recent to the current timestamp (within the last minute)". The check in the code below is optional and off by default, for a reason covered in the next section.

```javascript
const crypto = require('crypto');
const express = require('express');

function verifyTiltifySignature(rawBody, timestamp, signature, secret, toleranceSeconds = 0) {
  if (!timestamp || !signature || !secret) return false;
  // A SHA-256 digest is 44 characters of padded base64.
  if (!/^[A-Za-z0-9+/]{43}=$/.test(signature)) return false;

  // Optional replay check, off by default. The timestamp is ISO-8601.
  if (toleranceSeconds > 0) {
    const sentAt = Date.parse(timestamp);
    if (Number.isNaN(sentAt)) return false;
    if (Math.abs(Date.now() - sentAt) / 1000 > toleranceSeconds) return false;
  }

  // The signing key is used as a string. Do not hex-decode it.
  const expected = crypto
    .createHmac('sha256', secret)
    .update(timestamp, 'latin1')
    .update('.')
    .update(rawBody)
    .digest('base64');

  const received = Buffer.from(signature, 'utf8');
  const computed = Buffer.from(expected, 'utf8');
  return received.length === computed.length && crypto.timingSafeEqual(received, computed);
}

const app = express();

app.post('/webhooks/tiltify', express.raw({ type: '*/*' }), (req, res) => {
  const ok = verifyTiltifySignature(
    req.body,
    req.get('x-tiltify-timestamp'),
    req.get('x-tiltify-signature'),
    process.env.TILTIFY_WEBHOOK_SECRET
  );
  if (!ok) return res.sendStatus(401);

  const event = JSON.parse(req.body.toString('utf8'));
  // Deduplicate on event.meta.id, then route on event.meta.event_type.
  res.sendStatus(200);
});

```

The same check in Python:

```python
import base64
import hashlib
import hmac
import re
import time
from datetime import datetime

BASE64_SHA256 = re.compile(r"[A-Za-z0-9+/]{43}=")

def verify_tiltify_signature(raw_body: bytes, timestamp, signature, secret: str,
                             tolerance_seconds: int = 0) -> bool:
    if not timestamp or not signature or not secret:
        return False
    if not BASE64_SHA256.fullmatch(signature):
        return False
    # Optional replay check, off by default. The timestamp is ISO-8601.
    if tolerance_seconds > 0:
        try:
            sent_at = datetime.fromisoformat(timestamp.replace("Z", "+00:00")).timestamp()
        except ValueError:
            return False
        if abs(time.time() - sent_at) > tolerance_seconds:
            return False

    signed = timestamp.encode("latin-1") + b"." + raw_body
    digest = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).digest()
    expected = base64.b64encode(digest)
    return hmac.compare_digest(signature.encode("utf-8"), expected)

# In FastAPI: raw_body = await request.body()
# verify_tiltify_signature(raw_body, request.headers.get("x-tiltify-timestamp"),
#                          request.headers.get("x-tiltify-signature"),
#                          os.environ["TILTIFY_WEBHOOK_SECRET"])

```

Hookdeck's Tiltify source type checks the same signature: HMAC-SHA256 over `{X-Tiltify-Timestamp}.{body}`, base64, keyed with the signing key as a string. It applies no timestamp tolerance. A request that fails the check is marked as failed verification and is not delivered to your destinations.

> **Make Tiltify webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies the `X-Tiltify-Signature` header on every delivery, stores each donation and campaign update, and delivers it to your service with retries you control.

## Tiltify webhook limitations and pain points

### An hour of failures switches the endpoint off

**The Problem:** After an outage, no more webhooks arrive at all, even once your service is healthy again.

**Why It Happens:** Tiltify retries a failed delivery 12 times, waiting 2 seconds, then doubling to 512 seconds, then three waits of 900 seconds. That is 3,722 seconds, a little over an hour. "If your application fails to respond successfully (status code 200-299) after the 12th retry, Your Webhook Endpoint will be deactivated." A deactivated endpoint receives nothing until someone turns it back on.

**Workarounds:**

* Re-enable the endpoint with the **Active** switch in the Developer Dashboard, or through the Tiltify API with an Application Access Token that has the `webhooks:write` scope.
* Tiltify suggests calling that API endpoint every time your application boots, so a deploy always leaves the endpoint active.
* After an outage, reconcile donations and totals against the Tiltify API.

**How Hookdeck Can Help:** Hookdeck accepts each delivery as it arrives and stores it, so Tiltify sees a success even while your service is down. Hookdeck then delivers to your service on a [retry](/docs/retries) schedule you choose, and the endpoint stays active.

### A one-minute timestamp window

**The Problem:** A retried or replayed delivery is rejected by your own code, even though its signature is valid.

**Why It Happens:** Tiltify suggests rejecting timestamps more than a minute old. Each Tiltify retry is a new attempt with its own timestamp, but Hookdeck retries and replays resend the original headers, so they carry the time of the first attempt.

**Workarounds:**

* Leave the timestamp check off and deduplicate on `meta.id` instead.
* If you turn it on, choose a window longer than the longest delay you allow between an attempt and a retry or replay.

**How Hookdeck Can Help:** Hookdeck applies no timestamp tolerance, so retried and replayed deliveries still pass verification at the source.

### A secret that looks like hex

**The Problem:** Every delivery fails verification, even though the key was copied correctly.

**Why It Happens:** The Secret Webhook Signing Key is 64 hex characters, which invites code that decodes it to 32 bytes. Tiltify uses the 64-character string as the key.

**Workarounds:**

* Pass the key to the HMAC function as a string.
* Test your verifier against Tiltify's published example before you deploy it.

**How Hookdeck Can Help:** Hookdeck's Tiltify source uses the pasted key as a string, so the key handling sits in one place rather than in each service.

### Two event names for many changes

**The Problem:** A handler that only expects new donations double-counts when a donation is moderated or refunded.

**Why It Happens:** `donation_updated` fires for new donations, moderation and refunds alike, and `fact_updated` fires for any change to a fact, including every change to its total.

**Workarounds:**

* Store donations by `data.id` and update the stored record, rather than adding a new one per event.
* Read totals from the fact rather than summing donation events yourself.

**How Hookdeck Can Help:** Hookdeck [filters](/docs/filters) can route on `meta.event_type`, so donation updates and fact updates can go to different destinations.

## Best practices

### Verify against the raw body

The signature covers the exact bytes Tiltify sent. In Express, use `express.raw({ type: '*/*' })` on the webhook route. In a Next.js route handler, use `Buffer.from(await request.arrayBuffer())`. In FastAPI, read `await request.body()`. Parse JSON only after verification passes.

### Deduplicate on `meta.id`

Retries carry the same event. Store each `meta.id` once it is processed and skip repeats. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Acknowledge fast and process asynchronously

Every failure moves the endpoint closer to deactivation. Verify, persist the event, return `200`, and update alerts or totals afterwards. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

### Return `2xx` for events you ignore

If you subscribe to more than you handle, still answer with a `2xx`. An error for an unhandled event type counts as a failure like any other.

### Use private scope only where you need it

Private events carry donor emails and custom-question answers. Subscribe to them only for the services that need that data, and keep public events for anything that runs on stream.

## Conclusion

Tiltify signs each delivery with a base64 HMAC-SHA256 of the ISO timestamp and raw body, keyed with a signing key that must be used as a string. Verify against the raw bytes, deduplicate on `meta.id`, and keep your endpoint answering, because about an hour of failures leaves it deactivated until someone switches it back on.

[Hookdeck Event Gateway](https://hookdeck.com) verifies every Tiltify signature and holds each donation and campaign update until your service has processed it, so an outage on your side doesn't switch your endpoint off.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Tiltify webhooks reliably in minutes.