Verify Signatures
Verify that a webhook request comes from Petify, using the Standard Webhooks headers and your endpoint's signing secret.
Why verify
Anyone who knows your endpoint URL can send it a request. Verify the signature of every request, and reject the ones that fail. Petify signs events with the Standard Webhooks scheme, so a ready-made library does the work for you.
Headers
Every request carries three headers.
| Header | Description |
|---|---|
webhook-id | The event ID. It equals the id field of the body and stays the same across retries and resends. |
webhook-timestamp | Unix time in seconds of this delivery attempt. |
webhook-signature | v1,<signature>: the Base64 HMAC-SHA256 of {webhook-id}.{webhook-timestamp}.{raw body}, keyed with your signing secret. |
Your signing secret looks like whsec_.... Copy it in the Console: open the endpoint and click Reveal secret.
Verify the raw body. Compute the signature over the exact bytes you received. If your framework parses the JSON first and you serialize it again, whitespace and key order can change and the check fails. Read the raw body, verify, and only then parse it.
Node.js
Install the standardwebhooks package.
npm install standardwebhooks expressimport express from "express";
import { Webhook } from "standardwebhooks";
const app = express();
const wh = new Webhook(process.env.PETIFY_WEBHOOK_SECRET); // whsec_...
// express.raw keeps the body as a Buffer, exactly as Petify sent it.
app.post(
"/webhooks/petify",
express.raw({ type: "application/json" }),
(req, res) => {
let event;
try {
event = wh.verify(req.body.toString("utf8"), {
"webhook-id": req.header("webhook-id"),
"webhook-timestamp": req.header("webhook-timestamp"),
"webhook-signature": req.header("webhook-signature"),
});
} catch {
return res.status(400).send("invalid signature");
}
// Deduplicate on the webhook-id header before you act on the event.
switch (event.type) {
case "verification_job.succeeded":
// event.data.jobId, event.data.petId, event.data.isVerified, event.data.score
break;
default:
// Ignore event types you do not handle.
break;
}
res.sendStatus(200);
},
);
app.listen(3000);Python
Install the standardwebhooks package.
pip install standardwebhooks flaskimport os
from flask import Flask, request
from standardwebhooks.webhooks import Webhook, WebhookVerificationError
app = Flask(__name__)
wh = Webhook(os.environ["PETIFY_WEBHOOK_SECRET"]) # whsec_...
@app.post("/webhooks/petify")
def petify_webhook():
# get_data() returns the raw bytes, exactly as Petify sent them.
payload = request.get_data()
headers = {
"webhook-id": request.headers.get("webhook-id", ""),
"webhook-timestamp": request.headers.get("webhook-timestamp", ""),
"webhook-signature": request.headers.get("webhook-signature", ""),
}
try:
event = wh.verify(payload, headers)
except WebhookVerificationError:
return "invalid signature", 400
# Deduplicate on the webhook-id header before you act on the event.
if event["type"] == "verification_job.succeeded":
data = event["data"] # jobId, petId, isVerified, score
# Ignore event types you do not handle.
return "", 200The libraries also check webhook-timestamp and reject a request that is more than 5 minutes away from your server's clock. Keep your server's clock in sync (NTP).
Verify manually
If you cannot use a library, verify the signature yourself. The steps are:
- Reject the request if
webhook-timestampis more than 5 minutes away from your current time. This blocks replays of old requests. - Build the signed content:
{webhook-id}.{webhook-timestamp}.{raw body}. - Base64-decode the part of the secret after
whsec_. The result is the HMAC key. - Compute HMAC-SHA256 of the signed content and Base64-encode it.
- Compare it with each signature in
webhook-signatureusing a constant-time comparison. Accept the request if one matches.
import base64
import hashlib
import hmac
import time
TOLERANCE_SECONDS = 5 * 60
def verify(secret: str, headers: dict, raw_body: bytes) -> bool:
msg_id = headers["webhook-id"]
timestamp = headers["webhook-timestamp"]
# 1. Reject old (or far-future) timestamps.
if abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS:
return False
# 2-4. Sign "{id}.{timestamp}.{body}" with the decoded secret.
key = base64.b64decode(secret.removeprefix("whsec_"))
signed_content = f"{msg_id}.{timestamp}.".encode() + raw_body
digest = hmac.new(key, signed_content, hashlib.sha256).digest()
expected = base64.b64encode(digest).decode()
# 5. The header can hold several space-separated "v1,<signature>" values.
for part in headers["webhook-signature"].split(" "):
version, _, signature = part.partition(",")
if version == "v1" and hmac.compare_digest(signature, expected):
return True
return FalseRotating the secret
In the Console, open the endpoint and click Rotate secret. Petify creates a new secret and signs with it right away.
- For 24 hours after a rotation, the previous secret keeps signing as well. During that window the
webhook-signatureheader holds two signatures, new and previous, separated by a space. - Accept the request when any one of them verifies. The libraries above and the manual example do this already.
- Deploy the new secret to your server within the 24 hours. Requests that arrive after the window carry only the new signature.
A rotation does not interrupt delivery. Deploy the new secret first, then stop using the old one.
Troubleshooting
| Symptom | Likely cause |
|---|---|
| Every request fails verification | Wrong secret (for example, the secret of another endpoint), or the secret was rotated more than 24 hours ago and your server still uses the old one. |
| Verification fails only for some requests | Your framework changes the body before you read it (JSON parsing, decompression, trimming). Verify the raw bytes. |
| Timestamp rejected | The server clock is off by more than 5 minutes. |