Webhooks
Signed HTTP events for sandbox, template, and billing changes.
Webhooks push org events to your endpoint as signed HTTP POST requests. Use them to react to sandbox lifecycle, template builds, and billing alerts without polling. Manage webhooks in the dashboard or with the API.
Configure a webhook
| Field | Rules |
|---|---|
url | Required. http(s). Must resolve to a public address. |
events | Optional. Defaults to ["*"] (all events). |
secret | Optional. Your own secret, minimum 16 characters. Generated if omitted. |
An org can have a maximum of 20 webhooks. The API rejects the 21st create.
The API rejects URLs that resolve to loopback, link-local, or private addresses with 400. To test a local server, expose it through a public tunnel URL.
Create a webhook
The create response is the only place the signing secret appears. Save secret_returned_once now. No later call returns it.
const wh = await client.webhooks.create({
url: "https://your.app/hooks/platinum",
events: ["sandbox.created", "sandbox.deleted"],
});
const secret = wh.secret_returned_once;wh = client.webhooks.create(
"https://your.app/hooks/platinum",
events=["sandbox.created", "sandbox.deleted"],
)
secret = wh["secret_returned_once"]pt webhook create --url https://your.app/hooks/platinum \
--events sandbox.created,sandbox.deleted# REST route: see the API reference (/docs/api)Choose events
Subscribe with exact event names or ["*"]. The API rejects patterns like "sandbox.*" with 400.
| Event | Fires when | data fields |
|---|---|---|
sandbox.created | POST /v1/sandboxes succeeds | sandbox_id, host_id, template, cpu, ram_mb, disk_gb, via ("restore" | "cold-boot") |
sandbox.state_updated | A sandbox changes lifecycle state | sandbox_id, state, internal_ip |
sandbox.deleted | A sandbox is deleted | sandbox_id, host_id |
template.state_updated | An async template build finishes | template_id, state (ready | failed), image |
org.usage_alert | Org spend crosses a configured alert threshold | spend_micros, alert_micros |
org.usage_limit_reached | Org spend crosses 50 / 80 / 90 / 100 % of its hard limit, once per threshold per period | pct, spend_micros, limit_micros, blocked (true only at ≥100 %) |
The ping event comes only from the test endpoint.
Receive a delivery
Each delivery is a signed POST:
POST /hooks/platinum HTTP/1.1
Content-Type: application/json
x-pt-event: sandbox.state_updated
x-pt-delivery-webhook-id: wh_01K…
x-pt-delivery-id: whd_01K…
x-pt-signature: sha256=<hex>
{
"event": "sandbox.state_updated",
"org_id": "org_01K…",
"ts": "2026-07-17T16:24:00.000Z",
"delivery_id": "whd_01K…",
"data": { "sandbox_id": "sbx_01K…", "state": "running", "internal_ip": "10.42.1.7" }
}Every delivery has this shape. data holds the event-specific fields.
| Field | Meaning |
|---|---|
ts | When this attempt was sent. Each retry gets a new ts. |
delivery_id | One id per delivery. Every retry of a delivery carries the same id. It matches x-pt-delivery-id and the id in the deliveries list. |
New fields may be added to the envelope. Ignore fields you do not use.
Verify the signature
Compute the HMAC over the raw request bytes, not re-serialized JSON. Re-encoding changes whitespace and breaks the signature.
x-pt-signature is sha256= + hex HMAC-SHA-256(secret, raw_body):
import crypto from "node:crypto";
function verify(sig: string, rawBody: string, secret: string): boolean {
const expect = "sha256=" +
crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
return sig.length === expect.length &&
crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expect));
}import hmac, hashlib
def verify(sig: str, raw_body: bytes, secret: str) -> bool:
expect = "sha256=" + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(sig, expect)Reject replays
A valid signature proves Platinum sent the body. It does not prove the request is new. Someone who captures a delivery can send it again. After the signature check, read ts and delivery_id from the body, not the headers:
- Reject a delivery whose
tsis more than 5 minutes from your clock. - Reject a
delivery_idyou have already processed.
Keep processed ids for at least 8 hours. That covers the automatic retry schedule, so a retry of a delivery you already handled is also dropped.
const seen = new Map<string, number>(); // delivery_id -> processed at (use a shared store in production)
function accept(sig: string, rawBody: string, secret: string): boolean {
if (!verify(sig, rawBody, secret)) return false;
const { ts, delivery_id } = JSON.parse(rawBody);
if (Math.abs(Date.now() - Date.parse(ts)) > 5 * 60_000) return false;
if (seen.has(delivery_id)) return false;
seen.set(delivery_id, Date.now());
return true;
}import json, time
from datetime import datetime
seen: dict[str, float] = {} # delivery_id -> processed at (use a shared store in production)
def accept(sig: str, raw_body: bytes, secret: str) -> bool:
if not verify(sig, raw_body, secret):
return False
env = json.loads(raw_body)
sent = datetime.fromisoformat(env["ts"].replace("Z", "+00:00")).timestamp()
if abs(time.time() - sent) > 300:
return False
if env["delivery_id"] in seen:
return False
seen[env["delivery_id"]] = time.time()
return TrueHandle retries
Respond with a 2xx status within 5 seconds. Any other result counts as a failure: non-2xx, timeout, or connection error. Platinum does not follow redirects. A 3xx counts as failed.
| Attempt | 1 | 2 | 3 | 4 | 5 | 6 |
|---|---|---|---|---|---|---|
| Delay | immediate | 30 s | 2 min | 10 min | 1 h | 6 h |
After the sixth failure, Platinum marks the delivery dead_letter. Deliveries for a deleted or paused webhook dead-letter at their next retry.
Inspect and retry deliveries
Only failed and dead_letter deliveries are retryable. A manual retry resets the attempt counter and revives dead_letter rows.
await client.webhooks.deliveries(wh.id, { status: "failed" });
await client.webhooks.retryDelivery(wh.id, "whd_01K…");client.webhooks.deliveries(wh["id"], status="failed")
client.webhooks.retry_delivery(wh["id"], "whd_01K…")pt webhook deliveries wh_01K… --status failed
pt webhook retry wh_01K… whd_01K…# REST routes: see the API reference (/docs/api)deliveries lists each attempt with its status, response_code, and error.
List, update, test, delete
update with enabled: false pauses delivery and keeps the subscription. test sends a synthetic ping event through the real delivery path, with the same signing and retries. Check the webhook deliveries for the result.
await client.webhooks.list();
await client.webhooks.update(wh.id, { enabled: false });
await client.webhooks.test(wh.id);
await client.webhooks.delete(wh.id);client.webhooks.list()
client.webhooks.update(wh["id"], enabled=False)
client.webhooks.test(wh["id"])
client.webhooks.delete(wh["id"])pt webhook list
pt webhook update wh_01K… --disable
pt webhook test wh_01K…
pt webhook rm wh_01K…# REST routes: see the API reference (/docs/api)Full request and response shapes: API reference.