Petnow LogoPetnow

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.

HeaderDescription
webhook-idThe event ID. It equals the id field of the body and stays the same across retries and resends.
webhook-timestampUnix time in seconds of this delivery attempt.
webhook-signaturev1,<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 express
import 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 flask
import 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 "", 200

The 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:

  1. Reject the request if webhook-timestamp is more than 5 minutes away from your current time. This blocks replays of old requests.
  2. Build the signed content: {webhook-id}.{webhook-timestamp}.{raw body}.
  3. Base64-decode the part of the secret after whsec_. The result is the HMAC key.
  4. Compute HMAC-SHA256 of the signed content and Base64-encode it.
  5. Compare it with each signature in webhook-signature using 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 False

Rotating 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-signature header 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

SymptomLikely cause
Every request fails verificationWrong 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 requestsYour framework changes the body before you read it (JSON parsing, decompression, trimming). Verify the raw bytes.
Timestamp rejectedThe server clock is off by more than 5 minutes.

On this page