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:
| Header | Example | Purpose |
|---|---|---|
X-UniAsset-Signature | 3a7f2c... (64 hex chars) | HMAC-SHA256 signature over the raw body |
X-UniAsset-Event | asset.created | The event type that triggered delivery |
X-UniAsset-Timestamp | 2026-05-23T14:30:00.000Z | ISO 8601 delivery timestamp |
Content-Type | application/json | Always application/json |
Signing Algorithm
The signature is computed as:
HMAC-SHA256(webhookSecret, rawBody) → hex string
Where:
webhookSecretis the secret generated when you created the webhook (shown once at creation)rawBodyis 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.
| Scenario | Return |
|---|---|
| Signature valid, event processed | 200 OK |
| Signature valid, event type not handled | 200 OK (acknowledge and ignore) |
| Signature invalid or missing | 401 Unauthorized |
| Your server error | 500 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
.envfiles 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
2xxfor all verified deliveries — a404or500causes 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