Verify Signatures

Verify Edesy webhook signatures - X-Edesy-Signature HMAC-SHA256 for subscriptions and X-Webhook-Signature for status callbacks - with Node.js and Python code.

1 min read

When a subscription has a secret, every delivery carries X-Edesy-Signature: sha256=<hex>, the HMAC-SHA256 of the raw request body keyed with that secret. Recompute it over the exact bytes you received and compare in constant time; reject the request if it does not match. Status callbacks use a separate, timestamped scheme described at the end.

Subscription signatures#

POST /edesy/events
Content-Type: application/json
X-Edesy-Signature: sha256=9d4f0c...b72a

Steps:

  1. Read the raw body before any JSON parsing. Re-serialising parsed JSON changes the bytes and breaks the signature.
  2. Compute "sha256=" + hex(HMAC_SHA256(secret, raw_body)).
  3. Compare with X-Edesy-Signature using a constant-time comparison.
  4. De-duplicate on data.call_sid plus event, because retries and replays can deliver the same event twice.

Node.js (Express)#

const crypto = require('crypto');
const express = require('express');

const app = express();
const SECRET = process.env.EDESY_SUBSCRIPTION_SECRET;

app.post('/edesy/events', express.raw({ type: 'application/json' }), (req, res) => {
  const received = req.get('X-Edesy-Signature') || '';
  const expected = 'sha256=' + crypto.createHmac('sha256', SECRET).update(req.body).digest('hex');

  const valid = received.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected));
  if (!valid) return res.sendStatus(401);

  res.sendStatus(200); // respond first, then process
  const { event, data } = JSON.parse(req.body.toString('utf8'));
  queueForProcessing(event, data);
});

function queueForProcessing(event, data) {
  console.log(event, data.call_sid, data.external_id);
}

app.listen(3000);

Python (Flask)#

import hashlib, hmac, os
from flask import Flask, request, abort

app = Flask(__name__)
SECRET = os.environ["EDESY_SUBSCRIPTION_SECRET"].encode()

@app.post("/edesy/events")
def events():
    received = request.headers.get("X-Edesy-Signature", "")
    expected = "sha256=" + hmac.new(SECRET, request.get_data(), hashlib.sha256).hexdigest()
    if not hmac.compare_digest(received, expected):
        abort(401)

    payload = request.get_json()
    print(payload["event"], payload["data"].get("call_sid"))
    return "", 200

Status callback signatures#

Status callbacks and answer URL requests carry:

X-Webhook-Timestamp: 1761643841
X-Webhook-Signature: v1=<hex>

The value after v1= is the hex HMAC-SHA256 of "<timestamp>.<raw body>". Check the signature and reject timestamps older than a few minutes to stop replays. Code for both languages is on Status Callbacks. The status-callback signing secret is not shown in the dashboard yet; ask us for it through /contact.

Other checks#

  • Only accept HTTPS on your endpoint.
  • Treat the webhook as a notification and, for anything money-related, read the call back with GET/v1/calls/{CallSid}.
  • Rotate secrets by updating the subscription and accepting old and new signatures briefly.
Was this page helpful?