Verify a webhook
Fetch the public keys from the well-known URL, pick the key named by kid, verify the Ed25519 signature over t + "." + raw body, reject anything older than 300 seconds, and answer 2xx within ten seconds.
Written for the engineer writing the webhook handler.
What arrives
POST https://your-endpoint
Content-Type: application/json
User-Agent: Pehmen-Webhooks/1.0
Pehmen-Event-Id: evt_…
Pehmen-Signature: t=1788750000,kid=1,v1=<base64 signature>
{ "id": "evt_…", "type": "payment.intent.succeeded.v1", "created_at": "…", "tenant_id": "…", "data": { … } }
The body is serialized once when the event is fanned out and frozen. The signature covers those exact bytes, so verify the raw body, never a re-serialized one.
The public keys
GET https://pehmen-api.huper.technology/.well-known/pehmen-webhook-keys.json returns:
{ "keys": [ { "kid": 1, "alg": "EdDSA", "public_key": "<base64 of the raw 32-byte key>" } ] }
Cache it for five minutes (the response says so in cache-control). An unknown kid is a failure; do not try other keys. There is no shared secret anywhere in this scheme, so there is nothing of Pehmen's to store or rotate on your side.
Node
import { createPublicKey, verify } from 'node:crypto';
const SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex'); // Ed25519 SubjectPublicKeyInfo header
export function verifyPehmen(rawBody, signatureHeader, keys, nowSeconds = Date.now() / 1000) {
const parts = Object.fromEntries(signatureHeader.split(',').map((p) => p.split('=')));
const key = keys.find((k) => String(k.kid) === parts.kid);
if (!key || !parts.t || !parts.v1) return false;
if (Math.abs(nowSeconds - Number(parts.t)) > 300) return false;
const pub = createPublicKey({
key: Buffer.concat([SPKI_PREFIX, Buffer.from(key.public_key, 'base64')]),
format: 'der',
type: 'spki',
});
const message = Buffer.concat([Buffer.from(parts.t + '.'), rawBody]);
return verify(null, message, pub, Buffer.from(parts.v1, 'base64'));
}
An Express handler that keeps the raw body:
app.post('/pehmen', express.raw({ type: 'application/json' }), async (req, res) => {
const ok = verifyPehmen(req.body, req.get('Pehmen-Signature') ?? '', await cachedKeys());
if (!ok) return res.status(400).end();
const event = JSON.parse(req.body.toString('utf8'));
if (await alreadyHandled(event.id)) return res.status(200).end();
await enqueue(event); // do the work after answering
res.status(200).end();
});
Python
import base64, json, time
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
from cryptography.exceptions import InvalidSignature
def verify_pehmen(raw_body: bytes, signature_header: str, keys: list[dict], now: float | None = None) -> bool:
parts = dict(p.split("=", 1) for p in signature_header.split(","))
key = next((k for k in keys if str(k["kid"]) == parts.get("kid")), None)
if key is None or "t" not in parts or "v1" not in parts:
return False
if abs((now or time.time()) - float(parts["t"])) > 300:
return False
pub = Ed25519PublicKey.from_public_bytes(base64.b64decode(key["public_key"]))
try:
pub.verify(base64.b64decode(parts["v1"]), parts["t"].encode() + b"." + raw_body)
return True
except InvalidSignature:
return False
The delivery contract
- Your endpoint must be
https://on port 443 and publicly routable. Private and link-local addresses are refused when you save the endpoint and re-checked, with DNS re-resolved, on every attempt. Redirects are never followed. - Answer
2xxwithin ten seconds. Anything else is a failure and is retried: 30 seconds, doubling to a cap of one hour, each wait multiplied by a random factor between 0.5 and 1.5, up to twenty attempts. - After twenty failures the delivery and the endpoint are both
DISABLED, and awebhook.endpoint.disabled.v1event is recorded. Fix the endpoint, enable it, and replay from the Webhooks screen. - One delivery at a time per endpoint, oldest first, so order holds per endpoint.
- The same event is never delivered twice to the same endpoint except by an explicit replay, which carries the same id and the same bytes.
Why a valid signature fails
| Symptom | Cause |
|---|---|
| Fails on every event | You verified a re-serialized body. Use the raw bytes. A JSON body parser that runs before your handler has already destroyed them. |
| Fails on every event | You signed over the body alone. The message is t, a literal dot, then the body. |
| Fails intermittently | Your clock is off by more than five minutes. |
| Fails after a key rotation | You cached the keys for longer than five minutes, or ignored kid. |
| Works locally, fails in production | A proxy re-encoded or pretty-printed the body. Verify at the edge that receives the bytes. |
Events you can subscribe to
The list is on Outbound webhooks and is also returned by GET /v1/webhook-endpoints as merchant_visible_event_types.