Webhooks
Get your application's events delivered to your own endpoint, and verify that they come from Granite
A webhook sends every application event you choose to your HTTPS endpoint — to page the on-call engineer, post to a chat, or trigger your own automation.
Add a webhook
- Open the application, switch to the Webhooks tab and click Add webhook.

- Enter the endpoint URL and, optionally, a description. Only
https://URLs on public addresses are accepted.

- Choose All events (new event types included) or Selected events, and All resources or Selected resources of the application.

- Click Add webhook and copy the signing secret — it starts with
whsec_and is shown only once.

An application can have up to 3 webhooks. Use Send test event from the webhook’s menu to check your endpoint: the result is shown right away and the test counts as a delivery.

Events of the whole application — for example, redeploying all resources at once — are sent only to webhooks set to All resources.
What your endpoint receives
A POST request with the event object as a JSON body and these headers:
| Header | Value |
|---|---|
webhook-id |
The event ID. The same on every retry — use it to ignore duplicates. |
webhook-timestamp |
Unix time of this attempt. |
webhook-signature |
v1, followed by the signature, see below. |
User-Agent |
NoVPS-Webhooks/1 |
Respond with any 2xx status within 10 seconds. Redirects are not followed and count as a failure. Deliveries are not guaranteed to arrive in order: use occurred_at to order events.
Verify the signature
Requests are signed following the Standard Webhooks specification, so any of its libraries works. The signature is the base64 HMAC-SHA256 of {webhook-id}.{webhook-timestamp}.{body}, keyed with the base64-decoded part of your secret after whsec_. Verify it on the raw body, before parsing the JSON, and reject timestamps older than 5 minutes.
Node.js
import crypto from "node:crypto";
// headers: the request headers; rawBody: the body exactly as received (string or Buffer)export function verifyWebhook(secret, headers, rawBody, toleranceSec = 300) { const id = headers["webhook-id"]; const timestamp = headers["webhook-timestamp"]; const signatures = headers["webhook-signature"] ?? ""; if (Math.abs(Date.now() / 1000 - Number(timestamp)) > toleranceSec) { throw new Error("webhook timestamp is too old"); } const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64"); const expected = crypto.createHmac("sha256", key).update(`${id}.${timestamp}.${rawBody}`).digest(); const valid = signatures.split(" ").some((entry) => { const [version, signature] = entry.split(","); const given = Buffer.from(signature ?? "", "base64"); return version === "v1" && given.length === expected.length && crypto.timingSafeEqual(given, expected); }); if (!valid) throw new Error("invalid webhook signature");}Python
import base64import hashlibimport hmacimport time
def verify_webhook(secret: str, headers, body: bytes, tolerance: int = 300) -> None: msg_id = headers["webhook-id"] timestamp = headers["webhook-timestamp"] signatures = headers.get("webhook-signature", "") if abs(time.time() - int(timestamp)) > tolerance: raise ValueError("webhook timestamp is too old") key = base64.b64decode(secret.removeprefix("whsec_")) signed = f"{msg_id}.{timestamp}.".encode() + body expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode() for entry in signatures.split(): version, _, signature = entry.partition(",") if version == "v1" and hmac.compare_digest(signature, expected): return raise ValueError("invalid webhook signature")Go
// VerifyWebhook checks a Granite webhook request. body is the raw request body.func VerifyWebhook(secret string, h http.Header, body []byte, tolerance time.Duration) error { id, timestamp := h.Get("webhook-id"), h.Get("webhook-timestamp") ts, err := strconv.ParseInt(timestamp, 10, 64) if err != nil || math.Abs(time.Since(time.Unix(ts, 0)).Seconds()) > tolerance.Seconds() { return errors.New("webhook timestamp is missing or too old") } key, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(secret, "whsec_")) if err != nil { return err } mac := hmac.New(sha256.New, key) mac.Write([]byte(id + "." + timestamp + ".")) mac.Write(body) expected := mac.Sum(nil) for _, entry := range strings.Fields(h.Get("webhook-signature")) { version, signature, _ := strings.Cut(entry, ",") given, err := base64.StdEncoding.DecodeString(signature) if err == nil && version == "v1" && hmac.Equal(given, expected) { return nil } } return errors.New("invalid webhook signature")}Retries and failures
A failed delivery — a non-2xx status, a timeout or a connection error — is retried after 30 seconds, 2 minutes, 10 minutes, 30 minutes, 1 hour and 3 hours, about 5 hours in total. Recent deliveries in the webhook’s menu shows every attempt with the response code and the first part of the response body; Redeliver sends a delivery again.

A webhook whose last 20 deliveries all failed, with no successful delivery for 3 days, is disabled automatically and marked Disabled with the reason. Fix the endpoint and switch the webhook back on.
Manage webhooks
- Edit — change the URL, the description, the events or the resources.
- Rotate signing secret — issues a new secret; the old one stops working immediately.
- Disable — the switch on the webhook card stops deliveries without deleting it.
- Delete — removes the webhook and its delivery history.
Adding and changing webhooks requires the Owner, Maintainer or Modifier role in the application (see ownership and access control); members can see webhooks and their deliveries.