DEVELOPER GUIDE

Stable

Webhook Verification Guide

Verifying Webhook Authenticity

A JWT in the Authorization header of the request allows you to validate that the request originated from the PointServices platform. The JWT includes claims and a signature.

The JWT signature covers the token’s claims, and the body_sha256 claim covers the request body. Both checks must pass before you read any field from the body. A valid signature alone does not mean the body is genuine — without the digest check, a valid token can be replayed against a substituted body.

The JWT is an OpenIDConnect JWT that consists of a header, claim set, and signature. The webhook service encodes the JWT as a base64 string with period delimiters.

For example, the following authorization header includes an encoded JWT:

"Authorization" : "Bearer eyJraWQiOiIxMjM0NSIsInR5cCI6IkpXVCIsImFsZyI6IlJTMjU2In0.eyJpc3MiOiJodHRwczovL3NlY3VyZXRva2VuLmV4YW1wbGUuY29tL3Rlc3QtdGVuYW50IiwiYXVkIjoidGVzdC10ZW5hbnQiLCJpYXQiOjE3MDg3MTU2MDIsImV4cCI6MTcwODcxNTkwMiwiYm9keV9zaGEyNTYiOiJlM2IwYzQ0Mjk4ZmMxYzE0OWFmYmY0Yzg5OTZmYjkyNDI3YWU0MWU0NjQ5YjkzNGNhNDk1OTkxYjc4NTJiODU1In0.<signature>"

The header and claim set are JSON strings. Once decoded, they take the following form:

{
  "kid": "12345",
  "typ": "JWT",
  "alg": "RS256"
}

{
  "iss": "https://securetoken.example.com/test-tenant",
  "aud": "test-tenant",
  "iat": 1708715602,
  "exp": 1708715902,
  "body_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
}
Claim Description
kid Identifies which key in the JWKS signed this token. Used to select the verification key.
typ Always JWT.
alg Always RS256. Pin this value in your verifier rather than trusting the token’s own header.
iss Identifies the sending platform. Use it as the key into your trusted-issuer map (see Resolving Signing Keys) — never build a URL from it.
aud The audience registered for your tenant.
iat When the token was issued, as a Unix timestamp.
exp When the token expires, as a Unix timestamp. Tokens are short-lived, which bounds replay.
body_sha256 Hex-encoded SHA-256 of the request body as sent. Compare against a hash of the raw received bytes.

The token carries no event metadata and no user identity — it exists only to prove the message came from PointServices and to bind that proof to the body. The event itself is in the request body, described in the Implementation Guide.

Resolving Signing Keys

The JWKS (JSON Web Key Set) URI is not derived from the issuer value. Your account representative provides the JWKS URI and audience for each tenant during onboarding.

Keep these values in a map of trusted issuers, keyed by the iss claim. When a webhook arrives, look up its issuer in the map to find the JWKS URI to fetch signing keys from. An issuer that is not in the map has no entry, so it is rejected before any network request is made.

Caching JWKS

To avoid fetching the JWKS every time a token needs to be validated, cache the signing keys with a timed expiration — hourly is a reasonable default.

Verifying the Body Digest

A valid signature proves the token came from PointServices. It does not prove the body arrived unaltered — the two are separate objects, and a valid token can be replayed against a substituted body. The body_sha256 claim closes that gap.

After the signature verifies, hash the request body and compare it to body_sha256:

  1. Read the body as raw bytes, before any JSON parsing. Most frameworks parse the body for you by default, so this usually means opting out — express.raw() in Express (registered before the route), request.get_data() in Flask, getRequestBody().readAllBytes() in the JDK HTTP server.
  2. Hash those bytes, not a re-serialized object. Serializing the parsed body can reorder keys and change whitespace, so the digest will not match even when the body is genuine.
  3. Compare in constant time, using your platform’s fixed-time comparison rather than string equality — crypto.timingSafeEqual, hmac.compare_digest, or MessageDigest.isEqual.
  4. Parse only after both checks pass. Until then the body is unverified input.

A mismatch means the body is not the one the token was issued for. Respond 403 without processing it.

Rejecting Invalid Requests

If the token is expired, the issuer is not in the trusted list, or the audience is not valid for that issuer, the express-jwt middleware raises an exception that is handled by the express error handler. Once this exception is raised, your endpoint should return a 403 status code without processing the body of the webhook.

Webhook tokens are short-lived, so a receiver whose clock runs even slightly fast will reject genuine webhooks. Allow about 60 seconds of tolerance when checking exp. Keep it small — it widens the replay window by the same amount. Every JWT library exposes this: clockTolerance in express-jwt, leeway in PyJWT, and setMaxClockSkew on Nimbus’s DefaultJWTClaimsVerifier. All three examples below set it explicitly.

The full order is: signature → digest → parse → process. A failure at any step is a 403, and the body is never parsed or acted on.

See Responding to Webhooks in the Implementation Guide for the status codes your endpoint should return.

Example Code

Putting it together — each example verifies the signature, verifies the digest, and only then processes the event.

Node.js — Express and express-jwt
const express = require('express');
const { expressjwt } = require('express-jwt');
const jwksRsa = require('jwks-rsa');
const crypto = require('crypto');

const app = express();
const port = 3000;

// Capture the body as raw bytes so the digest can be computed over them.
// This must be registered BEFORE the route below: Express runs middleware in
// registration order, so a route declared earlier would never see req.body.
app.use(express.raw({ type: 'application/json' }));

// Trusted issuers, keyed by the `iss` claim of the incoming JWT.
// Your account representative provides the jwksUri and audience values
// for each tenant during onboarding.
const TRUSTED_ISSUERS = new Map([
  ['https://securetoken.example.com/test-tenant', {
    jwksUri: 'https://<provided-by-pitchpoint>/jwks',
    audience: 'test-tenant',
  }],
]);

// One JWKS client per trusted issuer. Each client caches signing keys in
// memory and rate limits requests back to the issuer.
const jwksClients = new Map(
  [...TRUSTED_ISSUERS].map(([issuer, { jwksUri }]) => [
    issuer,
    jwksRsa({
      jwksUri,
      cache: true,              // Cache signing keys
      cacheMaxAge: 3600000,     // Refresh hourly
      rateLimit: true,
      jwksRequestsPerMinute: 5, // Prevent excessive requests to the issuer
    }),
  ])
);

// Resolve the signing key for a token by looking up its issuer.
// An unrecognized issuer has no map entry, so it is rejected before any
// network request is made.
async function getSigningKey(req, token) {
  const issuer = token?.payload?.iss;
  const client = jwksClients.get(issuer);

  if (!client) {
    throw new Error(`Untrusted issuer: ${issuer}`);
  }

  const key = await client.getSigningKey(token.header.kid);
  return key.getPublicKey();
}

const checkJwt = expressjwt({
  secret: getSigningKey,
  algorithms: ['RS256'],       // pin: never honor the token's own alg
  // Verify the audience matches the one registered for this token's issuer.
  audience: [...TRUSTED_ISSUERS.values()].map((t) => t.audience),
  issuer: [...TRUSTED_ISSUERS.keys()],
  clockTolerance: 60,          // seconds of allowed clock drift
});

// Verify that the body is the exact one the token was issued for.
// req.body is a Buffer here — hash the bytes as received. Parsing first and
// re-serializing can reorder keys or change whitespace, and the digest would not match.
function checkBodyDigest(req, res, next) {
  const expected = Buffer.from(req.auth.body_sha256 || '', 'hex');
  const actual = crypto.createHash('sha256').update(req.body).digest();

  if (expected.length !== actual.length || !crypto.timingSafeEqual(expected, actual)) {
    return res.status(403).send('Body digest mismatch');
  }

  req.event = JSON.parse(req.body.toString('utf8')); // parse only after verifying
  next();
}

// Placeholder for business logic processing
function processBusinessLogic(req, res) {
  // Implement your business logic here.
  // req.event is trustworthy at this point: signature verified, digest matched.
  console.log('Topic:', req.event.topic);
  console.log('Tenant:', req.event.tenantId);
  console.log('Webhook id:', req.event.webhookId);
  console.log('Payload:', req.event.data);
  res.sendStatus(200);
}

// Your endpoint: verify the token, then the body digest, then process.
app.post('/receive-webhook', checkJwt, checkBodyDigest, (req, res) => {
  processBusinessLogic(req, res);
});

// Error handling for unauthorized access
app.use((err, req, res, next) => {
  if (err.name === 'UnauthorizedError') {
    res.status(403).send('Invalid token');
  } else {
    next(err);
  }
});

app.listen(port, () => {
  console.log(`Server listening at http://localhost:${port}`);
});

Look up the JWKS URI from your own map of trusted issuers, keyed by the token’s iss claim. Never derive it from the issuer value in an unverified token, and never fetch keys from an issuer that is not in the map — doing either lets an attacker point key resolution at a server they control.

Hash the raw request bytes, not a re-serialized object — { "a": 1 } and {"a":1} parse identically but hash differently. Register express.raw before the route, or req.body will be empty when the digest is computed.

Python 3 — Flask and PyJWT
import hashlib
import hmac

import jwt
from flask import Flask, request

app = Flask(__name__)

# Trusted issuers, keyed by the `iss` claim of the incoming JWT.
# Your account representative provides the jwks_uri and audience values
# for each tenant during onboarding.
TRUSTED_ISSUERS = {
    "https://securetoken.example.com/test-tenant": {
        "jwks_uri": "https://<provided-by-pitchpoint>/jwks",
        "audience": "test-tenant",
    },
}

# One JWKS client per trusted issuer. Each client caches signing keys in
# memory so they are not refetched on every request.
_jwks_clients = {
    issuer: jwt.PyJWKClient(config["jwks_uri"], cache_keys=True, lifespan=3600)
    for issuer, config in TRUSTED_ISSUERS.items()
}


class Unauthorized(Exception):
    pass


def verify_request(auth_header: str, raw_body: bytes) -> dict:
    """Verify the token and the body digest. Returns the claims, or raises."""
    if not auth_header or not auth_header.startswith("Bearer "):
        raise Unauthorized("missing bearer token")
    token = auth_header[len("Bearer "):]

    # Read the issuer without verifying, only to select a key. An issuer that
    # is not in the map is rejected before any network request is made.
    unverified = jwt.decode(token, options={"verify_signature": False})
    issuer = unverified.get("iss")
    config = TRUSTED_ISSUERS.get(issuer)
    if config is None:
        raise Unauthorized(f"untrusted issuer: {issuer}")

    signing_key = _jwks_clients[issuer].get_signing_key_from_jwt(token)
    claims = jwt.decode(
        token,
        signing_key.key,
        algorithms=["RS256"],   # pin: never honor the token's own alg
        audience=config["audience"],
        issuer=issuer,
        leeway=60,              # seconds of allowed clock drift
    )

    # Hash the bytes as received. Re-serializing the parsed JSON could reorder
    # keys or change whitespace, and the digest would not match.
    expected = claims.get("body_sha256", "")
    actual = hashlib.sha256(raw_body).hexdigest()
    if not hmac.compare_digest(expected, actual):
        raise Unauthorized("body digest mismatch")

    return claims


@app.post("/receive-webhook")
def receive_webhook():
    try:
        verify_request(request.headers.get("Authorization", ""), request.get_data())
    except Unauthorized as exc:
        return str(exc), 403

    # The event is trustworthy at this point: signature verified, digest matched.
    event = request.get_json()
    print("Topic:", event["topic"])
    print("Tenant:", event["tenantId"])
    print("Webhook id:", event["webhookId"])
    print("Payload:", event["data"])
    return "", 200


if __name__ == "__main__":
    app.run(port=3000)

Use request.get_data() to hash the body, not request.get_json(). Hashing a re-serialized object will not match the digest — { "a": 1 } and {"a":1} parse identically but hash differently. Compare digests with hmac.compare_digest, not ==.

Java 25 — Nimbus JOSE + JWT
import com.nimbusds.jose.JWSAlgorithm;
import com.nimbusds.jose.jwk.source.JWKSource;
import com.nimbusds.jose.jwk.source.JWKSourceBuilder;
import com.nimbusds.jose.proc.JWSVerificationKeySelector;
import com.nimbusds.jose.proc.SecurityContext;
import com.nimbusds.jwt.JWTClaimsSet;
import com.nimbusds.jwt.JWTParser;
import com.nimbusds.jwt.proc.ConfigurableJWTProcessor;
import com.nimbusds.jwt.proc.DefaultJWTClaimsVerifier;
import com.nimbusds.jwt.proc.DefaultJWTProcessor;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HashMap;
import java.util.HexFormat;
import java.util.Map;
import java.util.Set;

public class WebhookListener {

    record IssuerConfig(String jwksUri, String audience) {}

    // Trusted issuers, keyed by the `iss` claim of the incoming JWT.
    // Your account representative provides the jwksUri and audience values
    // for each tenant during onboarding.
    static final Map<String, IssuerConfig> TRUSTED_ISSUERS = Map.of(
        "https://securetoken.example.com/test-tenant",
        new IssuerConfig("https://jwks.example.com/jwks", "test-tenant"));

    // One JWT processor per trusted issuer, each with its own cached,
    // rate-limited JWKS source.
    static final Map<String, ConfigurableJWTProcessor<SecurityContext>> PROCESSORS = new HashMap<>();

    static {
        TRUSTED_ISSUERS.forEach((issuer, config) -> PROCESSORS.put(issuer, buildProcessor(issuer, config)));
    }

    static ConfigurableJWTProcessor<SecurityContext> buildProcessor(String issuer, IssuerConfig config) {
        try {
            JWKSource<SecurityContext> keySource = JWKSourceBuilder
                .create(URI.create(config.jwksUri()).toURL())
                .cache(3_600_000L, 30_000L)  // refresh hourly
                .rateLimited(5)              // cap refreshes per minute
                .build();

            ConfigurableJWTProcessor<SecurityContext> processor = new DefaultJWTProcessor<>();
            // Pin RS256: never honor the algorithm named in the token itself.
            processor.setJWSKeySelector(new JWSVerificationKeySelector<>(JWSAlgorithm.RS256, keySource));

            var claimsVerifier = new DefaultJWTClaimsVerifier<SecurityContext>(
                config.audience(),
                new JWTClaimsSet.Builder().issuer(issuer).build(),
                Set.of("exp", "body_sha256"));    // both are required
            claimsVerifier.setMaxClockSkew(60);   // seconds of allowed clock drift
            processor.setJWTClaimsSetVerifier(claimsVerifier);
            return processor;
        } catch (Exception e) {
            throw new IllegalStateException("Cannot build processor for " + issuer, e);
        }
    }

    static class UnauthorizedException extends Exception {
        UnauthorizedException(String message) { super(message); }
    }

    /** Verify the token and the body digest. Returns the claims, or throws. */
    static JWTClaimsSet verify(String authHeader, byte[] rawBody) throws UnauthorizedException {
        if (authHeader == null || !authHeader.startsWith("Bearer ")) {
            throw new UnauthorizedException("Missing bearer token");
        }
        String token = authHeader.substring("Bearer ".length());
        try {
            // Read the issuer without verifying, only to select a processor. An
            // issuer that is not in the map is rejected before any network request.
            String issuer = JWTParser.parse(token).getJWTClaimsSet().getIssuer();
            ConfigurableJWTProcessor<SecurityContext> processor = PROCESSORS.get(issuer);
            if (processor == null) {
                throw new UnauthorizedException("Untrusted issuer: " + issuer);
            }

            JWTClaimsSet claims = processor.process(token, null);

            // Hash the bytes as received. Re-serializing parsed JSON could reorder
            // keys or change whitespace, and the digest would not match.
            String expected = claims.getStringClaim("body_sha256");
            String actual = sha256Hex(rawBody);
            if (expected == null || !MessageDigest.isEqual(
                    expected.getBytes(StandardCharsets.UTF_8),
                    actual.getBytes(StandardCharsets.UTF_8))) {
                throw new UnauthorizedException("Body digest mismatch");
            }
            return claims;
        } catch (UnauthorizedException e) {
            throw e;
        } catch (Exception e) {
            throw new UnauthorizedException("Invalid token: " + e.getMessage());
        }
    }

    static String sha256Hex(byte[] data) throws Exception {
        return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(data));
    }

    public static void main(String[] args) throws IOException {
        HttpServer server = HttpServer.create(new InetSocketAddress(3000), 0);

        server.createContext("/receive-webhook", exchange -> {
            byte[] rawBody;
            try (InputStream in = exchange.getRequestBody()) {
                rawBody = in.readAllBytes();
            }

            try {
                verify(exchange.getRequestHeaders().getFirst("Authorization"), rawBody);
            } catch (UnauthorizedException e) {
                respond(exchange, 403, e.getMessage());
                return;
            }

            // The body is trustworthy at this point: signature verified, digest matched.
            // Parse it with your JSON library of choice and process the event.
            System.out.println("Verified event: " + new String(rawBody, StandardCharsets.UTF_8));
            respond(exchange, 200, "");
        });

        server.start();
        System.out.println("Server listening at http://localhost:3000");
    }

    static void respond(HttpExchange exchange, int status, String body) throws IOException {
        byte[] out = body.getBytes(StandardCharsets.UTF_8);
        exchange.sendResponseHeaders(status, out.length);
        try (OutputStream os = exchange.getResponseBody()) {
            os.write(out);
        }
    }
}

Add the JWT library to your build:

<dependency>
  <groupId>com.nimbusds</groupId>
  <artifactId>nimbus-jose-jwt</artifactId>
  <version>10.0.1</version>
</dependency>

Read the body with getRequestBody().readAllBytes() and hash those bytes. Hashing a re-serialized object will not match the digest — { "a": 1 } and {"a":1} parse identically but hash differently. Compare digests with MessageDigest.isEqual, not String.equals.

The static initializer builds each issuer’s JWKS source at class load, so an unreachable or malformed jwksUri fails at startup rather than on the first webhook. That is usually what you want — the misconfiguration surfaces on deploy — but it means the placeholder above must be replaced with the real value before the listener will start.


Copyright © Pitchpoint Solutions. All rights reserved.