Base URL https://api.echonova.ai

Developers

Verify a webhook signature

Recompute the HMAC-SHA256 signature on every delivery, compare it in constant time, and reject requests that are stale or unsigned.

Anyone who knows your endpoint URL can send it JSON. The signature on each delivery proves the request came from EchoNova and that the body was not changed on the way. Verify it before you act on the event.

What you need

  • A webhook and its signing secret, the whsec_ string returned once at creation. It is not shown again, so save it when you create the webhook; see Save the signing secret.
  • Access to the raw request body on your server. Frameworks that parse JSON before your code runs must be told to keep the original bytes.
  • The three headers on every delivery, listed on Webhook events.

Steps

Step 1: Read the raw body and the headers

Take the request body exactly as it arrived, as bytes. Do not parse it and serialise it again: any change in key order, spacing or Unicode escaping produces a different signature. Read X-Webhook-Timestamp and X-Webhook-Signature from the headers.

Recommended

app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  const rawBody = req.body; // Buffer, byte for byte as it arrived
});

Less effective

app.post('/webhook', express.json(), (req, res) => {
  const rawBody = JSON.stringify(req.body); // re-serialised; the signature no longer matches
});

Step 2: Build the signed string

Join the timestamp and the body with a single dot:

<X-Webhook-Timestamp>.<raw body>

The timestamp is the decimal string from the header. The body is the raw bytes. Nothing else is included: not the event name, not the URL, not the other headers.

Step 3: Compute the expected signature

Compute HMAC-SHA256 over the signed string, keyed with the full signing secret including its whsec_ prefix, and encode the result as lowercase hex. X-Webhook-Signature carries the same 64-character hex string.

Step 4: Compare in constant time

Compare your result with the header using a constant-time function: crypto.timingSafeEqual in Node, hmac.compare_digest in Python. A plain string comparison returns early at the first differing byte, and that timing difference can leak the secret to a patient attacker. Reject the request with a 401 when the values differ or the header is missing.

Step 5: Reject stale timestamps

Reject a delivery whose X-Webhook-Timestamp is more than 300 seconds away from your server's clock, in either direction. The window is yours to choose; 300 seconds is the recommended value. A signed request that an attacker captured can otherwise be replayed later. Every retry carries a fresh timestamp and a fresh signature, and the third and last attempt starts at most about 35 seconds after the first, so a 5-minute window does not reject legitimate retries. Keep your server's clock synchronised with NTP.

Step 6: Acknowledge, then do the work

Return a 2xx status as soon as the signature checks out, then process the event. EchoNova waits 10 seconds for the response and counts anything else as a failed attempt.

import hashlib
import hmac
import os
import time

from flask import Flask, abort, request

app = Flask(__name__)
SECRET = os.environ["ECHONOVA_WEBHOOK_SECRET"].encode()  # whsec_...
MAX_AGE_SECONDS = 300  # recommended window; the choice is yours


def verify(raw_body: bytes, headers) -> bool:
    signature = headers.get("X-Webhook-Signature", "")
    timestamp = headers.get("X-Webhook-Timestamp", "")
    if not timestamp.isdigit():
        return False
    if abs(int(time.time()) - int(timestamp)) > MAX_AGE_SECONDS:
        return False
    signed = timestamp.encode() + b"." + raw_body
    expected = hmac.new(SECRET, signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected.encode(), signature.encode())


@app.post("/webhook")
def webhook():
    if not verify(request.get_data(), request.headers):
        abort(401)
    event = request.headers["X-Webhook-Event"]
    payload = request.get_json(force=True)
    # Hand the event to a queue here, then answer.
    return "", 200

What happens next

  • A request that passes is a genuine EchoNova delivery with an unmodified body. Read X-Webhook-Event to pick the handler, then parse the JSON.
  • A request that fails gets 401 and no further processing. A non-2xx response is a failed attempt and is retried, so a signature that never matches ends as a failed delivery, and 5 failed deliveries in a row disable the webhook. If that happens, fix the secret and enable the webhook again.

Limits

Limit Value Applies to
Signature HMAC-SHA256, 64 lowercase hex characters X-Webhook-Signature
Timestamp Unix time in whole seconds X-Webhook-Timestamp
Signing key The full secret, whsec_ plus 43 characters HMAC key
Response deadline 10 seconds per attempt Your endpoint
Stale window 300 seconds, recommended Your verifier

Troubleshooting

The signature never matches

The most common causes, in order: your framework parsed and re-serialised the body before you hashed it; you hashed the body without the <timestamp>. prefix; you used the 12-character prefix instead of the full secret; the secret belongs to a different webhook. Log the raw bytes you hashed and the timestamp you used, and compare their lengths with what arrived.

Every request is rejected as stale

Your server's clock is off. Check it against NTP. If your handler runs behind a queue, verify the signature at the edge, before queuing, so the check uses the arrival time.

The retry has a different signature

Expected. Each attempt is signed at the moment it is sent, with a new timestamp. The body is identical across attempts, so you can still de-duplicate on it.

My framework only gives me parsed JSON

Configure it to keep the raw body for the webhook route. In Express, mount express.raw({ type: 'application/json' }) on that route before any JSON parser. In Flask, request.get_data() returns the raw bytes as long as you call it before request.get_json().

FAQ

Which secret do I use?

The full signingSecret returned when the webhook was created, including the whsec_ prefix. The 12-character signingSecretPrefix is a label, not a key.

Does the event name take part in the signature?

No. Only the timestamp and the body are signed. Read the event name from X-Webhook-Event and, if you need to, check it against the fields in the body.

Can I verify with cURL?

No. cURL cannot compute an HMAC. Use the Python or Node sample above, or any language with an HMAC-SHA256 function.

Can I rotate the secret?

The secret is fixed for the life of the webhook. Create a second webhook for the same URL and events, deploy its secret, then delete the first one. During the overlap your endpoint must accept either secret.

What if I do not verify?

Anyone who finds your URL can post a fake job.completed with a link of their choosing, or a fake spending.exhausted that makes you disable a key. Treat an unverified delivery as untrusted input.