Gareth Wilson Gareth Wilson

Guide to Tiltify Webhooks: Features and Best Practices

Published


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:

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

FeatureDetails
ConfigurationDeveloper Dashboard: your Application > Webhooks > Add Webhook
Endpoint URLMust be publicly reachable. localhost is not allowed
SubscriptionsPer fact, in the dashboard or through the Tiltify API
MethodPOST, Content-Type: application/json
Signature headerX-Tiltify-Signature, padded base64, no prefix
Timestamp headerX-Tiltify-Timestamp, ISO-8601 with microseconds
SchemeHMAC-SHA256, base64 digest
Signed content{X-Tiltify-Timestamp}.{raw body}
SecretSecret Webhook Signing Key, 64 hex characters, used as UTF-8 text
Payload{ "data", "meta" }
Retries12, from 2 seconds doubling to 512 seconds, then 900 seconds, about 62 minutes in all
After the last retryThe 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:

EventFires when
public:direct:donation_updatedA donation to the fact is created, moderated or refunded
public:direct:fact_updatedThe fact's details or its amount raised change
public:indirect:donation_updatedA donation to one of the fact's children changes
public:indirect:fact_updatedOne of the fact's children changes
private:direct:donation_updatedAs the public version, with private donation data
private:direct:fact_updatedAs the public version, with private data
private:indirect:donation_updatedAs the public version, with private donation data
private:indirect:fact_updatedAs 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:

{
  "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.

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:

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

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.

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 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 for free and handle Tiltify webhooks reliably in minutes.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.