Webhook Signature Verification
Webhook Signature Verification
Every webhook Yelp sends to your endpoint is signed with an Ed25519 digital signature. By verifying this signature, you can confirm three things: the payload genuinely came from Yelp, nothing was altered in transit, and it isn't a replay of an earlier delivery.
We strongly recommend all partners implement signature verification. It replaces the previous IP-allowlist approach and provides much stronger security guarantees.
What Your Endpoint Receives
Yelp adds three headers to every webhook delivery:
| Header | What it contains |
|---|---|
X-Yelp-Webhook-Signature | The Ed25519 signature, Base64-encoded (64 bytes when decoded) |
X-Yelp-Webhook-Key-Id | An identifier for the signing key (e.g., 2026-06-v1) |
X-Yelp-Webhook-Delivery-Id | A unique UUID for this specific delivery |
If you haven't implemented verification yet, you can safely ignore these headers — your existing integration will keep working.
How the Signature Is Built
Yelp signs a message constructed by joining three values with periods:
{client_id}.{delivery_id}.{body}
| Component | Where it comes from |
|---|---|
client_id | Your OAuth client ID (the one tied to your webhook subscription) |
delivery_id | The X-Yelp-Webhook-Delivery-Id header |
body | The raw HTTP request body, byte-for-byte as sent |
This format is unambiguous: client_id and delivery_id never contain periods, and the JSON body always starts with {.
How to Verify a Delivery
Step 1: Get Yelp's public keys
Call GET /v3/webhooks/keys to fetch the current signing keys. The response is a standard JWK Set:
{
"keys": [
{
"kty": "OKP",
"crv": "Ed25519",
"kid": "2026-06-v1",
"use": "sig",
"x": "ffqFbtjg3OeXpUtTMRAk1otf6Y2dj9I6m-37BLGB9qk"
}
]
}
This endpoint is unauthenticated and rate-limited. Cache the keys locally and only re-fetch when you see a kid you don't recognize.
Step 2: Match the key
Use the X-Yelp-Webhook-Key-Id header to find the matching kid in your cached key set. If there's no match, re-fetch from the keys endpoint. If there's still no match after a fresh fetch, reject the request.
Step 3: Reconstruct the signed message
Concatenate your client ID, the delivery ID from the header, and the raw request body, separated by periods:
"12345.a1b2c3d4-e5f6-7890-abcd-ef1234567890.{\"object\":\"business\",\"data\":{...}}"
Important: Use the raw request body bytes, not re-serialized JSON. If your framework parses the body before your handler runs, make sure you have access to the original bytes. Re-serializing can change whitespace or key ordering, which will break verification.
Step 4: Verify the signature
- Base64url-decode the
xfield from the JWK to get the 32-byte public key. - Base64-decode the
X-Yelp-Webhook-Signatureheader to get the 64-byte signature. - Verify the Ed25519 signature against your reconstructed message.
- If verification fails, respond with HTTP
401.
Code Examples
Python
import base64
import requests
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
KEYS_URL = "https://api.yelp.com/v3/webhooks/keys"
CLIENT_ID = "ThncHZzfDNaNYYMXk88hLQ" # Your OAuth client ID
_cached_keys = {}
def fetch_keys():
response = requests.get(KEYS_URL)
response.raise_for_status()
for key in response.json()["keys"]:
public_bytes = base64.urlsafe_b64decode(key["x"] + "==")
_cached_keys[key["kid"]] = Ed25519PublicKey.from_public_bytes(public_bytes)
def get_public_key(kid: str) -> Ed25519PublicKey:
if kid not in _cached_keys:
fetch_keys()
return _cached_keys[kid]
def verify_webhook(request) -> bool:
signature_b64 = request.headers["X-Yelp-Webhook-Signature"]
key_id = request.headers["X-Yelp-Webhook-Key-Id"]
delivery_id = request.headers["X-Yelp-Webhook-Delivery-Id"]
body = request.get_data() # Raw bytes, not parsed JSON
message = f"{CLIENT_ID}.{delivery_id}.".encode() + body
signature = base64.b64decode(signature_b64)
try:
get_public_key(key_id).verify(signature, message)
return True
except Exception:
return False
Node.js
const crypto = require("crypto");
const KEYS_URL = "https://api.yelp.com/v3/webhooks/keys";
const CLIENT_ID = "ThncHZzfDNaNYYMXk88hLQ"; // Your OAuth client ID
const cachedKeys = new Map();
async function fetchKeys() {
const res = await fetch(KEYS_URL);
const { keys } = await res.json();
for (const key of keys) {
const publicKeyBytes = Buffer.from(key.x, "base64url");
const publicKey = crypto.createPublicKey({
key: Buffer.concat([
// Ed25519 keys need a DER/SPKI wrapper to use with Node's crypto API
Buffer.from("302a300506032b6570032100", "hex"),
publicKeyBytes,
]),
format: "der",
type: "spki",
});
cachedKeys.set(key.kid, publicKey);
}
}
async function getPublicKey(kid) {
if (!cachedKeys.has(kid)) {
await fetchKeys();
}
return cachedKeys.get(kid);
}
async function verifyWebhook(req) {
const signatureB64 = req.headers["x-yelp-webhook-signature"];
const keyId = req.headers["x-yelp-webhook-key-id"];
const deliveryId = req.headers["x-yelp-webhook-delivery-id"];
const body = req.rawBody; // Raw body buffer, not parsed JSON
const message = Buffer.concat([
Buffer.from(`${CLIENT_ID}.${deliveryId}.`),
body,
]);
const signature = Buffer.from(signatureB64, "base64");
const publicKey = await getPublicKey(keyId);
return crypto.verify(null, message, publicKey, signature);
}
Java
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.security.KeyFactory;
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.util.Base64;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
public class WebhookVerifier {
private static final String KEYS_URL = "https://api.yelp.com/v3/webhooks/keys";
private static final String CLIENT_ID = "ThncHZzfDNaNYYMXk88hLQ"; // Your OAuth client ID
private static final Map<String, PublicKey> cachedKeys = new ConcurrentHashMap<>();
private static void fetchKeys() throws Exception {
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(KEYS_URL))
.GET()
.build();
HttpResponse<String> response = client.send(request,
HttpResponse.BodyHandlers.ofString());
// Parse the JSON response and extract each key.
// For each key object, base64url-decode the "x" field into 32 bytes,
// then build an Ed25519 PublicKey:
//
// byte[] rawKey = Base64.getUrlDecoder().decode(xFieldValue);
// boolean xOdd = (rawKey[rawKey.length - 1] & 1) != 0;
// byte[] reversed = reverseArray(rawKey);
// EdECPoint point = new EdECPoint(xOdd, new java.math.BigInteger(1, reversed));
// KeyFactory kf = KeyFactory.getInstance("Ed25519");
// PublicKey pk = kf.generatePublic(
// new EdECPublicKeySpec(NamedParameterSpec.ED25519, point));
// cachedKeys.put(kidValue, pk);
}
public static PublicKey getPublicKey(String kid) throws Exception {
if (!cachedKeys.containsKey(kid)) {
fetchKeys();
}
return cachedKeys.get(kid);
}
public static boolean verifyWebhook(
String signatureB64,
String keyId,
String deliveryId,
byte[] body) throws Exception {
byte[] prefix = (CLIENT_ID + "." + deliveryId + ".").getBytes();
byte[] message = new byte[prefix.length + body.length];
System.arraycopy(prefix, 0, message, 0, prefix.length);
System.arraycopy(body, 0, message, prefix.length, body.length);
byte[] signature = Base64.getDecoder().decode(signatureB64);
PublicKey publicKey = getPublicKey(keyId);
Signature verifier = Signature.getInstance("Ed25519");
verifier.initVerify(publicKey);
verifier.update(message);
return verifier.verify(signature);
}
}
Protecting Against Replay Attacks
The X-Yelp-Webhook-Delivery-Id is a UUID that's unique to each delivery and is included in the signed message. This means an attacker can't re-send a previously captured webhook — the signature is valid only for that specific delivery ID.
To take full advantage of this:
- Keep a record of delivery IDs you've already processed (a cache or database table with a TTL of at least 24 hours works well).
- Before processing a webhook, check whether the delivery ID is already in your record.
- If it's a duplicate, return HTTP
409(or200if you prefer idempotent responses) and skip processing.
The 24-hour window is sufficient because Yelp stops retrying a failed delivery after 24 hours.
Handling Key Rotation
Normally Yelp uses a single signing key. During a key rotation, the GET /v3/webhooks/keys endpoint will temporarily return two keys — the old one and the new one. Some deliveries may still be signed with the old key while others use the new one.
You don't need to do anything special to handle this. If you follow the recommended approach of caching keys locally and re-fetching whenever you encounter an unrecognized kid, rotation is automatic. Once rotation completes, the old key is removed from the endpoint.
Security Best Practices
- Use the raw request body. Don't verify against re-serialized JSON. Parsing and re-encoding can change whitespace or key order, producing different bytes than what Yelp signed.
- Always include your
client_idwhen reconstructing the message. Theclient_idprevents cross-partner replay attacks — a webhook intercepted from one partner's endpoint can't be replayed against another partner, because verification would use a differentclient_id. - Reject unknown key IDs. If a
kidis still unrecognized after a fresh fetch from the keys endpoint, reject the webhook with HTTP401. - Don't roll your own Ed25519. The libraries in the examples above handle constant-time comparison and other subtleties internally. Use them.
Updated 19 days ago
