WEBHOOKS

Verifying webhook signatures

X-Mesh-Signature OPTIONAL

When you create a webhook, Mesh shows you a signing secret once. You may never need it. This page explains what it is, why you get one even when you chose no authentication, when it is worth using, and exactly how to check it.

The short version

  • It is not a password and not a login. Mesh never uses it to get into your endpoint.
  • It is a way for you to check us. Every request we send carries a signature computed with your secret. Recomputing it proves the request came from Mesh and was not altered on the way.
  • Using it is optional. If your endpoint is happy to accept what it receives, ignore the secret and close the page.
  • Nothing is lost if you skip it. The secret is shown once, but Rotate secret on the webhook issues a new one whenever you want.

Why you get a secret even when you chose “None”

The webhook form asks how Mesh should authenticate to your endpoint — bearer token, basic auth, and so on. None means exactly that: we send no credentials at all. It is the right answer for a public URL that accepts anything, and it is a real none.

The signature points the other way. It is not how we get in; it is how you can tell that a request is genuinely ours. Those are independent, so every delivery is signed whatever you picked in that dropdown, and every subscription gets a secret.

It matters most precisely when you chose “None”

An endpoint with no authentication accepts a POST from anyone who learns the URL — and webhook URLs leak, through logs, screenshots and browser history. The signature is the only thing that separates a real delivery from a forged one. If your events cause something to happen — a refund, a message to a customer, a status change — that distinction is worth the twenty lines below. If they land in a log nobody acts on, it is not.

What kind of key it is

It is a shared secret, not a public key. HMAC is symmetric: there is one value, we hold a copy and you hold the same copy, and that same value both produces the signature and checks it. There is no separate private key on our side, and no public half that is safe to publish.

Treat it like a password

Anyone holding this value can forge a delivery your endpoint will believe. Do not commit it, do not put it in a client-side bundle, and do not paste it into a support ticket. If it is exposed, rotate it.

One secret per webhook

Each subscription gets its own, generated independently at the moment you create it — 32 random bytes, hex-encoded. It is not shared across your webhooks and not shared across the platform. Two webhooks in the same account have two unrelated secrets, so rotating one or leaking one does not affect the other.

What arrives

Every delivery is a POST with a JSON body and these headers:

HeaderContains
X-Mesh-Signaturet=<unix-seconds>,v1=<hex> — when we signed, and the HMAC-SHA256 digest.
X-Mesh-Subscription-IdWhich subscription produced this delivery, so you know which secret to verify against.
X-Mesh-Event-TypeThe event type, for example stop.completed. Also present in the body.
The timestamp is not part of the signed data

Some providers sign <timestamp>.<body>. Mesh does not: v1 is the HMAC of the request body alone. Sign the raw body and nothing else, or your comparison will never match. The t value is informational — because it is not covered by the signature, do not rely on it to reject replays.

How to verify

Compute HMAC-SHA256 over the exact bytes of the body you received, keyed by the signing secret, and compare it to the v1 value with a constant-time comparison.

Read the raw body, not a re-serialised object

This is where verification usually goes wrong. If your framework parses the JSON and you re-serialise it before hashing, the bytes will differ — key order, whitespace, number formatting — and every signature will look invalid. Capture the raw body first.

Node (Express)

import { createHmac, timingSafeEqual } from "node:crypto";

// Raw body, not express.json() -- a parsed object will not round-trip.
app.post("/webhooks/mesh", express.raw({ type: "application/json" }), (req, res) => {
  const header = req.get("X-Mesh-Signature") ?? "";
  const received = Object.fromEntries(
    header.split(",").map((part) => part.split("=", 2)),
  ).v1 ?? "";

  const expected = createHmac("sha256", process.env.MESH_SIGNING_SECRET)
    .update(req.body)            // Buffer of the raw bytes
    .digest("hex");

  const a = Buffer.from(expected, "hex");
  const b = Buffer.from(received, "hex");
  if (a.length !== b.length || !timingSafeEqual(a, b)) {
    return res.status(400).send("bad signature");
  }

  res.status(200).send("ok");    // Acknowledge fast; do the work afterwards.
});

Python (Flask)

import hmac, hashlib, os
from flask import request

@app.post("/webhooks/mesh")
def mesh_webhook():
    header = request.headers.get("X-Mesh-Signature", "")
    parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)

    expected = hmac.new(
        os.environ["MESH_SIGNING_SECRET"].encode(),
        request.get_data(),      # raw bytes, before any JSON parsing
        hashlib.sha256,
    ).hexdigest()

    if not hmac.compare_digest(expected, parts.get("v1", "")):
        return "bad signature", 400

    return "ok", 200
Use a constant-time comparison

timingSafeEqual or compare_digest, not ==. An ordinary string comparison returns as soon as it reaches a differing byte, and how long it took leaks how much of a guess was right.

Rotating, and losing the secret

The secret is displayed once, when the webhook is created. We keep a copy to sign with, but the portal will not show it to you again.

If you did not save it, nothing is broken: open the webhook in the portal and choose Rotate secret to get a fresh one. Rotation takes effect immediately — the old secret stops being used the moment the new one is issued, so update your receiver at the same time. If you are not verifying signatures at all, ignore this entirely.

What the signature does not do

  • It does not stop deliveries arriving. Anyone who knows the URL can still POST to it; the signature only lets you tell their request from ours.
  • It does not prevent replays. A genuine delivery captured and sent again still carries a valid signature. If that matters, de-duplicate on event_id from the body, which is unique per event.
  • It does not encrypt anything. The body travels in the clear inside TLS; the signature only proves it was not altered.
  • It is not how Mesh authenticates to you. That is the destination-authentication setting on the webhook, and it is a separate thing.