Webhook Signature Verification Guide
How Signing Works
Apaya takes the exact byte array of the outgoing HTTP body for that delivery attempt — never a re-serialized copy of a parsed object.
It computes
signature =HMAC-SHA256(secret, "{unix_seconds}." + raw_body), whereunix_secondsis the Unix timestamp (seconds) at the moment of that specific delivery attempt, andraw_bodyis the exact UTF-8 bytes of the request body.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 boolVerifyApayaWebhookSignature(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);
}
}
