Home/Knowledge Base/Integrations & IoT/Webhook Security Verification — Verifying UniAsset Webhook Signatures
Back to Integrations & IoT

Webhook Security Verification — Verifying UniAsset Webhook Signatures

8 min readadvancedLast updated: January 2, 2026

Overview

UniAsset signs every outbound webhook delivery with HMAC-SHA256 so your endpoint can verify that the request originated from UniAsset and was not tampered with in transit. This article explains the signing algorithm, the headers included on every delivery, and how to verify signatures correctly in Node.js, Python, and C#.


Delivery Headers

Every UniAsset webhook POST includes three headers:

HeaderExamplePurpose
X-UniAsset-Signature3a7f2c... (64 hex chars)HMAC-SHA256 signature over the raw body
X-UniAsset-Eventasset.createdThe event type that triggered delivery
X-UniAsset-Timestamp2026-05-23T14:30:00.000ZISO 8601 delivery timestamp
Content-Typeapplication/jsonAlways application/json

Signing Algorithm

The signature is computed as:

HMAC-SHA256(webhookSecret, rawBody) → hex string

Where:

  • webhookSecret is the secret generated when you created the webhook (shown once at creation)
  • rawBody is the exact byte-for-byte JSON body delivered in the POST — do not parse and re-serialize before verifying

The resulting hex digest is compared against the value in X-UniAsset-Signature.

Critical: Always use the raw body bytes for signature computation. Parsing JSON and re-serializing can change whitespace, key ordering, or encoding — the comparison will fail even for legitimate deliveries.


Verification Examples

Node.js

import crypto from "crypto";

export function verifyUniAssetWebhook(req, webhookSecret) {
  const signature = req.headers["x-uniasset-signature"];
  const timestamp = req.headers["x-uniasset-timestamp"];

  if (!signature || !timestamp) {
    return { valid: false, reason: "Missing signature or timestamp header" };
  }

  // Replay protection: reject deliveries older than 5 minutes
  const deliveryTime = new Date(timestamp).getTime();
  const now = Date.now();
  if (Math.abs(now - deliveryTime) > 5 * 60 * 1000) {
    return { valid: false, reason: "Delivery timestamp too old — possible replay attack" };
  }

  // req.body must be the raw Buffer, not a parsed object
  // With Express: app.use(express.raw({ type: "application/json" }))
  const rawBody = req.body instanceof Buffer ? req.body : Buffer.from(JSON.stringify(req.body));

  const expected = crypto
    .createHmac("sha256", webhookSecret)
    .update(rawBody)
    .digest("hex");

  const signatureBuffer = Buffer.from(signature, "hex");
  const expectedBuffer = Buffer.from(expected, "hex");

  if (signatureBuffer.length !== expectedBuffer.length) {
    return { valid: false, reason: "Signature length mismatch" };
  }

  // Use timingSafeEqual to prevent timing attacks
  const valid = crypto.timingSafeEqual(signatureBuffer, expectedBuffer);
  return valid ? { valid: true } : { valid: false, reason: "Signature mismatch" };
}

Express setup — ensure the raw body reaches your handler:

import express from "express";

const app = express();

// Register raw body parser for the webhook path BEFORE any JSON parser
app.post("/webhooks/uniasset", express.raw({ type: "application/json" }), (req, res) => {
  const result = verifyUniAssetWebhook(req, process.env.UNIASSET_WEBHOOK_SECRET);
  if (!result.valid) {
    return res.status(401).json({ error: result.reason });
  }

  const event = JSON.parse(req.body.toString("utf8"));
  // Process event.type and event.data
  res.status(200).send("ok");
});

Python

import hashlib
import hmac
import time
from datetime import datetime, timezone

def verify_uniasset_webhook(
    raw_body: bytes,
    signature_header: str,
    timestamp_header: str,
    webhook_secret: str,
    max_age_seconds: int = 300,
) -> tuple[bool, str]:
    """
    Returns (True, "") on success or (False, reason) on failure.
    raw_body must be the unmodified request body bytes.
    """
    if not signature_header or not timestamp_header:
        return False, "Missing signature or timestamp header"

    # Replay protection: reject deliveries older than max_age_seconds
    try:
        delivery_time = datetime.fromisoformat(timestamp_header.replace("Z", "+00:00"))
        age_seconds = (datetime.now(timezone.utc) - delivery_time).total_seconds()
        if abs(age_seconds) > max_age_seconds:
            return False, "Delivery timestamp too old — possible replay attack"
    except ValueError:
        return False, "Invalid timestamp format"

    # Compute expected signature
    expected = hmac.new(
        webhook_secret.encode("utf-8"),
        raw_body,
        hashlib.sha256,
    ).hexdigest()

    # Use compare_digest to prevent timing attacks
    if not hmac.compare_digest(expected, signature_header.lower()):
        return False, "Signature mismatch"

    return True, ""


# Flask example
from flask import Flask, request, abort

app = Flask(__name__)

@app.route("/webhooks/uniasset", methods=["POST"])
def uniasset_webhook():
    raw_body = request.get_data()  # raw bytes — do not call request.json first
    signature = request.headers.get("X-UniAsset-Signature", "")
    timestamp = request.headers.get("X-UniAsset-Timestamp", "")
    secret = os.environ["UNIASSET_WEBHOOK_SECRET"]

    valid, reason = verify_uniasset_webhook(raw_body, signature, timestamp, secret)
    if not valid:
        abort(401, description=reason)

    event = request.json
    # Process event["type"] and event["data"]
    return "ok", 200

C#

using System;
using System.Security.Cryptography;
using System.Text;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;

public class WebhookController : ControllerBase
{
    private readonly string _webhookSecret;
    private const int MaxAgeSeconds = 300;

    public WebhookController(IConfiguration config)
    {
        _webhookSecret = config["UniAsset:WebhookSecret"]
            ?? throw new InvalidOperationException("UniAsset webhook secret not configured");
    }

    [HttpPost("/webhooks/uniasset")]
    public async Task<IActionResult> Receive()
    {
        // Read raw body before any model binding
        Request.EnableBuffering();
        using var reader = new StreamReader(Request.Body, Encoding.UTF8, leaveOpen: true);
        var rawBody = await reader.ReadToEndAsync();
        Request.Body.Position = 0;

        var signature = Request.Headers["X-UniAsset-Signature"].FirstOrDefault();
        var timestamp = Request.Headers["X-UniAsset-Timestamp"].FirstOrDefault();

        var (valid, reason) = VerifySignature(rawBody, signature, timestamp);
        if (!valid)
            return Unauthorized(new { error = reason });

        // Deserialize and process
        // var payload = JsonSerializer.Deserialize<WebhookPayload>(rawBody);
        return Ok();
    }

    private (bool Valid, string Reason) VerifySignature(
        string rawBody,
        string? signature,
        string? timestamp)
    {
        if (string.IsNullOrEmpty(signature) || string.IsNullOrEmpty(timestamp))
            return (false, "Missing signature or timestamp header");

        // Replay protection
        if (!DateTimeOffset.TryParse(timestamp, out var deliveryTime))
            return (false, "Invalid timestamp format");

        var ageSeconds = Math.Abs((DateTimeOffset.UtcNow - deliveryTime).TotalSeconds);
        if (ageSeconds > MaxAgeSeconds)
            return (false, "Delivery timestamp too old — possible replay attack");

        // Compute expected signature
        var secretBytes = Encoding.UTF8.GetBytes(_webhookSecret);
        var bodyBytes = Encoding.UTF8.GetBytes(rawBody);

        using var hmac = new HMACSHA256(secretBytes);
        var expectedBytes = hmac.ComputeHash(bodyBytes);
        var expected = Convert.ToHexString(expectedBytes).ToLowerInvariant();

        // Use CryptographicOperations.FixedTimeEquals to prevent timing attacks
        var signatureBytes = Convert.FromHexString(signature.ToLowerInvariant());
        var expectedBytesForCompare = Convert.FromHexString(expected);

        if (!CryptographicOperations.FixedTimeEquals(signatureBytes, expectedBytesForCompare))
            return (false, "Signature mismatch");

        return (true, string.Empty);
    }
}

ASP.NET Core — preserve raw body: By default, ASP.NET Core reads and disposes the body stream. Use Request.EnableBuffering() (shown above) or configure a custom middleware to buffer the body before model binding runs.


Replay Attack Protection

The X-UniAsset-Timestamp header records when UniAsset dispatched the delivery. A replay attack occurs when an attacker intercepts a valid delivery and re-sends it later.

Recommended tolerance: Reject deliveries where the timestamp differs from your server clock by more than 5 minutes (300 seconds). This window covers network delays and minor clock skew while blocking replays.

| now - deliveryTimestamp | > 300s  →  reject with 401

Important: Your server clock must be synchronized (NTP). A server with a drifted clock will incorrectly reject legitimate deliveries. Cloud-managed infrastructure (AWS EC2, Azure App Service, GCP Compute) synchronizes time automatically.

Idempotency key (optional, stronger protection): For critical workflows (financial triggers, automated provisioning), maintain a short-lived cache of recently seen signature values and reject any delivery whose signature appears twice within the replay window — even if the timestamp is fresh.


Returning the Correct Status Code

Always return 2xx to acknowledge receipt, regardless of what your handler does with the event. UniAsset marks a delivery as failed and schedules a retry if your endpoint returns 4xx or 5xx.

ScenarioReturn
Signature valid, event processed200 OK
Signature valid, event type not handled200 OK (acknowledge and ignore)
Signature invalid or missing401 Unauthorized
Your server error500 Internal Server Error (UniAsset will retry)

UniAsset retries failed deliveries with exponential backoff. Make your handler idempotent — it may receive the same event more than once after a retry.


Secret Management

  • Store the webhook secret in a secrets manager (Azure Key Vault, AWS Secrets Manager, HashiCorp Vault) — not in source code or .env files committed to version control
  • The secret is shown once when you create the webhook. If you did not copy it, delete the webhook and create a new one — a new secret will be generated
  • If you suspect the secret has been exposed, delete the webhook and create a new one immediately
  • Rotate secrets periodically by creating a new webhook, updating your consumer, verifying delivery, then deleting the old webhook

Troubleshooting

Signature always fails:

  • Confirm you are computing the HMAC over the raw body bytes, not a parsed/re-serialized object
  • Confirm the secret is the exact string shown at creation — no trailing newline, no URL encoding
  • Confirm your framework is not consuming the body stream before your verification code runs

Timestamp rejection on valid deliveries:

  • Check your server clock synchronization. Use timedatectl (Linux) or check NTP status
  • Ensure you are parsing the timestamp as UTC, not local time

Delivery not arriving:

  • Confirm your endpoint returns 2xx for all verified deliveries — a 404 or 500 causes retry scheduling
  • Check Settings → Integrations → Activity for delivery status and response codes

Related Articles

Need Help?

If you have questions not covered in this article, our support team is here to help.

Contact Support