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

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

EventTriggered when
public:direct:donation_updatedA donation to the subscribed fact is created, moderated or refunded (public data)
public:direct:fact_updatedThe subscribed fact changes, including its amount_raised
private:direct:donation_updatedAs above, plus sensitive fields such as donor_email (Developer Dashboard only)
private:direct:fact_updatedAs public:direct:fact_updated, with private data
public:indirect:donation_updatedA donation to a child of the subscribed fact
public:indirect:fact_updatedA child of the subscribed fact changes
private:indirect:donation_updatedIndirect donation update with private data
private:indirect:fact_updatedIndirect fact update with private data

For payload notes and delivery details, see references/overview.md and the Tiltify webhook docs.

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

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


Repository

hookdeck/webhook-skills

v0.1.0 · MIT · Updated Nov 10, 2026

View on GitHub →