Skip to main content

Verifying webhook signatures

Propper Click signs every outbound webhook so your service can verify the request came from us and was not tampered with in transit. Verification is mandatory for any production integration that acts on click.* events.


Headers

Each webhook POST includes:

HeaderExamplePurpose
X-Propper-Signaturet=1716900000,v1=abc123...Timestamp + 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 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. The SDK verifiers do this for you with a default 300-second window (configurable).


Node.js / TypeScript

import express from 'express';
import { verifyWebhookSignature } from '@propper/click-api-node';

const app = express();
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
try {
verifyWebhookSignature({
body: req.body, // Buffer from express.raw — DO NOT use express.json here
signatureHeader: req.header('X-Propper-Signature'),
secrets: process.env.PROPPER_WEBHOOK_SECRET,
toleranceSeconds: 300, // default
});
} catch {
return res.status(400).send('invalid signature');
}
const event = JSON.parse(req.body.toString('utf8'));
// ... handle event
res.status(200).send('ok');
});

Python

from fastapi import FastAPI, HTTPException, Request
from propper_click import verify_webhook_signature, WebhookVerificationError

app = FastAPI()

@app.post("/webhook")
async def webhook(request: Request):
body = await request.body() # raw bytes
try:
verify_webhook_signature(
body=body,
signature_header=request.headers.get("X-Propper-Signature"),
secrets=os.environ["PROPPER_WEBHOOK_SECRET"],
tolerance_seconds=300,
)
except WebhookVerificationError as err:
raise HTTPException(status_code=400, detail=f"invalid signature ({err.reason})")
# ... handle event
return {"status": "ok"}

Go

import (
"errors"
"io"
"net/http"

click "github.com/mypropper/click-sdk-go"
)

func webhook(secret string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
defer r.Body.Close()

if err := click.VerifyWebhookSignature(click.VerifyWebhookOptions{
Body: body,
SignatureHeader: r.Header.Get("X-Propper-Signature"),
Secrets: []string{secret},
}); err != nil {
var verr *click.WebhookVerificationError
if errors.As(err, &verr) {
http.Error(w, "invalid signature", http.StatusBadRequest)
return
}
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// ... handle event
w.WriteHeader(http.StatusOK)
}
}

Key rotation

When you rotate a webhook signing secret in the dashboard, Propper signs every delivery with both the new and old secrets for a 24-hour rotating window. The SDK verifiers accept a list of secrets — pass both during the window so no deliveries are rejected:

verifyWebhookSignature({
body: req.body,
signatureHeader: req.header('X-Propper-Signature'),
secrets: [process.env.PROPPER_WEBHOOK_SECRET, process.env.PROPPER_WEBHOOK_SECRET_PREVIOUS],
});

After the rotation window closes, remove the old secret from your config.


What happens on failure

If the verifier throws, return a 4xx response (400 Bad Request is conventional). Propper will retry the delivery up to 5 times with exponential backoff. After exhaustion, the event lands in the dead-letter queue, viewable in Click → Settings → Webhooks → Failed Deliveries.