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-signature: sha256=<hex>
{
"event": "sandbox.state_updated",
"org_id": "org_01K…",
"ts": "2026-07-17T16:24:00.000Z",
"data": { "sandbox_id": "sbx_01K…", "state": "running", "internal_ip": "10.42.1.7" }
}Every delivery has this shape. data holds the event-specific fields.
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)Handle 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.