PLATINUM DOCS

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

FieldRules
urlRequired. http(s). Must resolve to a public address.
eventsOptional. Defaults to ["*"] (all events).
secretOptional. 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.

EventFires whendata fields
sandbox.createdPOST /v1/sandboxes succeedssandbox_id, host_id, template, cpu, ram_mb, disk_gb, via ("restore" | "cold-boot")
sandbox.state_updatedA sandbox changes lifecycle statesandbox_id, state, internal_ip
sandbox.deletedA sandbox is deletedsandbox_id, host_id
template.state_updatedAn async template build finishestemplate_id, state (ready | failed), image
org.usage_alertOrg spend crosses a configured alert thresholdspend_micros, alert_micros
org.usage_limit_reachedOrg spend crosses 50 / 80 / 90 / 100 % of its hard limit, once per threshold per periodpct, 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.

FieldMeaning
tsWhen this attempt was sent. Each retry gets a new ts.
delivery_idOne 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:

  1. Reject a delivery whose ts is more than 5 minutes from your clock.
  2. Reject a delivery_id you 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 True

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.

Attempt123456
Delayimmediate30 s2 min10 min1 h6 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.