Async state and webhook-style integrations

Hilt’s public developer contract today is polling-first. That means the safest production pattern is:
  1. create or read the product
  2. start checkout and keep the payment_id
  3. let the buyer finish payment
  4. poll Hilt until the payment reaches a terminal state
  5. fan that final result into your own event or webhook pipeline
For most teams, that is simpler and more reliable than trying to infer commercial state directly from wallet activity.

Why polling is the durable contract

The buyer can finish the wallet step before your backend sees the final Hilt result. That is normal. The durable answer is the Hilt payment object, not:
  • a wallet popup
  • a screenshot
  • a single browser callback
Once Hilt marks a payment CONFIRMED, you can then read:
  • memberships for access state
  • receipts for proof
  • support for the human trail
Most production integrations work well with a simple worker loop:
  1. your checkout flow stores the payment_id
  2. a background worker polls GET /v1/payments/{payment_id}
  3. when the payment becomes CONFIRMED, the worker reads memberships and receipts
  4. the worker then emits an application event or calls your own webhook consumer
That keeps Hilt as the system of record while still fitting cleanly into a webhook-oriented application architecture.

Suggested polling cadence

Use a progression that is fast at the start and calmer later:
WindowSuggested cadence
First 15 seconds after checkout startsevery 2 to 3 seconds
While PENDING_CONFIRMATION is clearly activeevery 5 to 10 seconds
Once a payment is older than expectedevery 20 to 30 seconds
Stop polling when the payment is:
  • CONFIRMED
  • FAILED
If the session has expired, you may also get 410 during the active checkout flow. Treat that as terminal for that session and start a fresh one.

What your worker should persist

Persist these fields when checkout starts:
  • payment_id
  • product_id
  • slug
  • your own buyer reference if you already know it
  • your own job timestamp
Then persist these when they appear:
  • tx_signature
  • membership_id
  • receipt_id
That gives you a clean path from checkout start to post-payment automation.

A good worker state machine

Hilt stateMeaningWorker action
PENDING_SIGNATUREBuyer session exists but the signature has not been completed yetkeep the payment open and continue polling
PENDING_CONFIRMATIONHilt is waiting for final settlementcontinue polling with the same payment_id
CONFIRMEDHilt has accepted the payment as finalfetch membership and receipt data, then emit your own event
FAILEDThe payment will not complete successfullystop polling and mark the job failed

Fan confirmed payments into your own webhook consumers

If your platform already relies on webhooks, let Hilt be the source and your application be the broadcaster. Example event payload:
{
  "type": "hilt.payment.confirmed",
  "payment_id": "f0f4e620-1ca3-4fc8-b0ba-2d04342fe467",
  "product_id": "ae9673c8-95db-4b39-bc2c-b5e6d5dfd9d3",
  "membership_id": "0ce94832-4da4-4f47-a7df-9505817d7022",
  "receipt_id": "51b69947-0f0b-4a17-9170-229661",
  "status": "CONFIRMED"
}
That lets the rest of your system stay webhook-driven without asking Hilt to be anything other than the payment and proof source of truth.

TypeScript worker example

type Payment = {
  id: string;
  status: "PENDING_SIGNATURE" | "PENDING_CONFIRMATION" | "CONFIRMED" | "FAILED";
  tx_signature?: string | null;
  membership_id?: string | null;
};

async function waitForTerminalPayment(paymentId: string): Promise<Payment> {
  for (let attempt = 0; attempt < 30; attempt += 1) {
    const response = await fetch(`https://api.hilt.so/v1/payments/${paymentId}`);
    if (!response.ok) {
      throw new Error(`Unable to read payment ${paymentId}: ${response.status}`);
    }

    const payment = (await response.json()) as Payment;
    if (payment.status === "CONFIRMED" || payment.status === "FAILED") {
      return payment;
    }

    const delayMs = payment.status === "PENDING_SIGNATURE" ? 2500 : 7000;
    await new Promise((resolve) => setTimeout(resolve, delayMs));
  }

  throw new Error(`Payment ${paymentId} did not reach a terminal state in time`);
}

Python worker example

import time
import requests

def wait_for_terminal_payment(payment_id: str):
    for _ in range(30):
        response = requests.get(
            f"https://api.hilt.so/v1/payments/{payment_id}",
            timeout=20,
        )
        response.raise_for_status()
        payment = response.json()

        if payment["status"] in {"CONFIRMED", "FAILED"}:
            return payment

        time.sleep(2.5 if payment["status"] == "PENDING_SIGNATURE" else 7)

    raise RuntimeError(f"Payment {payment_id} did not settle in time")

What to do after CONFIRMED

Once the worker gets a confirmed payment:
  1. fetch the membership if access should exist
  2. fetch the receipt if proof should exist
  3. emit your own application event or call your own webhook consumer
  4. open or continue a support ticket only if the flow still needs human help
That sequence keeps automation clean and keeps support trails attached only to real exceptions.

Good practices for webhook-oriented teams

  • always key background jobs by payment_id
  • treat repeated reads as safe
  • deduplicate your own emitted events by payment_id
  • do not create a second payment while the first is still pending
  • do not grant access until Hilt shows CONFIRMED

A practical rule

Think of Hilt as the settlement and proof layer. If your product needs webhooks, let your own application publish them after Hilt has reached a stable answer.