Webhook Signature Verification Guide

How Signing Works

  1. Apaya takes the exact byte array of the outgoing HTTP body for that delivery attempt — never a re-serialized copy of a parsed object.

  2. It computes signature =HMAC-SHA256(secret, "{unix_seconds}." + raw_body), where unix_seconds is the Unix timestamp (seconds) at the moment of that specific delivery attempt, and raw_body is the exact UTF-8 bytes of the request body.

  3. The digest is hex-encoded (lowercase) and sent as

    • X-Webhook-Signature: sha256={digest}

    • X-Webhook-Timestamp: {unix_seconds}

Retries: if a delivery attempt fails, the retry is sent with a fresh unix_seconds and therefore a fresh signature — but the underlying body bytes are unchanged from the original attempt.

Example Headers

POST /apaya/webhooks HTTP/1.1 
Content-Type: application/json
X-Webhook-Signature: sha256=5257a869e7ecebed9f8c9d9b1e3a2f4c6d8e0f1a2b3c4d5e6f7a8b9c0d1e2f3a
X-Webhook-Timestamp: 1713355200

Verifying the Signature

Constant-time comparison is mandatory. Comparing signatures with a naive == / .Equals() string check leaks timing information that an attacker can exploit to forge a valid signature byte-by-byte. Always use your language's constant-time comparison primitive (crypto.timingSafeEqual in Node, hmac.compare_digest in Python, CryptographicOperations.FixedTimeEquals in .NET).

Verification sample code

C# reference implementation. This mirrors the exact algorithm Apaya uses internally to sign each delivery — verifying is the mirror image of signing

C# (.NET) 

using System;
using System.Security.Cryptography;
using System.Text;

public static class WebhookVerification
{
public static bool VerifyApayaWebhookSignature(byte[] rawBodyBytes, string signatureHeader, string timestampHeader, string secret)
{
// 1. Reject stale or future-dated deliveries (5-minute replay window).
if (!long.TryParse(timestampHeader, out long unixTimestampSeconds))
return false;

long nowSeconds = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
if (Math.Abs(nowSeconds - unixTimestampSeconds) > 300)
return false;

// 2. Recompute the signature over the exact raw bytes received.
const string prefix = "sha256=";
if (!signatureHeader.StartsWith(prefix, StringComparison.Ordinal))
return false;

string receivedHex = signatureHeader.Substring(prefix.Length);

byte[] secretBytes = Encoding.UTF8.GetBytes(secret);
byte[] prefixBytes = Encoding.ASCII.GetBytes(unixTimestampSeconds + ".");
byte[] signedContent = new byte[prefixBytes.Length + rawBodyBytes.Length];
Buffer.BlockCopy(prefixBytes, 0, signedContent, 0, prefixBytes.Length);
Buffer.BlockCopy(rawBodyBytes, 0, signedContent, prefixBytes.Length, rawBodyBytes.Length);

using var hmac = new HMACSHA256(secretBytes);
byte[] expectedBytes = hmac.ComputeHash(signedContent);
string expectedHex = Convert.ToHexString(expectedBytes).ToLowerInvariant();

// 3. Constant-time comparison - never use == or string.Equals for this.
byte[] expectedHexBytes = Encoding.ASCII.GetBytes(expectedHex);
byte[] receivedHexBytes = Encoding.ASCII.GetBytes(receivedHex);

return expectedHexBytes.Length == receivedHexBytes.Length
&& CryptographicOperations.FixedTimeEquals(expectedHexBytes, receivedHexBytes);
}
}