Skip to content

Verifying signatures ​

Anyone who learns your endpoint's URL can post to it. The signature on each delivery is how you know a request came from Esfera and that its body was not altered on the way. Verify it before you act on anything in the body. This page gives the exact scheme, working code in the Node SDK, plain Node and Python, and a known-answer vector so you can test your implementation before you go live.

The scheme ​

Esfera signs every delivery with the endpoint's secret and puts the result in the X-Esfera-Signature header:

text
X-Esfera-Signature: t=1756200000,v1=ab07d307bf84e71631e80b57851f4b05c40a1e33ea3c2498b4722906de611bc7
  • t is the Unix time in seconds at which that attempt was signed.
  • v1 is the lowercase hexadecimal HMAC-SHA256 of the string <t>.<raw body>, a decimal timestamp, a full stop, then the body exactly as it was sent. The key is the endpoint's secret, the whole whsec_esf_… string including its prefix, as UTF-8 bytes.
  • The scheme has the same shape as the one many payment providers use, so existing verification code is easy to adapt. The header name differs and there is only one v1 value.

To verify a delivery:

  1. Read the raw request body as bytes. Do not parse and re-serialise it: a different key order or spacing gives a different HMAC.
  2. Split the header into t and v1. If either is missing or v1 is not 64 hex characters, reject.
  3. Reject if the absolute difference between now and t is more than 300 seconds. This stops an old capture being replayed at you.
  4. Compute HMAC-SHA256 of "<t>.<raw body>" with your secret and compare it with v1 in constant time.
  5. Only then parse the JSON.

Every attempt is signed afresh, with a new t and so a new v1, even when it re-sends the same body. A retry that lands hours after the first attempt still passes the 300-second check, because the timestamp is the time of that attempt. A replay is signed afresh too.

There is no secret rotation. To change a secret, register a new endpoint (an account may have up to three active), switch over, and revoke the old one.

With the Node SDK ​

Esfera.webhooks.constructEvent verifies the signature, checks the timestamp and parses the body in one call. It throws SignatureVerificationError when anything is wrong. It needs no API key, and it runs on Node, Bun, Deno and Workers.

ts
import express from "express";
import { Esfera, SignatureVerificationError } from "@esfera/sdk";

const app = express();

// The raw body is required: use express.raw, not express.json, on this route.
app.post("/esfera", express.raw({ type: "application/json" }), async (req, res) => {
  try {
    const event = await Esfera.webhooks.constructEvent(
      req.body,                                    // a Buffer: the raw bytes
      req.header("X-Esfera-Signature") ?? "",
      process.env.ESFERA_WEBHOOK_SECRET!,          // whsec_esf_…
    );
    // Store the event, then answer. Do the work after responding.
    console.log(event.event, event.data.conversion.id, event.data.conversion.status);
    res.sendStatus(200);
  } catch (err) {
    if (err instanceof SignatureVerificationError) return res.sendStatus(400);
    throw err;
  }
});

Esfera.webhooks.verify(rawBody, header, secret) is the same check without the parsing. It returns true or false and never throws. Both accept { toleranceSec, now } as a fourth argument. The default tolerance is 300 seconds.

With plain Node ​

No SDK needed. This is the same algorithm with node:crypto:

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

export function verify(secret, header, rawBody, { toleranceSec = 300, nowMs = Date.now() } = {}) {
  const m = /^t=(\d+),v1=([0-9a-f]{64})$/.exec(header ?? "");
  if (!m || !secret) return false;
  const t = Number(m[1]);
  if (Math.abs(nowMs / 1000 - t) > toleranceSec) return false;
  const expected = createHmac("sha256", secret).update(`${t}.`).update(rawBody).digest();
  return timingSafeEqual(expected, Buffer.from(m[2], "hex"));
}

rawBody is a Buffer or a string, whichever your framework gives you before it parses.

With Python ​

python
import hashlib
import hmac
import re
import time

_HEADER = re.compile(r"^t=(\d+),v1=([0-9a-f]{64})$")


def verify(secret: str, header: str, raw_body: bytes, tolerance_sec: int = 300, now: float | None = None) -> bool:
    m = _HEADER.match(header or "")
    if not m or not secret:
        return False
    t = int(m.group(1))
    if abs((time.time() if now is None else now) - t) > tolerance_sec:
        return False
    expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, m.group(2))

In Flask, pass request.get_data(). In Django, request.body. In FastAPI, await request.body(). Take the bytes before any JSON parsing.

python
from flask import Flask, request, abort

app = Flask(__name__)

@app.post("/esfera")
def esfera():
    if not verify(SECRET, request.headers.get("X-Esfera-Signature", ""), request.get_data()):
        abort(400)
    event = request.get_json()
    # Store the event, then answer.
    return "", 200

A known-answer vector ​

Use this to check your implementation before you trust it. It does not use a real endpoint secret.

InputValue
Secretknown-answer-secret
t1756200000
Raw body{"a":1} (seven bytes, no trailing newline)
Signed string1756200000.{"a":1}
Headert=1756200000,v1=ab07d307bf84e71631e80b57851f4b05c40a1e33ea3c2498b4722906de611bc7

The vector comes from Esfera's own test suite, where it is checked against an independent HMAC implementation. Your code must produce that v1 from the first three rows. You can check it from a shell:

bash
printf '%s' '1756200000.{"a":1}' | openssl dgst -sha256 -hmac 'known-answer-secret'
# prints the digest: ab07d307bf84e71631e80b57851f4b05c40a1e33ea3c2498b4722906de611bc7

The timestamp is long past, so a verifier that checks tolerance will refuse the header in real time. To test the full function, pass a clock that matches t:

js
verify("known-answer-secret", "t=1756200000,v1=ab07d307bf84e71631e80b57851f4b05c40a1e33ea3c2498b4722906de611bc7", '{"a":1}', { nowMs: 1756200000_000 });   // true
verify("known-answer-secret", "t=1756200000,v1=ab07d307bf84e71631e80b57851f4b05c40a1e33ea3c2498b4722906de611bc7", '{"a":2}', { nowMs: 1756200000_000 });   // false: body changed
verify("known-answer-secret", "t=1756200000,v1=ab07d307bf84e71631e80b57851f4b05c40a1e33ea3c2498b4722906de611bc7", '{"a":1}', { nowMs: 1756200301_000 });   // false: 301 s old
python
H = "t=1756200000,v1=ab07d307bf84e71631e80b57851f4b05c40a1e33ea3c2498b4722906de611bc7"
assert verify("known-answer-secret", H, b'{"a":1}', now=1756200000)
assert not verify("known-answer-secret", H, b'{"a":2}', now=1756200000)
assert not verify("known-answer-secret", H, b'{"a":1}', now=1756200301)

With the SDK: await Esfera.webhooks.verify('{"a":1}', H, "known-answer-secret", { now: 1756200000_000 }) returns true.

Mistakes that break verification ​

  • Verifying a parsed-and-re-serialised body. Use the raw bytes.
  • Using express.json() before the route. It consumes the body. Use express.raw on the webhook route.
  • Trimming or normalising the body, including a trailing newline.
  • Comparing hex strings with ==. Use a constant-time comparison.
  • Using the endpoint's id or only the part of the secret after whsec_esf_. The key is the entire secret string.
  • A server clock that is more than five minutes out. Keep it on NTP.

developers.esfera.audio · the Esfera Converter API, SDK and CLI