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:

HeaderWhat it contains
X-Yelp-Webhook-SignatureThe Ed25519 signature, Base64-encoded (64 bytes when decoded)
X-Yelp-Webhook-Key-IdAn identifier for the signing key (e.g., 2026-06-v1)
X-Yelp-Webhook-Delivery-IdA 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}
ComponentWhere it comes from
client_idYour OAuth client ID (the one tied to your webhook subscription)
delivery_idThe X-Yelp-Webhook-Delivery-Id header
bodyThe 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

  1. Base64url-decode the x field from the JWK to get the 32-byte public key.
  2. Base64-decode the X-Yelp-Webhook-Signature header to get the 64-byte signature.
  3. Verify the Ed25519 signature against your reconstructed message.
  4. 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:

  1. 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).
  2. Before processing a webhook, check whether the delivery ID is already in your record.
  3. If it's a duplicate, return HTTP 409 (or 200 if 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_id when reconstructing the message. The client_id prevents cross-partner replay attacks — a webhook intercepted from one partner's endpoint can't be replayed against another partner, because verification would use a different client_id.
  • Reject unknown key IDs. If a kid is still unrecognized after a fresh fetch from the keys endpoint, reject the webhook with HTTP 401.
  • Don't roll your own Ed25519. The libraries in the examples above handle constant-time comparison and other subtleties internally. Use them.

Did this page help you?