Webhooks

Hilt supports native outbound webhooks. Use them as the main system-to-system integration path for payment settlement, membership changes, receipt creation, delivery failures, and support events. Polling GET /v1/payments/{payment_id} still works and remains useful during a live buyer session, but you no longer need to build your own relay just to make the rest of your application event-driven.

Quickstart

  1. create a webhook endpoint in Hilt
  2. copy the signing secret once and store it safely
  3. subscribe the endpoint to the event types you actually need
  4. verify the Hilt signature against the raw request body
  5. deduplicate by Hilt event id
  6. treat the webhook payload as the operational snapshot for your app
Endpoint management currently uses an authenticated Hilt workspace session. In practice that means:
  • register endpoints from the Hilt workspace or the session-authenticated webhook APIs
  • use API keys for your own server-to-server product, payment, membership, and support calls
Zapier REST Hooks use the narrower integration endpoint under /v1/integrations/zapier/hooks so the Zapier app can subscribe and unsubscribe its own hook URLs with a read + execute API key. Use that route only for Zapier-owned hooks.

Create an endpoint

curl -X POST https://api.hilt.so/v1/webhooks/endpoints \
  -H "Authorization: Bearer JWT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "label": "Production receiver",
    "url": "https://example.com/hilt/webhooks",
    "subscribed_events": [
      "payment.confirmed",
      "payment.failed",
      "receipt.created",
      "membership.activated"
    ],
    "product_ids": []
  }'
The response includes:
  • the endpoint record
  • the signing secret for that endpoint
Store the signing secret immediately. Hilt only returns the full secret when the endpoint is created or explicitly rotated.

Send a signed test event

Once the endpoint exists, send a signed test event before you rely on it for real settlement or access automation.
curl -X POST https://api.hilt.so/v1/webhooks/endpoints/WEBHOOK_ENDPOINT_ID/test \
  -H "Authorization: Bearer JWT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "event_type": "payment.confirmed"
  }'
Use test delivery when you want to confirm:
  • your endpoint is reachable
  • signature verification is wired correctly
  • your handler is idempotent
  • your own logging and alerting capture the Hilt event cleanly

Smallest possible receiver: Node

import crypto from "node:crypto";
import express from "express";

const app = express();
const signingSecret = process.env.HILT_WEBHOOK_SECRET!;

app.post("/hilt/webhooks", express.raw({ type: "application/json" }), (req, res) => {
  const rawBody = req.body as Buffer;
  const signatureHeader = req.header("X-Hilt-Signature") ?? "";

  const parts = Object.fromEntries(
    signatureHeader.split(",").map((part) => {
      const [key, value] = part.split("=", 2);
      return [key, value];
    }),
  );

  if (!parts.t || !parts.v1) {
    return res.status(400).json({ error: "malformed signature header" });
  }

  const expected = crypto
    .createHmac("sha256", signingSecret)
    .update(`${parts.t}.${rawBody.toString("utf8")}`)
    .digest("hex");

  const valid = crypto.timingSafeEqual(
    Buffer.from(expected, "utf8"),
    Buffer.from(parts.v1 ?? "", "utf8"),
  );

  if (!valid) {
    return res.status(400).json({ error: "invalid signature" });
  }

  const event = JSON.parse(rawBody.toString("utf8"));
  console.log(event.id, event.type);
  return res.status(200).json({ ok: true });
});

Smallest possible receiver: Python

import hashlib
import hmac
import json
import os

from fastapi import FastAPI, HTTPException, Request

app = FastAPI()
signing_secret = os.environ["HILT_WEBHOOK_SECRET"]


@app.post("/hilt/webhooks")
async def hilt_webhooks(request: Request):
    raw_body = await request.body()
    signature_header = request.headers.get("X-Hilt-Signature", "")

    parts = {}
    for chunk in signature_header.split(","):
        key, value = chunk.split("=", 1)
        parts[key] = value

    if not parts.get("t") or not parts.get("v1"):
        raise HTTPException(status_code=400, detail="malformed signature header")

    expected = hmac.new(
        signing_secret.encode("utf-8"),
        f"{parts['t']}.{raw_body.decode('utf-8')}".encode("utf-8"),
        hashlib.sha256,
    ).hexdigest()

    if not hmac.compare_digest(expected, parts.get("v1", "")):
        raise HTTPException(status_code=400, detail="invalid signature")

    event = json.loads(raw_body.decode("utf-8"))
    print(event["id"], event["type"])
    return {"ok": True}

Event catalog

Hilt currently emits these event types:
EventWhat it means
payment.confirmedThe payment is final in Hilt and it is safe to unlock access.
payment.failedThe payment did not complete successfully.
receipt.createdProof is available and a receipt verify URL can be used.
membership.activatedA membership or access record is active.
membership.renewedA renewal or extension was applied.
membership.entered_graceThe membership moved into grace instead of remaining fully active.
membership.expiredAccess has ended and downstream systems should treat it as inactive.
membership.reapproval_requiredA recurring membership needs operator or buyer review before the next period.
delivery.failedA Telegram, Discord, redirect, or post-payment delivery step failed.
support.ticket.createdA new support issue was opened inside the Hilt payment trail.
The most important event for most integrations is still payment.confirmed, but Hilt becomes more useful when you also consume the events around it:
  • receipt creation
  • membership activation or renewal
  • grace and expiry
  • failed delivery
  • support escalation
When you start wiring recurring access operations, the membership events are the ones that keep your own system aligned with:
  • paid-through timing
  • grace entry
  • access expiry
  • native collection or access extension completion

Payload shape

Every webhook event has:
  • id
  • type
  • api_version
  • created_at
  • livemode
  • data
Payment-linked events also include the durable ids you are likely to care about inside the payload snapshot:
  • payment.id
  • payment.product.id
  • membership.id when access exists
  • receipt.id when proof exists

Example event

{
  "id": "1886194f-0a41-4ca6-a62d-e7d7ee5db3a2",
  "type": "payment.confirmed",
  "api_version": "2026-05-04",
  "created_at": "2026-05-04T13:42:11Z",
  "livemode": true,
  "data": {
    "payment": {
      "id": "f0f4e620-1ca3-4fc8-b0ba-2d04342fe467",
      "status": "CONFIRMED",
      "amount_minor_units": 29000000,
      "token_mint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
      "tx_signature": "5B7WmR...example",
      "confirmed_at": "2026-05-04T13:42:10Z",
      "delivery_status": "SENT",
      "product": {
        "id": "ae9673c8-95db-4b39-bc2c-b5e6d5dfd9d3",
        "slug": "telegram-pro",
        "title": "Telegram Pro Membership"
      }
    },
    "membership": {
      "id": "0ce94832-4da4-4f47-a7df-9505817d7022",
      "status": "ACTIVE",
      "platform": "TELEGRAM",
      "current_period_end_at": "2026-06-03T13:42:10Z"
    },
    "receipt": {
      "id": "51b69947-0f0b-4a17-9170-229661000111",
      "verify_url": "https://api.hilt.so/v1/receipt/51b69947-0f0b-4a17-9170-229661000111/verify",
      "schema_version": "hilt-v1"
    }
  }
}

Signature verification

Each delivery includes these headers:
  • X-Hilt-Event
  • X-Hilt-Event-Id
  • X-Hilt-Delivery-Id
  • X-Hilt-Timestamp
  • X-Hilt-Signature
The signature format is:
t=<unix_timestamp>,v1=<hex_hmac_sha256>
Hilt signs:
<timestamp>.<raw_json_body>
using your endpoint signing secret.

TypeScript verification example

import crypto from "node:crypto";

export function verifyHiltWebhookSignature({
  rawBody,
  signatureHeader,
  signingSecret,
}: {
  rawBody: string;
  signatureHeader: string;
  signingSecret: string;
}) {
  const parts = Object.fromEntries(
    signatureHeader.split(",").map((part) => {
      const [key, value] = part.split("=", 2);
      return [key, value];
    }),
  );

  const timestamp = parts.t;
  const received = parts.v1;
  if (!timestamp || !received) {
    throw new Error("Malformed Hilt signature header");
  }

  const signedPayload = `${timestamp}.${rawBody}`;
  const expected = crypto
    .createHmac("sha256", signingSecret)
    .update(signedPayload)
    .digest("hex");

  const valid = crypto.timingSafeEqual(
    Buffer.from(expected, "utf8"),
    Buffer.from(received, "utf8"),
  );

  if (!valid) {
    throw new Error("Invalid Hilt webhook signature");
  }
}

Python verification example

import hashlib
import hmac

def verify_hilt_signature(raw_body: str, signature_header: str, signing_secret: str) -> None:
    parts = {}
    for chunk in signature_header.split(","):
        key, value = chunk.split("=", 1)
        parts[key] = value

    timestamp = parts.get("t")
    received = parts.get("v1")
    if not timestamp or not received:
        raise ValueError("Malformed Hilt signature header")

    signed_payload = f"{timestamp}.{raw_body}".encode("utf-8")
    expected = hmac.new(
        signing_secret.encode("utf-8"),
        signed_payload,
        hashlib.sha256,
    ).hexdigest()

    if not hmac.compare_digest(expected, received):
        raise ValueError("Invalid Hilt webhook signature")

Verification rules

For a production consumer:
  1. verify against the raw request body
  2. reject obviously stale timestamps
  3. store and deduplicate by event id
  4. return 2xx only when your handler has actually finished

Delivery behaviour

Hilt currently delivers webhooks as:
  • signed HTTP POST
  • JSON payloads
  • one endpoint per URL and signing secret
  • exact event subscriptions per endpoint
  • optional product filters per endpoint
Hilt attempts delivery immediately, then retries on a staged schedule before moving the delivery into dead-letter state. The current retry cadence is:
AttemptDelay
Initial deliveryimmediate
Retry 130 seconds
Retry 22 minutes
Retry 310 minutes
Retry 430 minutes
Retry 52 hours
Only 2xx responses count as a successful delivery. Everything else is treated as a failure and is retried until the schedule is exhausted.

Idempotency and ordering

Treat Hilt webhook deliveries as notifications from the source of truth, not as permission to skip idempotency on your side. Recommended pattern:
  1. verify the signature
  2. persist the Hilt event id
  3. ignore duplicate event IDs
  4. process the payload snapshot
  5. read back from Hilt if your downstream action needs extra confirmation
The most important durable correlation id is still payment_id, but deduplication should happen on the webhook event id. Do not assume event arrival order alone is enough to keep your own system safe.

Endpoint filters and delivery logs

Hilt currently supports:
  • endpoint list and status
  • exact event subscriptions
  • optional product filters
  • delivery history with attempt counts and response codes
  • signed test events from the same endpoint record
  • event timelines tied back to payment or membership context
  • self-serve re-send controls for retry-scheduled and dead-letter deliveries from the merchant dashboard
  • signing-secret rotation
Re-send webhook is now visible to merchants inside Dashboard -> Advanced, where recent delivery logs show self-serve re-send controls for deliveries that are stuck in retry windows or have fallen into dead letter.

Event timeline

Hilt now keeps an event timeline you can inspect by payment_id or membership_id. That timeline is useful when you need to answer:
  • did Hilt emit the event you expected
  • which webhook deliveries succeeded or failed around that payment
  • whether a delivery failure happened before or after membership activation
  • whether support or delivery rescue actions followed the payment
The practical debugging loop is:
  1. find the payment_id or membership_id
  2. inspect the Hilt event timeline
  3. inspect the endpoint delivery rows
  4. re-send the webhook only if the downstream system is now ready to consume the same event safely

Merchant delivery-log workflow

For merchants and support operators, the strongest delivery-log flow is:
  1. filter the log to one endpoint
  2. open the event timeline for the affected payment or membership
  3. check whether the issue is Hilt delivery, downstream processing, or both
  4. re-send the webhook only after the consumer has been fixed
That is much safer than retrying blindly and hoping a duplicate side effect does not happen downstream.

Migration from polling

If you already built a polling-first integration, do not throw it away. The clean migration is:
  1. keep the same payment_id correlation model
  2. add webhook consumers for longer-running automation
  3. keep polling only where the live buyer session still benefits from immediate progress updates
That means:
  • access grants should move to payment.confirmed
  • receipt workflows should move to receipt.created
  • member automation should move to membership lifecycle events
  • support notifications should move to support events

When polling still makes sense

Polling is still useful when:
  • the buyer is actively waiting on a checkout screen
  • you want a quick progress loop while the transaction is still settling
  • you are debugging a live integration
The practical rule is simple:
  • use webhooks as the longer-running system-to-system integration layer
  • use polling only where the live buyer experience genuinely benefits from it

Common questions

What Hilt events should I subscribe to first?

Start with payment.confirmed, payment.failed, receipt.created, and the membership events that match your product. Add delivery and support events once your operations workflow is ready to act on them.

How do I verify a Hilt webhook?

Verify the X-Hilt-Signature header against the raw request body using the endpoint signing secret, reject stale timestamps, and deduplicate by Hilt event id before triggering downstream work.

Are webhooks a replacement for payment polling?

Use webhooks for longer-running backend automation. Keep payment polling only inside active buyer experiences where the user is waiting for immediate progress.