Skip to content

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

  1. Open the application, switch to the Webhooks tab and click Add webhook.

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

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

  1. 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 base64
import hashlib
import hmac
import 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.

Read also