Guide Developers

Verify the webhook signature (X-DS-Signature)

How to verify X-DS-Signature: sha256= plus HMAC-SHA256 of timestamp.body, exact header names, a test vector, and working PHP, Node.js and Python code with constant-time comparison and a timestamp check.

Last updated 22 October 2026 · Last verified 22 October 2026

Purpose#

Every outgoing webhook message carries an HMAC signature, so you can prove that it came from the platform and was not altered. This guide shows exactly what is signed and gives working verification code in PHP, Node.js and Python.

What you need#

  • The link's signing key, which starts with whsec_. In the panel it is shown in full right after you add the link, and afterwards behind the eye icon of "Signing key" (with copy and renew icons next to it). In the API it is returned once, in the response of POST /api/v1/webhooks. Renewing replaces it immediately and the old key stops working.
  • An endpoint that reads the raw request body, byte for byte as it arrived.
  • Keep the key on your server only (for example in an environment variable DS_WEBHOOK_SECRET), never in browser or mobile code.

How the signature is built#

You receive the headers X-DS-Timestamp (Unix seconds as text) and X-DS-Signature (sha256= followed by 64 hex characters):

text
signature = "sha256=" + hex( HMAC_SHA256( key = signing key, message = X-DS-Timestamp + "." + raw body ) )
  • The key is the whole signing key string, including the whsec_ prefix.
  • The signed message is the value of X-DS-Timestamp, a dot, then the raw body exactly as you received it.
  • The output is lowercase hex.
  • The body is JSON written with Unicode characters as they are (not as \uXXXX) and with / not escaped as \/; do not re-encode it.
  • The timestamp changes on every attempt (automatic or manual resend), so the signature changes with it.

A test vector (a fake placeholder key; the body has no trailing newline):

text
key:       whsec_EXAMPLE_ONLY_NOT_A_REAL_SECRET
timestamp: 1760349120
body:      {"id":"evt_abcdefghijklmnopqrstuvwx","event":"ping","created_at":"2026-10-14T14:32:05+00:00","data":{"message":"Test message"}}
expected:  sha256=928459abb32ee05c949aa3c0db24ac8cd753ac129507f5fdc9735f7b74387bd2

Verification steps#

  1. Read the raw body and the headers

    Read the body bytes before any JSON parsing, plus X-DS-Timestamp and X-DS-Signature. If your framework parses JSON automatically, turn that off for this route.

  2. Recompute the signature

    Compute sha256= followed by the hex HMAC-SHA256 of timestamp + "." + body using your signing key.

  3. Compare in constant time and check the time

    Compare the result with the received header using a constant-time function (hash_equals, crypto.timingSafeEqual or hmac.compare_digest), and reject a timestamp older than your tolerance (300 seconds in the examples). On failure answer 401.

  4. Deduplicate and answer fast

    Skip events whose X-DS-Event-Id you already processed, queue the work and return 2xx within 10 seconds.

Code examples#

PHP

php
<?php
const TOLERANCE_SECONDS = 300;

$secret    = getenv('DS_WEBHOOK_SECRET');           // the whole key: whsec_...
$body      = file_get_contents('php://input');      // raw body, no json_decode before verifying
$timestamp = $_SERVER['HTTP_X_DS_TIMESTAMP'] ?? '';
$received  = $_SERVER['HTTP_X_DS_SIGNATURE'] ?? '';

$expected = 'sha256=' . hash_hmac('sha256', $timestamp . '.' . $body, $secret);

if (!ctype_digit($timestamp)
    || abs(time() - (int) $timestamp) > TOLERANCE_SECONDS
    || !hash_equals($expected, $received)) {
    http_response_code(401);
    exit;
}

$event   = json_decode($body, true);
$eventId = $_SERVER['HTTP_X_DS_EVENT_ID'] ?? '';
// 1) skip $eventId if already handled  2) queue the work  3) answer fast
http_response_code(200);

Node.js (Express)

javascript
import crypto from 'node:crypto';
import express from 'express';

const app = express();
const TOLERANCE_SECONDS = 300;

app.post('/webhooks/delivery-syria', express.raw({ type: 'application/json' }), (req, res) => {
  const timestamp = req.get('X-DS-Timestamp') || '';
  const received = req.get('X-DS-Signature') || '';
  const expected = 'sha256=' + crypto
    .createHmac('sha256', process.env.DS_WEBHOOK_SECRET)
    .update(`${timestamp}.`)
    .update(req.body)            // Buffer: the raw body
    .digest('hex');

  const a = Buffer.from(received);
  const b = Buffer.from(expected);
  const fresh = /^\d+$/.test(timestamp)
    && Math.abs(Date.now() / 1000 - Number(timestamp)) <= TOLERANCE_SECONDS;

  if (!fresh || a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
    return res.sendStatus(401);
  }

  const event = JSON.parse(req.body.toString('utf8'));
  // skip req.get('X-DS-Event-Id') if already handled, then queue the work
  res.sendStatus(200);
});

Python (Flask)

python
import hashlib
import hmac
import os
import time

from flask import Flask, abort, request

app = Flask(__name__)
TOLERANCE_SECONDS = 300


@app.post("/webhooks/delivery-syria")
def delivery_syria_webhook():
    body = request.get_data()                       # raw body (bytes)
    timestamp = request.headers.get("X-DS-Timestamp", "")
    received = request.headers.get("X-DS-Signature", "")
    secret = os.environ["DS_WEBHOOK_SECRET"].encode()

    expected = "sha256=" + hmac.new(
        secret, timestamp.encode() + b"." + body, hashlib.sha256
    ).hexdigest()

    if (not timestamp.isdigit()
            or abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS
            or not hmac.compare_digest(expected.encode(), received.encode())):
        abort(401)

    event = request.get_json()
    # skip request.headers.get("X-DS-Event-Id") if already handled, then queue the work
    return "", 200

Expected result#

Try it with the "Send test" button: the platform sends a ping message signed the same way, so you verify it like any other message. When your verification is right and you answer 2xx, the panel shows a success message with the HTTP status your system returned. If your endpoint rejects the signature and answers 401, the panel shows a failure message with the reason, and the message counts as a failed attempt.

Replay protection#

The timestamp is part of the signed text, so nobody can change it without breaking the signature. The platform enforces no time window itself; that check is yours. The examples in the panel's own documentation use a 300-second tolerance. Because every attempt (automatic retries and manual resend) is signed with a fresh timestamp, a tolerance does not reject legitimate retries, but keep your server clock accurate. For duplicates inside the window rely on X-DS-Event-Id.

Common mistakes#

  • Verifying a body you re-encoded: any change in spacing or key order breaks the signature; use the raw bytes.
  • Dropping the whsec_ prefix, or using another link's key: each link has its own key, and the whole string is used.
  • Comparing with ==: use a constant-time function.
  • Keeping the old key after renewing: it stops working at once, and your messages fail until you update it.
  • Rejecting valid messages because of a bug on your side: any non-2xx answer counts as a failure and is retried, and after 40 consecutive failures the link is suspended (see the retries article).

Permissions and limits#

  • Who can use it: a merchant (platform merchant) from the merchant panel, or a company admin from "Integrations and API". Company links are signed the same way. When plans apply to your account, the plan must include the "Webhook" flag (see the outgoing webhooks article).
  • Your endpoint must answer 2xx within 10 seconds; any other answer counts as a failure, and after 40 consecutive failures the link is suspended (see the retries article).
  • "Send test" is limited to 10 per minute.

API

  • POST /api/v1/webhooks
  • POST /api/v1/webhooks/{id}/test