Skip to main content

Verifying webhook signatures

Propper Click signs every outbound webhook so your service can verify the request came from Propper and was not tampered with in transit. Verify the signature before you act on any click.* event.

There is no Click SDK to install: the examples below use each language's standard library.

You subscribe an endpoint, and see its signing secret, in Settings → Developers → Webhooks in the dashboard.


Events​

EventSent when
click.acceptance.completedAn acceptance is recorded and its evidence is complete
click.evidence.createdThe evidence record for an acceptance is created
click.evidence.signedThe evidence record is signed
click.evidence.failedEvidence could not be produced; the acceptance itself is still recorded (see evidenceStatus on the receipt)

Headers​

Each webhook POST includes:

HeaderExamplePurpose
X-Propper-Signaturet=1716900000,v1=abc123...Timestamp and HMAC-SHA256
X-Propper-Timestamp1716900000Same Unix epoch as t= above
X-Propper-Event-Idevt_...Unique event ID (idempotency on your side)
X-Propper-Event-Typeclick.acceptance.completedWhat happened
X-Propper-Delivery-Idwhd_...Per-delivery ID, useful in support tickets

Signature format​

The X-Propper-Signature header is Stripe-compatible:

t=<unix_seconds>,v1=<hmac_hex>[,v1=<hmac_hex>...]

Multiple v1= entries appear during a key rotation window. Verify against each of your active secrets and accept if any matches.

The HMAC is computed as:

HMAC_SHA256(secret, "<timestamp>.<raw_body>")
Use the RAW request body

The signature is computed over the exact bytes the sender wrote. If your framework JSON-parses and re-serializes before you verify, the signature will not match. Read the raw bytes first, verify, then parse.


Replay protection​

t= is the Unix-seconds timestamp at signing time. Reject any webhook where |now - t| > 300 to defend against replay.


Node.js​

import crypto from 'node:crypto';
import express from 'express';

const app = express();

function verify(rawBody, header, secrets, toleranceSeconds = 300) {
const parts = header.split(',').map((part) => part.split('='));
const timestamp = parts.find(([key]) => key === 't')?.[1];
const signatures = parts.filter(([key]) => key === 'v1').map(([, value]) => value);
const ts = Number(timestamp);
if (!timestamp || !Number.isFinite(ts) || Math.abs(Date.now() / 1000 - ts) > toleranceSeconds) {
return false;
}
return secrets.some((secret) => {
const expected = crypto.createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest();
return signatures.some((signature) => {
const received = Buffer.from(signature, 'hex');
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
});
});
}

// express.raw keeps the exact bytes; do NOT use express.json here.
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
// Without a JSON Content-Type, express.raw leaves req.body unparsed: reject it.
if (!Buffer.isBuffer(req.body)) {
return res.status(400).send('expected a JSON body');
}
const body = req.body.toString('utf8');
const header = req.header('X-Propper-Signature') ?? '';
if (!verify(body, header, [process.env.PROPPER_WEBHOOK_SECRET])) {
return res.status(400).send('invalid signature');
}
const event = JSON.parse(body);
// ... handle event
res.status(200).send('ok');
});

Python​

import hashlib
import hmac
import os
import time

from fastapi import FastAPI, HTTPException, Request

app = FastAPI()


def verify(raw_body: bytes, header: str, secrets: list[str], tolerance_seconds: int = 300) -> bool:
parts = [part.split("=", 1) for part in header.split(",") if "=" in part]
timestamp = next((value for key, value in parts if key == "t"), None)
signatures = [value for key, value in parts if key == "v1"]
try:
if timestamp is None or abs(time.time() - int(timestamp)) > tolerance_seconds:
return False
except ValueError: # malformed t= value
return False
signed = timestamp.encode() + b"." + raw_body
return any(
hmac.compare_digest(hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest(), signature)
for secret in secrets
for signature in signatures
)


@app.post("/webhook")
async def webhook(request: Request):
body = await request.body() # raw bytes
header = request.headers.get("X-Propper-Signature", "")
if not verify(body, header, [os.environ["PROPPER_WEBHOOK_SECRET"]]):
raise HTTPException(status_code=400, detail="invalid signature")
# ... handle event
return {"status": "ok"}

Go​

package main

import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"io"
"math"
"net/http"
"os"
"strconv"
"strings"
"time"
)

func verify(body []byte, header string, secrets []string, tolerance time.Duration) bool {
var timestamp string
var signatures []string
for _, part := range strings.Split(header, ",") {
key, value, ok := strings.Cut(part, "=")
if !ok {
continue
}
switch key {
case "t":
timestamp = value
case "v1":
signatures = append(signatures, value)
}
}
seconds, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil || math.Abs(float64(time.Now().Unix()-seconds)) > tolerance.Seconds() {
return false
}
for _, secret := range secrets {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(timestamp + "."))
mac.Write(body)
expected := mac.Sum(nil)
for _, signature := range signatures {
received, err := hex.DecodeString(signature)
if err == nil && hmac.Equal(received, expected) {
return true
}
}
}
return false
}

func webhook(secret string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
defer r.Body.Close()
header := r.Header.Get("X-Propper-Signature")
if err != nil || !verify(body, header, []string{secret}, 5*time.Minute) {
http.Error(w, "invalid signature", http.StatusBadRequest)
return
}
// ... handle event
w.WriteHeader(http.StatusOK)
}
}

func main() {
http.Handle("/webhook", webhook(os.Getenv("PROPPER_WEBHOOK_SECRET")))
_ = http.ListenAndServe(":8080", nil)
}

Key rotation​

When you rotate a webhook signing secret, Propper signs every delivery with both the new and the old secret for a rotation window. Pass both secrets to verify during the window so no deliveries are rejected, then remove the old one from your configuration.


What happens on failure​

If verification fails, return a 4xx response (400 Bad Request is conventional). Propper retries failed deliveries with exponential backoff, as described in Webhooks.