DEVELOPER GUIDE
Stable
Webhook Endpoint Implementation Guide
Overview
Your system needs to implement a webhook endpoint to receive real-time notifications from our services. Each webhook message carries the event metadata and payload in the JSON request body, and a JWT in the Authorization header that signs that body.
Before You Start
Your endpoint must be registered before it will receive any events. See Registering for the details you need to provide.
Webhook Message Format
A webhook is an HTTP POST with a JSON body. The event metadata and payload are both in that body, and the JWT in the Authorization header carries a body_sha256 claim binding the token to the exact bytes of the body.
POST /receive-webhook
Authorization: Bearer <JWT>
Content-Type: application/json
{
"webhookId": "279e4e55-dfa0-4e04-b717-148ae547ab7d",
"topic": "orders/placed",
"subTopic": "product:adv/adv-120",
"tenantId": "OU1243",
"triggeredAt": "2024-01-01T10:00:00.7777748Z",
"actorId": "xpps|1223",
"data": {
"orderId": "12345"
}
}
| Field | Description |
|---|---|
webhookId | Unique identifier for this event. Your endpoint may receive the same event more than once; use this to detect duplicates. |
topic | The name of the event (e.g., orders/placed). |
subTopic | The subtype of the event (e.g., product:adv/adv-120). |
tenantId | The organizational unit that generated the event (e.g., OU1243). |
triggeredAt | UTC timestamp when the event was triggered, in ISO 8601 format. Fractional seconds are given to 7 digits, so parse with a library that accepts sub-millisecond precision rather than a fixed 3-digit format. |
actorId | Opaque identifier for the user or system that triggered the event. Deliberately not an email address or name — resolve it to a person through the API if you need to display one. See the example body above for its format. |
data | The event payload. Its structure depends on topic. |
Verify the JWT signature and the body_sha256 digest before reading any field above. Until both checks pass, the body is unverified input. See the Verification Guide.
Handling Webhooks
Processing Webhook Events
- Use
topicto determine the type of event and process accordingly. - Check
webhookIdto avoid processing duplicate events. - Read the event payload from
data.
Responding to Webhooks
- Your endpoint should respond with a
200 OKstatus code after successfully processing the webhook. - If your endpoint is unable to authorize the webhook, respond with a
403 Forbiddenstatus code. - If your endpoint is unable to handle the webhook, respond with an appropriate error code (e.g.,
500 Internal Server Error). - If your endpoint returns a status code >=
500, the data will be resent using an exponential backoff algorithm.
Example Code
Each example verifies and processes a webhook request. See Verifying the Body Digest for what each verification step is doing and why.
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.