Beta
Verifying Signatures
- Overview
- Delivery headers
- The signature header
- The signed string
- Verifying a v1 signature
- Verifying a v1a signature
- Rejecting stale deliveries
- Verification order
- Example code
- What to read next
Overview
PPS implements the Standard Webhooks signature scheme (standardwebhooks.com). Most languages have a ready-made library for it. What follows is what those libraries implement, so you can verify by hand if you prefer.
Verify every delivery before you parse the body or act on it. A handler that processes unverified requests accepts event data from anyone who discovers its URL.
Delivery headers
Every delivery carries three headers. Two more are conditional.
| Header | Value | Present |
|---|---|---|
| webhook-id | The event id, which is also the idempotency key. The same value appears in the body as eventId. | Always |
| webhook-timestamp | The send time of this attempt, as unix seconds. Every attempt is signed fresh at send time, so a retry carries a later value than the attempt before it. | Always |
| webhook-signature | A space-separated list of signature entries, each written as a version tag, a comma, then a base64 value. | Always |
| webhook-key-id | Which published PPS key produced the v1a signature. | Conditional: only on deliveries carrying a v1a entry |
| webhook-test | true on a test delivery. Never present on a production or simulated one. | Conditional: test deliveries only |
webhook-test lets you reject test traffic at the edge without parsing the body. See Keeping test traffic out of production handling.
A symmetric delivery on the wire looks like this.
POST /pps/workflows HTTP/1.1
Host: hooks.example.com
Content-Type: application/json
webhook-id: msg_9f1c2e4a-6b73-3d81-a0c5-4e2f7b9d1350
webhook-timestamp: 1787927163
webhook-signature: v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE=
{"type":"workflow.updated","eventId":"msg_9f1c2e4a-6b73-3d81-a0c5-4e2f7b9d1350","timestamp":"2026-08-28T14:26:03Z","data":{"workflowId":"wfpop_7b3f1c9e-2a84-4d16-9f05-c3e8a1b47d92","runId":"wfrunpop_7b3f1c9e-2a84-4d16-9f05-c3e8a1b47d92","correlationId":"9c2e14a7-55d0-4b8e-a731-6f0d2b93e185","serviceName":"ADV","modelName":"ADV-120","status":"ready"}}
An asymmetric delivery carries a v1a entry and the webhook-key-id header, and no v1 entry at all.
POST /pps/workflows HTTP/1.1
Host: hooks.example.com
Content-Type: application/json
webhook-id: msg_9f1c2e4a-6b73-3d81-a0c5-4e2f7b9d1350
webhook-timestamp: 1787927163
webhook-signature: v1a,ljJWD3JeUHFQ8tvQrm5A6773z7ru628FhxvP0NHrInppCu4BM5cvMD4LL+SGGHS839s0zLg09bj2LCG+U2mmMg==
webhook-key-id: pps.7Gq2XbN4uR0w
{"type":"workflow.updated","eventId":"msg_9f1c2e4a-6b73-3d81-a0c5-4e2f7b9d1350","timestamp":"2026-08-28T14:26:03Z","data":{"workflowId":"wfpop_7b3f1c9e-2a84-4d16-9f05-c3e8a1b47d92","runId":"wfrunpop_7b3f1c9e-2a84-4d16-9f05-c3e8a1b47d92","correlationId":"9c2e14a7-55d0-4b8e-a731-6f0d2b93e185","serviceName":"ADV","modelName":"ADV-120","status":"ready"}}
The signature header
webhook-signature is a list, not a single value. Entries are separated by spaces, and each is written as a version tag, a comma, then a base64 value. Schematically:
v1,<base64 signature> v1a,<base64 signature>
PPS sends one entry today, because an endpoint is SYMMETRIC or ASYMMETRIC and never both: a symmetric endpoint receives a v1 entry, an asymmetric one receives v1a and nothing else. The list form is what lets a future scheme be added without breaking you.
A verifier that requires a specific entry breaks when PPS adds a scheme. Accept the delivery if ANY entry verifies, and ignore entries whose version tag you do not recognize. Do not require both entries, and do not treat a single-entry header as malformed.
The signed string
Both schemes sign the identical string, formed by joining three values with full stops:
{webhook-id}.{webhook-timestamp}.{raw request body}
With the values from the delivery above, that is one line with no whitespace between the parts:
msg_9f1c2e4a-6b73-3d81-a0c5-4e2f7b9d1350.1787927163.{"type":"workflow.updated","eventId":"msg_9f1c2e4a-6b73-3d81-a0c5-4e2f7b9d1350","timestamp":"2026-08-28T14:26:03Z","data":{"workflowId":"wfpop_7b3f1c9e-2a84-4d16-9f05-c3e8a1b47d92","runId":"wfrunpop_7b3f1c9e-2a84-4d16-9f05-c3e8a1b47d92","correlationId":"9c2e14a7-55d0-4b8e-a731-6f0d2b93e185","serviceName":"ADV","modelName":"ADV-120","status":"ready"}}
A JSON body that has been parsed and serialized again will not verify, because key order and whitespace change. Use the body exactly as received, before you parse it. Most frameworks hand you a parsed object by default, so reach for the raw-bytes escape hatch: express.raw() registered before the route in Express, request.get_data() in Flask, getRequestBody().readAllBytes() in the JDK HTTP server.
Verifying a v1 signature
v1 is HMAC-SHA256, keyed by your signing secret. It is what a SYMMETRIC endpoint receives.
- Strip the
whsec_prefix from your signing secret. - Base64-decode the remainder. The key is those decoded bytes, not the literal string.
- Compute HMAC-SHA256 over the signed string with that key.
- Compare your result against the entry’s base64 value, in constant time.
signing secret : whsec_<base64 key material>
after stripping: <base64 key material>
HMAC key : base64-decode("<base64 key material>")
= the raw bytes, 24 to 64 of them, which is what you key the HMAC with
Step 2 is the one that is easy to miss. Using the prefix-stripped string directly as the key produces a signature that never matches.
Constant-time comparison matters: a comparison that returns early on the first differing byte leaks how much of a forged signature was correct. Use MessageDigest.isEqual in Java, hmac.compare_digest in Python, crypto.timingSafeEqual in Node.
Verifying a v1a signature
v1a is Ed25519, verified against the PPS published public key rather than a shared secret. It is what an ASYMMETRIC endpoint receives, and in that mode it receives v1a and nothing else.
Finding the PPS public keys
Never hardcode a JWKS path. The path discovery returns can change, and a hardcoded one breaks when it does. Fetch https://identity.pointservices.com/.well-known/openid-configuration and read jwks_uri out of it.
curl -X GET "https://identity.pointservices.com/.well-known/openid-configuration"
{
"issuer": "https://identity.pointservices.com",
"jwks_uri": "https://identity.pointservices.com/.well-known/jwks.json",
"id_token_signing_alg_values_supported": ["RS256", "EdDSA"]
}
Then fetch the URI that document gave you.
curl -X GET "https://identity.pointservices.com/.well-known/jwks.json"
Select the Ed25519 keys: kty of OKP and crv of Ed25519.
Never pin a key from this page. The x value below is the published Ed25519 test vector from RFC 8037, not a PPS key, and even a real PPS key rotates. Fetch the live key set through discovery, as shown, and trust only what it returns.
{
"keys": [
{
"kty": "OKP",
"crv": "Ed25519",
"kid": "pps.7Gq2XbN4uR0w",
"use": "sig",
"alg": "EdDSA",
"x": "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo"
}
]
}
Cache the key set. Do not fetch it on every delivery.
The webhook-key-id header
webhook-key-id names which published key produced the signature, so you can select that key directly instead of trying every key you hold. The value is opaque: it starts pps. and the rest is base64url characters. Match it exactly against a kid in the key set rather than parsing it — there is no date, version or other structure in it to read. The published key set and the signer derive both the kid and the signature from the same key material, so the two agree by construction.
Treat webhook-key-id as an opaque lookup hint. It proves nothing; only the signature authenticates a delivery. It may be absent entirely, since symmetric deliveries do not carry it, so keep trying every key you hold as your fallback.
Unknown key ids
If the kid is one you do not recognize, refetch the JWKS once, and rate-limit that refetch so a stream of unknown kids cannot turn into a stream of requests to PPS. If it is still unknown after that refetch, and no key you hold verifies the signature, reject the delivery rather than guessing.
Reject an unknown kid with a 503, not a 4xx. PPS retries a delivery that got a 5xx, signed fresh, so the retry arrives after your rate limit allows another refetch. Any 4xx other than 429 ends the delivery on the first response, with no retry, so a 401 here loses a genuine delivery until you replay it. See Choosing a status when you reject a delivery.
Key rotation between retries
Do not assume two attempts of one webhook-id carry the same webhook-key-id. Every attempt is signed at send time, so a rotation landing between retries means the same event arrives under different kids. That is expected. Verify against the signature, not the header.
Endpoints API Reference — signatureMode decides which scheme an endpoint receives, and is set when you register or edit it.
Rejecting stale deliveries
Compare webhook-timestamp, which is unix seconds, against your own clock, and reject a delivery that is far from it. Five minutes is the common tolerance and what most libraries default to. The specification requires a tolerance but does not fix the number.
Do not use zero. A zero tolerance disables the check and admits replayed captures indefinitely.
Verification order
Do these in order. A failure at any step is a rejection, and the body is never parsed or acted on. Reject with a 4xx, except for a v1a signature from a key you cannot load yet, which gets a 503 so that PPS retries it.
- Read the raw request bytes, before any JSON parsing.
- Check that
webhook-timestampis within your tolerance. - Build the signed string from
webhook-id,webhook-timestampand the raw bytes. - Verify each entry in
webhook-signature, and accept on the first one that passes. - Parse the body.
- Process the event.
Example code
Each example reads the raw body, checks timestamp skew, splits the signature list, verifies v1 by HMAC in constant time, verifies v1a by Ed25519 against a JWKS resolved through OIDC discovery and cached, accepts on the first entry that verifies, and returns 200 only after all checks pass. A delivery that fails only because its webhook-key-id names a key the handler still cannot load gets a 503, so PPS retries it; every other failure gets a 4xx.
Java 21 — java.net.http and java.security
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.security.KeyFactory;
import java.security.MessageDigest;
import java.security.PublicKey;
import java.security.Signature;
import java.security.spec.NamedParameterSpec;
import java.security.spec.EdECPoint;
import java.security.spec.EdECPublicKeySpec;
import java.math.BigInteger;
import java.time.DateTimeException;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Base64;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* Verifies a PPS webhook delivery. Java 21, no third-party dependency beyond a
* JSON parser.
*
* v1 - HMAC-SHA256 keyed by the signing secret, base64-decoded after the
* whsec_ prefix is stripped.
* v1a - Ed25519 against the PPS public key set, reached through OIDC discovery.
*/
public final class PpsWebhookVerifier {
private static final String DISCOVERY_URL =
"https://identity.pointservices.com/.well-known/openid-configuration";
/** Five minutes is the common tolerance and what most libraries default to. */
private static final Duration TIMESTAMP_TOLERANCE = Duration.ofMinutes(5);
private static final ObjectMapper MAPPER = new ObjectMapper();
private static final HttpClient HTTP = HttpClient.newHttpClient();
/** Your signing secret, as set on the endpoint. Load it from your secret store. */
private final String signingSecret;
/** kid -> Ed25519 public key, cached so every delivery does not refetch. */
private Map<String, PublicKey> jwksCache = new HashMap<>();
/** Refetch the JWKS at most once per minute, so unknown kids cannot become a request storm. */
private Instant nextJwksFetchAllowedAt = Instant.EPOCH;
private static final Duration JWKS_REFETCH_INTERVAL = Duration.ofMinutes(1);
/**
* A failed fetch waits only this long, so one network blip does not blackhole
* v1a deliveries for a full minute, while a hard-down JWKS is still not hammered.
*/
private static final Duration JWKS_RETRY_AFTER_FAILURE = Duration.ofSeconds(5);
/**
* The three outcomes of checking webhook-signature. KEY_UNAVAILABLE is kept
* apart from FAILED because it gets a different status: PPS retries a 5xx but
* ends a delivery on any 4xx except 429, so an unknown kid must not get a 401.
*/
private enum Outcome { VERIFIED, KEY_UNAVAILABLE, FAILED }
public PpsWebhookVerifier(String signingSecret) {
this.signingSecret = signingSecret;
}
public static void main(String[] args) throws IOException {
String secret = System.getenv("PPS_WEBHOOK_SECRET");
if (secret == null || !secret.startsWith("whsec_")) {
// Fail at startup rather than rejecting every genuine v1 delivery with a
// generic "signature verification failed" that looks like a crypto problem.
throw new IllegalStateException("PPS_WEBHOOK_SECRET is unset or malformed");
}
PpsWebhookVerifier verifier = new PpsWebhookVerifier(secret);
HttpServer server = HttpServer.create(new InetSocketAddress(8080), 0);
server.createContext("/pps/workflows", exchange -> verifier.handle(exchange));
server.start();
}
private void handle(HttpExchange exchange) throws IOException {
if (!"POST".equals(exchange.getRequestMethod())) {
respond(exchange, 405, "method not allowed");
return;
}
// Read the RAW bytes. The signature is computed over these exact bytes,
// so a body that is parsed and serialized again will not verify.
byte[] rawBody;
try (InputStream in = exchange.getRequestBody()) {
rawBody = in.readAllBytes();
}
String webhookId = exchange.getRequestHeaders().getFirst("webhook-id");
String timestamp = exchange.getRequestHeaders().getFirst("webhook-timestamp");
String signatures = exchange.getRequestHeaders().getFirst("webhook-signature");
String keyId = exchange.getRequestHeaders().getFirst("webhook-key-id");
if (webhookId == null || timestamp == null || signatures == null) {
respond(exchange, 400, "{\"error\":\"missing signature headers\"}");
return;
}
if (!timestampIsFresh(timestamp)) {
respond(exchange, 400, "{\"error\":\"stale timestamp\"}");
return;
}
byte[] signedString = (webhookId + "." + timestamp + ".")
.getBytes(StandardCharsets.UTF_8);
byte[] toVerify = new byte[signedString.length + rawBody.length];
System.arraycopy(signedString, 0, toVerify, 0, signedString.length);
System.arraycopy(rawBody, 0, toVerify, signedString.length, rawBody.length);
Outcome outcome = verifySignatures(signatures, toVerify, keyId);
if (outcome == Outcome.KEY_UNAVAILABLE) {
// webhook-key-id names a key we still cannot load, even after a refetch.
// 503, not 401, so PPS retries the delivery, signed fresh, once the key
// set has caught up. A 4xx would end the delivery with no retry.
respond(exchange, 503, "{\"error\":\"signing key not available yet\"}");
return;
}
if (outcome != Outcome.VERIFIED) {
respond(exchange, 401, "{\"error\":\"signature verification failed\"}");
return;
}
// Verified. Parse and hand off, then answer 2xx quickly.
JsonNode envelope;
try {
envelope = MAPPER.readTree(rawBody);
} catch (IOException e) {
// Only reachable by someone holding the signing key, but letting this
// escape would make PPS retry a delivery that can never succeed.
respond(exchange, 400, "{\"error\":\"verified body is not valid JSON\"}");
return;
}
process(webhookId, envelope);
respond(exchange, 200, "{\"received\":true}");
}
private boolean timestampIsFresh(String timestampHeader) {
try {
// parseLong accepts values Instant cannot represent, and Duration can
// overflow, so catch those too: every failure here must be a rejection,
// never an exception escaping the handler.
Instant sent = Instant.ofEpochSecond(Long.parseLong(timestampHeader.trim()));
Duration skew = Duration.between(sent, Instant.now()).abs();
return skew.compareTo(TIMESTAMP_TOLERANCE) <= 0;
} catch (NumberFormatException | DateTimeException | ArithmeticException e) {
return false;
}
}
/**
* webhook-signature is a space-separated list of "version,base64value" entries.
* Accept the delivery if ANY entry verifies, and ignore versions you do not know.
* KEY_UNAVAILABLE means a v1a entry could not be checked because its key is not loaded.
*/
private Outcome verifySignatures(String header, byte[] toVerify, String keyId) {
boolean keyUnavailable = false;
for (String entry : header.trim().split("\\s+")) {
int comma = entry.indexOf(',');
if (comma < 0) {
continue;
}
String version = entry.substring(0, comma);
String value = entry.substring(comma + 1);
Outcome outcome = switch (version) {
case "v1" -> verifySymmetric(toVerify, value) ? Outcome.VERIFIED : Outcome.FAILED;
case "v1a" -> verifyAsymmetric(toVerify, value, keyId);
default -> Outcome.FAILED; // Unknown version tag: ignore this entry.
};
if (outcome == Outcome.VERIFIED) {
return Outcome.VERIFIED;
}
keyUnavailable |= outcome == Outcome.KEY_UNAVAILABLE;
}
return keyUnavailable ? Outcome.KEY_UNAVAILABLE : Outcome.FAILED;
}
private boolean verifySymmetric(byte[] toVerify, String base64Signature) {
if (signingSecret == null || !signingSecret.startsWith("whsec_")) {
return false;
}
try {
// The key is the secret with whsec_ stripped, then base64-DECODED.
byte[] key = Base64.getDecoder().decode(signingSecret.substring("whsec_".length()));
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(key, "HmacSHA256"));
byte[] expected = mac.doFinal(toVerify);
byte[] presented = Base64.getDecoder().decode(base64Signature);
// Constant-time comparison.
return MessageDigest.isEqual(expected, presented);
} catch (Exception e) {
return false;
}
}
private Outcome verifyAsymmetric(byte[] toVerify, String base64Signature, String keyId) {
byte[] presented;
try {
presented = Base64.getDecoder().decode(base64Signature);
} catch (IllegalArgumentException e) {
return Outcome.FAILED;
}
// webhook-key-id is an opaque lookup hint. It proves nothing and may be
// absent, so try that key first and fall back to every key held.
boolean hintedKeyUnknown = false;
if (keyId != null) {
PublicKey hinted = publicKeyFor(keyId);
if (hinted == null) {
hintedKeyUnknown = true;
} else if (ed25519Verifies(hinted, toVerify, presented)) {
return Outcome.VERIFIED;
}
}
// The fallback has to work with no key id at all, which is why the load
// is here and not only inside publicKeyFor: on a cold cache a delivery
// carrying no webhook-key-id would otherwise have nothing to try.
ensureKeysLoaded();
List<PublicKey> keys = snapshotOfKeys();
for (PublicKey key : keys) {
if (ed25519Verifies(key, toVerify, presented)) {
return Outcome.VERIFIED;
}
}
// Nothing we hold verifies it. If the hint names a key we could not load, or
// we hold no keys at all because the key set could not be fetched, we cannot
// tell a forgery from a key rotated in moments ago, so ask PPS to retry.
if (hintedKeyUnknown || keys.isEmpty()) {
return Outcome.KEY_UNAVAILABLE;
}
return Outcome.FAILED;
}
/** A copy taken under the lock, so iteration cannot race a refresh. */
private synchronized List<PublicKey> snapshotOfKeys() {
return new ArrayList<>(jwksCache.values());
}
private boolean ed25519Verifies(PublicKey key, byte[] toVerify, byte[] signature) {
try {
Signature verifier = Signature.getInstance("Ed25519");
verifier.initVerify(key);
verifier.update(toVerify);
return verifier.verify(signature);
} catch (Exception e) {
return false;
}
}
/**
* Returns the cached key for a kid, refetching the JWKS when the kid is unknown
* and the rate limiter allows it. Inside the limiter's window the refetch is
* skipped entirely. Still unknown either way: return null, the handler answers
* 503, and the PPS retry redelivers it signed fresh.
*/
private synchronized PublicKey publicKeyFor(String kid) {
PublicKey cached = jwksCache.get(kid);
if (cached != null) {
return cached;
}
refreshIfAllowed();
return jwksCache.get(kid);
}
/** Loads the key set when nothing is cached yet, through the same rate limiter. */
private synchronized void ensureKeysLoaded() {
if (jwksCache.isEmpty()) {
refreshIfAllowed();
}
}
/**
* Refetches at most once per interval, so unknown kids cannot become a request
* storm. Inside the interval the refetch is SKIPPED, so a kid rotated in moments
* ago is answered 503 until the interval elapses; the PPS retry then redelivers it.
*/
private synchronized void refreshIfAllowed() {
if (Instant.now().isBefore(nextJwksFetchAllowedAt)) {
return;
}
// The clock is armed by the OUTCOME, not by the attempt: a failed fetch backs
// off briefly rather than spending the full interval, so one transient blip
// does not reject every v1a delivery until the interval expires.
boolean succeeded = refreshJwks();
nextJwksFetchAllowedAt = Instant.now()
.plus(succeeded ? JWKS_REFETCH_INTERVAL : JWKS_RETRY_AFTER_FAILURE);
}
/**
* Returns true when the key set was replaced, false when the fetch failed.
*
* Synchronized, and it holds the monitor across two HTTP calls. That is
* deliberate: it is what makes the check, the fetch and the arming in
* refreshIfAllowed one atomic region, so a rotation under concurrent traffic
* produces one upstream fetch rather than one per delivery. The cost is that
* other delivery threads block here for the duration. If you would rather
* shed that latency than serialize, swap the monitor for a ReentrantLock and
* tryLock, letting a delivery that cannot acquire fall through to the keys it
* already holds.
*/
private synchronized boolean refreshJwks() {
try {
// NEVER hardcode a JWKS path. Read jwks_uri out of the discovery document.
JsonNode discovery = getJson(DISCOVERY_URL);
String jwksUri = discovery.get("jwks_uri").asText();
JsonNode jwks = getJson(jwksUri);
// Build the replacement first, then swap it in one statement, so no
// reader ever observes a half-filled cache.
Map<String, PublicKey> fresh = new HashMap<>();
for (JsonNode jwk : jwks.get("keys")) {
if (!"OKP".equals(jwk.path("kty").asText())
|| !"Ed25519".equals(jwk.path("crv").asText())) {
continue;
}
fresh.put(jwk.path("kid").asText(), ed25519PublicKey(jwk.path("x").asText()));
}
if (fresh.isEmpty()) {
// A key set carrying no usable Ed25519 key is a failed refresh, not
// an empty one: do not discard keys currently verifying deliveries.
return false;
}
jwksCache = fresh;
return true;
} catch (Exception e) {
// Leave the cache as it stands; the next delivery tries again shortly.
return false;
}
}
private JsonNode getJson(String url) throws IOException, InterruptedException {
HttpRequest request = HttpRequest.newBuilder(URI.create(url))
.timeout(Duration.ofSeconds(10))
.GET()
.build();
HttpResponse<String> response = HTTP.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
throw new IOException("GET " + url + " returned " + response.statusCode());
}
return MAPPER.readTree(response.body());
}
/** Builds an Ed25519 public key from the JWK's base64url "x" parameter. */
private PublicKey ed25519PublicKey(String base64UrlX) throws Exception {
byte[] encoded = Base64.getUrlDecoder().decode(base64UrlX);
// The final byte's high bit is the x-coordinate sign; the rest is y, little-endian.
byte[] yLittleEndian = encoded.clone();
boolean xOdd = (yLittleEndian[yLittleEndian.length - 1] & 0x80) != 0;
yLittleEndian[yLittleEndian.length - 1] &= 0x7F;
byte[] yBigEndian = new byte[yLittleEndian.length];
for (int i = 0; i < yLittleEndian.length; i++) {
yBigEndian[i] = yLittleEndian[yLittleEndian.length - 1 - i];
}
EdECPoint point = new EdECPoint(xOdd, new BigInteger(1, yBigEndian));
EdECPublicKeySpec spec = new EdECPublicKeySpec(NamedParameterSpec.ED25519, point);
return KeyFactory.getInstance("Ed25519").generatePublic(spec);
}
/** Your own handling. Delivery is at least once, so deduplicate before working. */
private void process(String webhookId, JsonNode envelope) {
if (alreadySeen(webhookId)) {
return;
}
// Queue the work and return; do it asynchronously rather than in the handler.
}
/** Replace with a durable store carrying a TTL; an in-process set forgets on restart. */
private boolean alreadySeen(String webhookId) {
return false;
}
private void respond(HttpExchange exchange, int status, String body) throws IOException {
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().add("Content-Type", "application/json");
exchange.sendResponseHeaders(status, bytes.length);
try (OutputStream out = exchange.getResponseBody()) {
out.write(bytes);
}
}
}
Python 3.11 — Flask and cryptography
"""
Verifies a PPS webhook delivery. Python 3.11, Flask.
v1 - HMAC-SHA256 keyed by the signing secret, base64-decoded after the
whsec_ prefix is stripped.
v1a - Ed25519 against the PPS public key set, reached through OIDC discovery.
"""
import base64
import hmac
import hashlib
import json
import os
import re
import threading
import time
import requests
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
from flask import Flask, request
app = Flask(__name__)
DISCOVERY_URL = "https://identity.pointservices.com/.well-known/openid-configuration"
# Five minutes is the common tolerance and what most libraries default to.
TIMESTAMP_TOLERANCE_SECONDS = 300
# Your signing secret, as set on the endpoint. Load it from your secret store.
SIGNING_SECRET = os.environ["PPS_WEBHOOK_SECRET"]
# kid -> Ed25519PublicKey, cached so every delivery does not refetch.
_jwks_cache: dict[str, Ed25519PublicKey] = {}
# Reentrant: refresh_if_allowed holds this across refresh_jwks(), which reacquires
# it to swap the cache. A plain Lock would deadlock there.
_jwks_lock = threading.RLock()
_next_jwks_fetch_allowed_at = 0.0
# Refetch at most once per minute, so unknown kids cannot become a request storm.
JWKS_REFETCH_INTERVAL_SECONDS = 60
# A failed fetch waits only this long, so one network blip does not blackhole
# v1a deliveries for a full minute, while a hard-down JWKS is still not hammered.
JWKS_RETRY_AFTER_FAILURE_SECONDS = 5
# The three outcomes of checking webhook-signature. KEY_UNAVAILABLE is kept apart
# from FAILED because it gets a different status: PPS retries a 5xx but ends a
# delivery on any 4xx except 429, so an unknown kid must not be answered with 401.
VERIFIED = "verified"
KEY_UNAVAILABLE = "key-unavailable"
FAILED = "failed"
@app.post("/pps/workflows")
def receive_delivery():
# request.get_data() returns the RAW bytes. The signature is computed over
# these exact bytes, so a body that is parsed and serialized again will not
# verify. Never use request.get_json() before verifying.
raw_body = request.get_data()
webhook_id = request.headers.get("webhook-id")
timestamp = request.headers.get("webhook-timestamp")
signatures = request.headers.get("webhook-signature")
key_id = request.headers.get("webhook-key-id")
if not webhook_id or not timestamp or not signatures:
return {"error": "missing signature headers"}, 400
if not timestamp_is_fresh(timestamp):
return {"error": "stale timestamp"}, 400
to_verify = f"{webhook_id}.{timestamp}.".encode("utf-8") + raw_body
outcome = verify_signatures(signatures, to_verify, key_id)
if outcome == KEY_UNAVAILABLE:
# webhook-key-id names a key we still cannot load, even after a refetch.
# 503, not 401, so PPS retries the delivery, signed fresh, once the key
# set has caught up. A 4xx would end the delivery with no retry.
return {"error": "signing key not available yet"}, 503
if outcome != VERIFIED:
return {"error": "signature verification failed"}, 401
# Verified. Parse the raw bytes we already hold: request.get_json() is
# Content-Type sensitive and would 415 a verified delivery.
try:
envelope = json.loads(raw_body)
except ValueError:
# Only reachable by someone holding the signing key, but a 500 here would
# make PPS retry a delivery that can never succeed.
return {"error": "verified body is not valid JSON"}, 400
process(webhook_id, envelope)
return {"received": True}, 200
def timestamp_is_fresh(timestamp_header: str) -> bool:
try:
# A huge digit string fails in one of two ways depending on the runtime:
# int() itself raises ValueError past CPython's digit limit, and otherwise
# the subtraction below raises OverflowError. Catch both and reject, rather
# than letting either escape the handler.
raw = timestamp_header.strip()
# int() also accepts non-ASCII digits such as "٣٠٠"; require plain ASCII so
# the three samples agree on which timestamps are valid.
if not re.fullmatch(r"-?[0-9]+", raw):
return False
sent = int(raw)
return abs(time.time() - sent) <= TIMESTAMP_TOLERANCE_SECONDS
except (ValueError, OverflowError):
return False
def verify_signatures(header: str, to_verify: bytes, key_id: str | None) -> str:
"""
webhook-signature is a space-separated list of "version,base64value" entries.
Accept the delivery if ANY entry verifies, and ignore versions you do not know.
Returns VERIFIED, KEY_UNAVAILABLE when a v1a entry could not be checked because
its key is not loaded, or FAILED.
"""
key_unavailable = False
for entry in header.split():
version, _, value = entry.partition(",")
if not value:
continue
if version == "v1" and verify_symmetric(to_verify, value):
return VERIFIED
if version == "v1a":
outcome = verify_asymmetric(to_verify, value, key_id)
if outcome == VERIFIED:
return VERIFIED
key_unavailable = key_unavailable or outcome == KEY_UNAVAILABLE
# Unknown version tag: ignore this entry.
return KEY_UNAVAILABLE if key_unavailable else FAILED
def verify_symmetric(to_verify: bytes, base64_signature: str) -> bool:
if not SIGNING_SECRET.startswith("whsec_"):
return False
try:
# The key is the secret with whsec_ stripped, then base64-DECODED.
# Pad before decoding: a 32-byte key encodes with a trailing "=", which is
# routinely stripped in env vars and CI. Java and Node accept the unpadded
# form, so accept it here too rather than rejecting a genuine delivery.
key = base64.b64decode(pad_base64(SIGNING_SECRET[len("whsec_"):]), validate=True)
expected = hmac.new(key, to_verify, hashlib.sha256).digest()
presented = base64.b64decode(pad_base64(base64_signature), validate=True)
except (ValueError, TypeError):
return False
# Constant-time comparison.
return hmac.compare_digest(expected, presented)
def verify_asymmetric(to_verify: bytes, base64_signature: str, key_id: str | None) -> str:
try:
# validate=True so a malformed signature raises instead of silently
# dropping non-alphabet characters and producing garbage bytes; padded
# first so an unpadded-but-genuine value is not rejected.
presented = base64.b64decode(pad_base64(base64_signature), validate=True)
except (ValueError, TypeError):
return FAILED
# webhook-key-id is an opaque lookup hint. It proves nothing and may be
# absent, so try that key first and fall back to every key held.
hinted_key_unknown = False
if key_id:
hinted = public_key_for(key_id)
if hinted is None:
hinted_key_unknown = True
elif ed25519_verifies(hinted, to_verify, presented):
return VERIFIED
# The fallback has to work with no key id at all, which is why the load is
# here and not only inside public_key_for: on a cold cache a delivery
# carrying no webhook-key-id would otherwise have nothing to try.
ensure_keys_loaded()
with _jwks_lock:
keys = list(_jwks_cache.values())
if any(ed25519_verifies(key, to_verify, presented) for key in keys):
return VERIFIED
# Nothing we hold verifies it. If the hint names a key we could not load, or
# we hold no keys at all because the key set could not be fetched, we cannot
# tell a forgery from a key rotated in moments ago, so ask PPS to retry.
if hinted_key_unknown or not keys:
return KEY_UNAVAILABLE
return FAILED
def ed25519_verifies(key: Ed25519PublicKey, to_verify: bytes, signature: bytes) -> bool:
try:
key.verify(signature, to_verify)
return True
except InvalidSignature:
return False
def public_key_for(kid: str) -> Ed25519PublicKey | None:
"""
Returns the cached key for a kid, refetching the JWKS when the kid is unknown
and the rate limiter allows it. Inside the limiter's window the refetch is
skipped entirely. Still unknown either way: return None, the handler answers
503, and the PPS retry redelivers it signed fresh.
"""
with _jwks_lock:
cached = _jwks_cache.get(kid)
if cached is not None:
return cached
refresh_if_allowed()
with _jwks_lock:
return _jwks_cache.get(kid)
def ensure_keys_loaded() -> None:
"""Loads the key set when nothing is cached yet, through the same rate limiter."""
with _jwks_lock:
if _jwks_cache:
return
refresh_if_allowed()
def refresh_if_allowed() -> None:
"""
Refetches at most once per interval, so unknown kids cannot become a request
storm. Inside the interval the refetch is SKIPPED, so a kid rotated in moments
ago is answered 503 until the interval elapses; the PPS retry then redelivers it.
"""
global _next_jwks_fetch_allowed_at
# The check, the fetch and the arming are ONE atomic region. Releasing the lock
# around the fetch would let every caller arriving during that round trip pass
# the check and start its own: a rotation under concurrent traffic then becomes
# one upstream request per in-flight delivery. Concurrent callers block here and
# find the cache already populated when they acquire.
with _jwks_lock:
if time.time() < _next_jwks_fetch_allowed_at:
return
# The clock is armed by the OUTCOME, not by the attempt: a failed fetch backs
# off briefly rather than spending the full interval, so one transient blip
# does not reject every v1a delivery until the interval expires. try/finally
# so an unexpected exception still arms it rather than leaving it open.
succeeded = False
try:
succeeded = refresh_jwks()
finally:
_next_jwks_fetch_allowed_at = time.time() + (
JWKS_REFETCH_INTERVAL_SECONDS if succeeded else JWKS_RETRY_AFTER_FAILURE_SECONDS
)
def refresh_jwks() -> bool:
"""Returns True when the key set was replaced, False when the fetch failed."""
try:
# NEVER hardcode a JWKS path. Read jwks_uri out of the discovery document.
discovery = requests.get(DISCOVERY_URL, timeout=10)
discovery.raise_for_status()
jwks_uri = discovery.json()["jwks_uri"]
jwks = requests.get(jwks_uri, timeout=10)
jwks.raise_for_status()
fresh: dict[str, Ed25519PublicKey] = {}
keys = jwks.json().get("keys")
for jwk in keys if isinstance(keys, list) else []:
if not isinstance(jwk, dict):
continue
if jwk.get("kty") != "OKP" or jwk.get("crv") != "Ed25519":
continue
kid, x = jwk.get("kid"), jwk.get("x")
if not isinstance(kid, str) or not isinstance(x, str):
continue
fresh[kid] = Ed25519PublicKey.from_public_bytes(
base64.urlsafe_b64decode(pad_base64(x))
)
if not fresh:
# A key set carrying no usable Ed25519 key is a failed refresh, not an
# empty one: do not discard keys that are currently verifying deliveries.
return False
with _jwks_lock:
_jwks_cache.clear()
_jwks_cache.update(fresh)
return True
except Exception:
# Any failure is a failed refresh, never an exception out of the handler.
# A malformed JWKS ({"keys": null}, a non-string "x", a missing "kid") raises
# TypeError or AttributeError rather than ValueError, and an escaping
# exception would also skip the limiter's arming.
# Leave the cache as it stands; the next delivery tries again shortly.
return False
def pad_base64(value: str) -> str:
"""Restores stripped base64 padding. Java and Node tolerate its absence; Python does not."""
return value + "=" * (-len(value) % 4)
def process(webhook_id: str, envelope: dict) -> None:
"""Your own handling. Delivery is at least once, so deduplicate before working."""
if already_seen(webhook_id):
return
# Queue the work and return; do it asynchronously rather than in the handler.
def already_seen(webhook_id: str) -> bool:
"""Replace with a durable store carrying a TTL; an in-process set forgets on restart."""
return False
if __name__ == "__main__":
app.run(port=8080)
Node.js 20 — Express and node:crypto
/**
* Verifies a PPS webhook delivery. Node.js 20, Express.
*
* v1 - HMAC-SHA256 keyed by the signing secret, base64-decoded after the
* whsec_ prefix is stripped.
* v1a - Ed25519 against the PPS public key set, reached through OIDC discovery.
*/
const express = require('express');
const crypto = require('crypto');
const app = express();
const DISCOVERY_URL =
'https://identity.pointservices.com/.well-known/openid-configuration';
// Five minutes is the common tolerance and what most libraries default to.
const TIMESTAMP_TOLERANCE_SECONDS = 300;
// Your signing secret, as set on the endpoint. Load it from your secret store.
const SIGNING_SECRET = process.env.PPS_WEBHOOK_SECRET;
if (!SIGNING_SECRET || !SIGNING_SECRET.startsWith('whsec_')) {
// Fail at startup rather than rejecting every genuine v1 delivery with a
// generic "signature verification failed" that looks like a crypto problem.
throw new Error('PPS_WEBHOOK_SECRET is unset or malformed');
}
// kid -> KeyObject, cached so every delivery does not refetch. Replaced
// wholesale on refresh rather than mutated, so no request sees it half filled.
let jwksCache = new Map();
// Refetch at most once per minute, so unknown kids cannot become a request storm.
const JWKS_REFETCH_INTERVAL_MS = 60_000;
// A failed fetch waits only this long, so one network blip does not blackhole
// v1a deliveries for a full minute, while a hard-down JWKS is still not hammered.
const JWKS_RETRY_AFTER_FAILURE_MS = 5_000;
let nextJwksFetchAllowedAt = 0;
// One in-flight refresh shared by every concurrent caller, so a rotation under
// load produces one upstream fetch rather than one per delivery.
let inFlightRefresh = null;
// The three outcomes of checking webhook-signature. KEY_UNAVAILABLE is kept apart
// from FAILED because it gets a different status: PPS retries a 5xx but ends a
// delivery on any 4xx except 429, so an unknown kid must not be answered with 401.
const VERIFIED = 'verified';
const KEY_UNAVAILABLE = 'key-unavailable';
const FAILED = 'failed';
// express.raw() must be registered BEFORE the route so req.body is the RAW
// Buffer. The signature is computed over these exact bytes, so a body that has
// been parsed and serialized again will not verify. Do not use express.json()
// on this route.
app.post(
'/pps/workflows',
// type: '*/*' so a request with another Content-Type still arrives as a Buffer.
// With 'application/json' req.body is left undefined and the concat below throws,
// which would 500 before any signature check runs.
express.raw({ type: '*/*' }),
async (req, res) => {
const rawBody = req.body;
if (!Buffer.isBuffer(rawBody)) {
return res.status(400).json({ error: 'expected a raw request body' });
}
const webhookId = req.get('webhook-id');
const timestamp = req.get('webhook-timestamp');
const signatures = req.get('webhook-signature');
const keyId = req.get('webhook-key-id');
if (!webhookId || !timestamp || !signatures) {
return res.status(400).json({ error: 'missing signature headers' });
}
if (!timestampIsFresh(timestamp)) {
return res.status(400).json({ error: 'stale timestamp' });
}
const toVerify = Buffer.concat([
Buffer.from(`${webhookId}.${timestamp}.`, 'utf8'),
rawBody,
]);
const outcome = await verifySignatures(signatures, toVerify, keyId);
if (outcome === KEY_UNAVAILABLE) {
// webhook-key-id names a key we still cannot load, even after a refetch.
// 503, not 401, so PPS retries the delivery, signed fresh, once the key set
// has caught up. A 4xx would end the delivery with no retry.
return res.status(503).json({ error: 'signing key not available yet' });
}
if (outcome !== VERIFIED) {
return res.status(401).json({ error: 'signature verification failed' });
}
// Verified. Parse and hand off, then answer 2xx quickly.
let envelope;
try {
envelope = JSON.parse(rawBody.toString('utf8'));
} catch {
// Only reachable by someone holding the signing key, but a 500 here would
// make PPS retry a delivery that can never succeed.
return res.status(400).json({ error: 'verified body is not valid JSON' });
}
process_(webhookId, envelope);
return res.status(200).json({ received: true });
}
);
function timestampIsFresh(timestampHeader) {
// Number.parseInt would accept "300abc" as 300. Require the whole value to be
// digits, so the three samples agree on which timestamps are valid.
const raw = timestampHeader.trim();
if (!/^-?\d+$/.test(raw)) {
return false;
}
const sent = Number(raw);
if (!Number.isFinite(sent)) {
return false;
}
const skew = Math.abs(Date.now() / 1000 - sent);
return skew <= TIMESTAMP_TOLERANCE_SECONDS;
}
/**
* webhook-signature is a space-separated list of "version,base64value" entries.
* Accept the delivery if ANY entry verifies, and ignore versions you do not know.
* Returns VERIFIED, KEY_UNAVAILABLE when a v1a entry could not be checked because
* its key is not loaded, or FAILED.
*/
async function verifySignatures(header, toVerify, keyId) {
let keyUnavailable = false;
for (const entry of header.trim().split(/\s+/)) {
const comma = entry.indexOf(',');
if (comma < 0) {
continue;
}
const version = entry.slice(0, comma);
const value = entry.slice(comma + 1);
if (version === 'v1' && verifySymmetric(toVerify, value)) {
return VERIFIED;
}
if (version === 'v1a') {
const outcome = await verifyAsymmetric(toVerify, value, keyId);
if (outcome === VERIFIED) {
return VERIFIED;
}
keyUnavailable = keyUnavailable || outcome === KEY_UNAVAILABLE;
}
// Unknown version tag: ignore this entry.
}
return keyUnavailable ? KEY_UNAVAILABLE : FAILED;
}
function verifySymmetric(toVerify, base64Signature) {
if (!SIGNING_SECRET || !SIGNING_SECRET.startsWith('whsec_')) {
return false;
}
try {
// The key is the secret with whsec_ stripped, then base64-DECODED.
const key = Buffer.from(SIGNING_SECRET.slice('whsec_'.length), 'base64');
const expected = crypto.createHmac('sha256', key).update(toVerify).digest();
const presented = Buffer.from(base64Signature, 'base64');
if (expected.length !== presented.length) {
return false;
}
// Constant-time comparison.
return crypto.timingSafeEqual(expected, presented);
} catch {
return false;
}
}
async function verifyAsymmetric(toVerify, base64Signature, keyId) {
// Buffer.from never throws on bad base64; it drops what it cannot decode.
// That is safe HERE only because Ed25519 verification independently rejects a
// wrong-length signature. In the symmetric path the explicit length check
// before timingSafeEqual is what makes the same leniency safe — do not read
// this as lenient decoding being harmless in general.
const presented = Buffer.from(base64Signature, 'base64');
// webhook-key-id is an opaque lookup hint. It proves nothing and may be
// absent, so try that key first and fall back to every key held.
let hintedKeyUnknown = false;
if (keyId) {
const hinted = await publicKeyFor(keyId);
if (!hinted) {
hintedKeyUnknown = true;
} else if (ed25519Verifies(hinted, toVerify, presented)) {
return VERIFIED;
}
}
// The fallback has to work with no key id at all, which is why the load is
// here and not only inside publicKeyFor: on a cold cache a delivery carrying
// no webhook-key-id would otherwise have nothing to try.
await ensureKeysLoaded();
// Read the reference once: a refresh swaps in a new Map rather than mutating
// this one, so the loop iterates a stable snapshot.
const keys = jwksCache;
for (const key of keys.values()) {
if (ed25519Verifies(key, toVerify, presented)) {
return VERIFIED;
}
}
// Nothing we hold verifies it. If the hint names a key we could not load, or we
// hold no keys at all because the key set could not be fetched, we cannot tell
// a forgery from a key rotated in moments ago, so ask PPS to retry.
if (hintedKeyUnknown || keys.size === 0) {
return KEY_UNAVAILABLE;
}
return FAILED;
}
function ed25519Verifies(key, toVerify, signature) {
try {
// Ed25519 takes no separate digest algorithm, so the first argument is null.
return crypto.verify(null, toVerify, key, signature);
} catch {
return false;
}
}
/**
* Returns the cached key for a kid, refetching the JWKS when the kid is unknown
* and the rate limiter allows it. Inside the limiter's window the refetch is
* skipped entirely. Still unknown either way: return null, the handler answers
* 503, and the PPS retry redelivers it signed fresh.
*/
async function publicKeyFor(kid) {
const cached = jwksCache.get(kid);
if (cached) {
return cached;
}
await refreshIfAllowed();
return jwksCache.get(kid) ?? null;
}
/** Loads the key set when nothing is cached yet, through the same rate limiter. */
async function ensureKeysLoaded() {
if (jwksCache.size === 0) {
await refreshIfAllowed();
}
}
/**
* Refetches at most once per interval, so unknown kids cannot become a request
* storm. Inside the interval the refetch is SKIPPED, so a kid rotated in moments
* ago is answered 503 until the interval elapses; the PPS retry then redelivers it.
*/
async function refreshIfAllowed() {
if (Date.now() < nextJwksFetchAllowedAt) {
return;
}
// Join a refresh already under way rather than starting a second one.
if (inFlightRefresh) {
return inFlightRefresh;
}
// Arm BEFORE awaiting. The await yields the event loop, so leaving the limiter
// open across the fetch would let every delivery arriving during that round trip
// pass the check and start its own: a rotation under concurrent traffic then
// becomes one upstream request per in-flight delivery.
nextJwksFetchAllowedAt = Date.now() + JWKS_REFETCH_INTERVAL_MS;
inFlightRefresh = (async () => {
try {
// The clock is corrected by the OUTCOME: a failed fetch backs off briefly
// rather than spending the full interval, so one transient blip does not
// reject every v1a delivery until the interval expires.
const succeeded = await refreshJwks();
nextJwksFetchAllowedAt =
Date.now() + (succeeded ? JWKS_REFETCH_INTERVAL_MS : JWKS_RETRY_AFTER_FAILURE_MS);
} finally {
inFlightRefresh = null;
}
})();
return inFlightRefresh;
}
/** Returns true when the key set was replaced, false when the fetch failed. */
async function refreshJwks() {
try {
// NEVER hardcode a JWKS path. Read jwks_uri out of the discovery document.
const discoveryResponse = await fetch(DISCOVERY_URL);
if (!discoveryResponse.ok) {
return false;
}
const { jwks_uri: jwksUri } = await discoveryResponse.json();
const jwksResponse = await fetch(jwksUri);
if (!jwksResponse.ok) {
return false;
}
const { keys } = await jwksResponse.json();
// Build the replacement first, then swap it in one statement, so no request
// interleaving on an await ever observes a half-filled cache.
const fresh = new Map();
for (const jwk of keys ?? []) {
if (jwk.kty !== 'OKP' || jwk.crv !== 'Ed25519') {
continue;
}
fresh.set(jwk.kid, crypto.createPublicKey({ key: jwk, format: 'jwk' }));
}
if (fresh.size === 0) {
// A key set carrying no usable Ed25519 key is a failed refresh, not an empty
// one: do not discard keys that are currently verifying deliveries.
return false;
}
jwksCache = fresh;
return true;
} catch {
// Leave the cache as it stands; the next delivery tries again shortly.
return false;
}
}
/** Your own handling. Delivery is at least once, so deduplicate before working. */
function process_(webhookId, envelope) {
if (alreadySeen(webhookId)) {
return;
}
// Queue the work and return; do it asynchronously rather than in the handler.
}
/** Replace with a durable store carrying a TTL; an in-process set forgets on restart. */
function alreadySeen(webhookId) {
return false;
}
app.listen(8080);
What to read next
- Receiving Deliveries — what to do once a delivery verifies.
- Endpoints API Reference — setting
signatureModeand rotating the signing secret.