Skip to content
Indiana
Dashboard
Docs
Browse docs / Notifications and webhooks

Notifications and webhooks

Receive signed purchase and usage notifications, verify signatures, and answer Funnel external task callbacks.

API v1Updated

Set up a receiver

  • Open Developers → App notifications in Console (/workspace/developers?tab=notifications) and select the App and Sandbox environment.
  • Enter an HTTPS receiver URL, click Generate signing key and store the key in your backend, for example as INDIANA_NOTIFICATION_KEY.
  • Turn on Billing events for purchase notifications, then enable and save.
Receivers can currently be configured for Sandbox only; events from before enabling are not backfilled.

Event types

Notification types
typeSent when
billing.purchaseA purchase is paid and verified (requires Billing events).
usage.consumeUsage is recorded.
usage.confirmA reservation is confirmed.
usage.refundUsage is refunded.
usage.limitUsage reaches a set share of its limit (thresholds in manageBillingLimits).
usage.allowanceUsage reaches a set share of the included allowance (includedThresholds).
usage.alertUsage reaches a set quantity (absoluteThresholds).

Notification types

  • billing.purchase

    Sent when
    A purchase is paid and verified (requires Billing events).
  • usage.consume

    Sent when
    Usage is recorded.
  • usage.confirm

    Sent when
    A reservation is confirmed.
  • usage.refund

    Sent when
    Usage is refunded.
  • usage.limit

    Sent when
    Usage reaches a set share of its limit (thresholds in manageBillingLimits).
  • usage.allowance

    Sent when
    Usage reaches a set share of the included allowance (includedThresholds).
  • usage.alert

    Sent when
    Usage reaches a set quantity (absoluteThresholds).

Request format

notification.jsonJSON
{  "id": "<DELIVERY_UUID>",  "type": "billing.purchase",  "projectId": "<APP_UUID>",  "environment": "sandbox",  "customerId": "<CUSTOMER_UUID>",  "occurredAt": "2026-10-22T08:00:00.000Z",  "data": {}}
Headers
HeaderContent
X-Indiana-Event-IdThe event ID, equal to the body id and unchanged on retries.
X-Indiana-Signaturet=<Unix seconds>,v1=<HMAC-SHA256 hex>
X-Indiana-Key-IdThe signing key ID, used during key rotation.

Headers

  • X-Indiana-Event-Id

    Content
    The event ID, equal to the body id and unchanged on retries.
  • X-Indiana-Signature

    Content
    t=<Unix seconds>,v1=<HMAC-SHA256 hex>
  • X-Indiana-Key-Id

    Content
    The signing key ID, used during key rotation.
  • Return 2xx within 8 seconds; store the event first and process it asynchronously.
  • The same event can arrive more than once; deduplicate by event ID. Non-2xx responses and timeouts are retried up to 10 times.
  • Bodies are at most 64 KiB; amounts and quantities are decimal strings.

Verify the signature

Compute HMAC-SHA256 over <timestamp>.<raw body> with the Base64URL-decoded key, compare it with v1, and reject timestamps older than five minutes. Use the raw request bytes.

app/api/indiana/route.tsTypeScript
import { createHmac, timingSafeEqual } from "node:crypto"; const key = Buffer.from(process.env.INDIANA_NOTIFICATION_KEY!, "base64url");const toleranceSeconds = 300; /** rawBody must be the exact request bytes, read before any JSON parsing. */export function verifyIndianaSignature(rawBody: Buffer, header: string | null) {  const parts = Object.fromEntries(    (header ?? "").split(",").map((part) => part.split("=", 2)),  );  const timestamp = Number(parts.t);  if (!Number.isSafeInteger(timestamp) || !parts.v1) return false;  if (Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false;  const expected = createHmac("sha256", key)    .update(`${timestamp}.`)    .update(rawBody)    .digest();  const received = Buffer.from(parts.v1, "hex");  return received.length === expected.length && timingSafeEqual(received, expected);} // Your own storage: returns false when this event ID was already recorded.declare function recordOnce(eventId: string, event: unknown): Promise<boolean>; export async function POST(request: Request) {  const rawBody = Buffer.from(await request.arrayBuffer());  if (!verifyIndianaSignature(rawBody, request.headers.get("X-Indiana-Signature")))    return new Response("invalid signature", { status: 401 });  const event = JSON.parse(rawBody.toString("utf8")) as { id: string; type: string };  await recordOnce(event.id, event); // persist first, process asynchronously  return new Response(null, { status: 200 });}

Answer Funnel external tasks

A Funnel external task node sends a funnel.task request to the same receiver with the same signature. Return outcome and data in a 2xx response; data must match the node's declared outputs and stay within 8 KiB.

task.request.jsonJSON
{  "type": "funnel.task",  "eventId": "<RECEIPT_UUID>",  "runId": "<RUN_UUID>",  "nodeId": "<NODE_ID>",  "visit": 1,  "projectId": "<APP_UUID>",  "environmentId": "<ENVIRONMENT_UUID>",  "customerId": "<CUSTOMER_UUID>",  "taskKey": "check_eligibility",  "input": { "plan": "pro" }}

429 and 5xx are retried under the node settings (by default up to 4 attempts); other errors fail. Deduplicate by eventId.