Webhooks
ThrustLab webhooks deliver server-side events (simulation lifecycle, sweep lifecycle, compute-unit balance, component review) to your HTTPS endpoint as signed POST requests. Delivery is at-least-once, with HMAC-SHA256 signatures, deterministic backoff retries, and a 7-day auto-disable safety net.
The full surface lives under /v1/webhook_endpoints. See the OpenAPI reference for request/response schemas. This page covers the developer-facing semantics: registration, signature verification, the event catalog, retry behavior, auto-disable, the test event, the delivery debug surface, and source IP allowlisting.
Registration
Register an endpoint via POST /v1/webhook_endpoints:
curl -X POST https://thrustlab.com/v1/webhook_endpoints \
-H "Authorization: Bearer key_..." \
-H "Content-Type: application/json" \
-d '{
"url": "https://yourapp.example.com/hooks/thrustlab",
"events": ["simulation.completed", "simulation.failed"],
"description": "Prod ingest"
}'The response includes the signing secret (whsec_<43-char-base64url>, total 49 chars) exactly once. Store it immediately — every subsequent read of this endpoint returns secret: null plus a four-character secret_hint. Lose it and you must call POST /v1/webhook_endpoints/{id}/rotate_secret for a new one (which invalidates the old one immediately).
To subscribe to every event type, pass ["*"]. Otherwise pass an exact list; unknown event types are rejected at create-time.
URL constraints: HTTPS only, no private/loopback/link-local IPs (SSRF guard), DNS must resolve.
Signature verification
Every delivery includes a Thrustlab-Signature header in the form t=<unix-ts>,v1=<hex-sha256>. The signed payload is f"{t}.{raw_body}" — concatenate the timestamp, a literal dot, and the raw request body bytes received off the wire.
Critical: verify against the raw body. Re-serializing the JSON before computing the HMAC (e.g.
json.dumps(json.loads(body))) is the #1 webhook signature bug — sort order, whitespace, and Unicode escaping will not match what we signed.
Complete handler example (Python, FastAPI):
"""Example FastAPI webhook handler verifying incoming events."""
import os
from fastapi import FastAPI, Request, HTTPException
from thrustlab import Webhook
from thrustlab.exceptions import SignatureVerificationError
app = FastAPI()
WEBHOOK_SECRET = os.environ["THRUSTLAB_WEBHOOK_SECRET"]
@app.post("/webhooks/thrustlab")
async def thrustlab_webhook(request: Request):
payload = await request.body()
sig = request.headers.get("Thrustlab-Signature", "")
try:
event = Webhook.verify(payload, sig, WEBHOOK_SECRET)
except SignatureVerificationError as exc:
raise HTTPException(status_code=400, detail=str(exc))
if event.type == "simulation.succeeded":
sim_id = event.data["id"]
# ... do work ...
return {"received": True}
The SDK's Webhook.verify() helper handles:
- Timestamp freshness — rejects events older than 5 minutes. Replay protection.
- Constant-time comparison — uses
hmac.compare_digestinternally. Immune to timing attacks. - Raw body verification — operates on the request body bytes received off the wire, not a decoded-then-reserialized copy.
Replay protection
Each delivery additionally carries a Webhook-Id: evt_<ksuid> header and a Webhook-Attempt header (1-based attempt number — 1 on the first try, 2 on the first retry, and so on). Server-side delivery is at-least-once — a 2xx response lost mid-network results in the same evt_xxx arriving twice. Deduplicate on Webhook-Id for exactly-once processing.
Combined with the 5-minute timestamp tolerance above, this gives you full replay defense.
Event catalog
| Event type | Fired when |
|---|---|
simulation.queued | A new simulation is accepted and queued. |
simulation.running | A queued simulation has started executing on a worker. |
simulation.completed | A simulation finished successfully. |
simulation.failed | A simulation terminated with an error. |
simulation.canceled | A simulation was canceled by the user before completion. |
sweep.queued | A new sweep is accepted and its child simulations are being queued. |
sweep.running | A sweep has at least one child simulation running. |
sweep.completed | All child simulations of a sweep finished successfully. |
sweep.failed | A sweep terminated with at least one failed child. |
sweep.canceled | A sweep was canceled by the user. |
credits.low_balance | The authenticated user's compute-unit balance crossed below the low-balance threshold. |
component.submitted | A user-submitted component awaits moderator review. |
component.approved | A submitted component was approved and is now visible. |
component.rejected | A submitted component was rejected by a moderator. |
webhook.test | Synthetic test event fired by POST /v1/webhook_endpoints/{id}/test; never fired by the regular fan-out path. |
To subscribe to every present and future type, register with events: ["*"]. Otherwise the registration is exact-match — adding a new event type later requires a PATCH to the endpoint's events array.
credits.low_balance payload
The data.object for credits.low_balance is the full CreditBalance resource — the same shape returned by GET /v1/compute-units/balance. Webhook consumers can route off total or render the breakdown directly.
{
"id": "evt_2c5tQ...",
"object": "event",
"type": "credits.low_balance",
"created_at": "2026-04-25T15:42:11.123Z",
"api_version": "beta",
"data": {
"object": {
"object": "credit_balance",
"total": 95,
"breakdown": [
{"type": "free", "balance": 30, "expires_at": null},
{"type": "monthly", "balance": 65, "expires_at": "2026-05-25T00:00:00.000Z"}
],
"currency": "credit",
"low_balance_threshold": 200,
"as_of": "2026-04-25T15:42:11.123Z"
}
}
}
The event fires once per crossing (above-threshold → below-threshold). It does not re-fire for additional consumptions while already below the threshold. If a refund or grant returns the balance to at-or-above the threshold, the detector rearms and will fire again on the next crossing. Free-tier and beta users (no recurring monthly grant) do not receive this event — the threshold is null for them.
See compute units for the full balance and usage-history documentation.
Retry and backoff schedule
Each delivery gets up to 5 attempts. Backoff between attempts:
| Attempt | Delay since previous failure |
|---|---|
| 1 | immediate |
| 2 | +1 minute |
| 3 | +30 minutes |
| 4 | +2 hours |
| 5 | +24 hours |
Total wall-clock window from first attempt to permanent failure: ~26.5 hours (~27h, rounded). After attempt 5 fails the delivery transitions to permanently_failed and the consecutive-failure counter on the endpoint advances by one.
Retry triggers: HTTP 5xx, HTTP 429, and network/timeout errors (10-second connect+read timeout per attempt).
Retry skipped (terminal immediately): HTTP 404 and HTTP 410. These are treated as "this URL is gone" and burn the entire attempt budget on the spot.
Any 2xx response — body content irrelevant — marks the delivery succeeded and resets the endpoint's consecutive-failure counter.
Auto-disable
If an endpoint has no successful delivery for 7 days while at least one delivery has failed in that window, the server flips enabled=false and stamps auto_disabled_at. Pending deliveries on a disabled endpoint are skipped (not retried).
To re-enable, PATCH /v1/webhook_endpoints/{id} with {"enabled": true}. The auto_disabled_at timestamp is cleared and the consecutive-failure counter resets.
You may also receive an endpoint disabled notification email when auto-disable fires (the email task is gated on broader email infrastructure; the webhook subsystem itself disables cleanly regardless).
Test endpoint
curl -X POST https://thrustlab.com/v1/webhook_endpoints/wh_.../test \
-H "Authorization: Bearer key_..."Returns a webhook_delivery row (HTTP 202). The server fires a synthetic webhook.test event scoped to that one endpoint — bypassing the regular fan-out, so other endpoints subscribed to * will not receive it. Useful for validating signature verification, allowlist rules, and TLS terminators end-to-end.
The data.object payload is a small static body containing endpoint_id, a friendly message, and the trigger timestamp.
Delivery debug surface
Every dispatch and retry creates a row in webhook_deliveries, viewable via:
GET /v1/webhook_endpoints/{id}/deliveries— cursor-paginated list of deliveries for that endpoint, newest first. Filter bystatus(pending/succeeded/permanently_failed) and eventtype.GET /v1/webhook_endpoints/{id}/deliveries/{whd_id}— full detail:request_body(the canonical JSON we signed),response_status_code,response_body_excerpt(first 1 KB of your response),error_message,attempt_count,last_attempt_at,next_attempt_at, andmanual_retry_of(the priorwhd_if this row is a manual replay).
To replay a terminal delivery (succeeded or permanently_failed), use:
curl -X POST https://thrustlab.com/v1/webhook_endpoints/wh_.../deliveries/whd_.../retry \
-H "Authorization: Bearer key_..."
This creates a fresh delivery row with manual_retry_of pointing back at the source. The worker re-signs the original canonical body (preserved on the source row), so retries still verify even if the source event has since been pruned by the 30-day retention sweep. Pending deliveries cannot be retried (HTTP 409); wait for them to terminate.
Source IP allowlisting
All webhook deliveries originate from the ThrustLab production VPS. If your firewall does egress filtering, allow inbound HTTPS from:
5.161.233.170
This IP is stable for the foreseeable future. We will publish a deprecation notice well in advance of any change.
Forward-looking subscriptions
Webhook subscriptions are forward-looking. An endpoint only receives events created at or after the endpoint's own created_at timestamp. We do not retro-deliver historical events to newly-registered endpoints.
If you need historical events — e.g. you're rebuilding a downstream cache, or you missed a window of deliveries — pull from the events resource directly. GET /v1/events returns the same event objects that webhooks deliver, filterable by type, resource_id, and created_at range, with 30-day retention.