GuidesRecipesAPI ReferenceChangelog
Log In
Guides

v2

Our webhooks now include an encrypted signature

Webhook Signing

Webhook payloads are signed with an HMAC-SHA256 signature. A Snappt-Signature header is included on every webhook request, allowing you to cryptographically verify that the request came from Snappt.

📘

Recommendation

Verify the signature on every webhook request, and always make a follow-up request to the API to retrieve the full application data.

Important Details

⚠️

Key points for successful verification

  • Signing key: Your webhook signing secret (starts with whsec_), provided when you create a webhook via POST /webhooks.
  • Retrieving your secret: If you lose your signing secret, call GET /webhooks/:id/signing-secret to retrieve it.
  • Payload format: Snappt sends the webhook body as compact JSON with no whitespace (no newlines, no indentation, no extra spaces). The signature is computed against this exact string.
  • Raw body required: You must use the raw HTTP request body as received — do not parse and re-serialize the JSON before verifying. Many web frameworks (e.g., ASP.NET, Express with JSON middleware) parse the body automatically, which can introduce whitespace, reorder keys, or change formatting. If your framework does this, ensure you capture the raw body string before parsing occurs.
  • Hash encoding: The signature uses base64url encoding (RFC 4648 §5), which differs from standard base64: + becomes -, / becomes _, and trailing = padding is omitted.

Signature Format

Snappt sends two signature headers with every webhook request:

Snappt-Signature:    t=<timestamp>,v1=<signature>
Snappt-Signature-v2: t=<timestamp>,v2=<signature>
HeaderDescription
Snappt-Signature-v2 (recommended)HMAC-SHA256 signature using your webhook signing secret (whsec_-prefixed), base64url-encoded. Use this header for verification.
Snappt-Signature (legacy)HMAC-SHA256 signature using the API Key ID. Deprecated; will be removed in a future release.

Both headers include a t component with the Unix timestamp in milliseconds when the signature was generated.

Verification Steps

import crypto from "crypto";

const rawBody = req.body; // Must be raw string, not parsed JSON
const header = req.headers["snappt-signature-v2"];

// Step 1: Parse header
const parts = header.split(",");
let timestamp: string | undefined;
let v2Sig: string | undefined;
for (const part of parts) {
  const [key, value] = part.split("=", 2);
  if (key === "t") timestamp = value;
  else if (key === "v2") v2Sig = value;
}
if (!v2Sig || !timestamp) throw new Error("Missing signature components");

// Step 2: Verify timestamp (recommended: reject if older than 5 minutes)
const age = Date.now() - parseInt(timestamp);
if (age > 5 * 60 * 1000) throw new Error("Webhook timestamp too old");

// Step 3: Compute expected signature
const expected = crypto
  .createHmac("sha256", "YOUR_WEBHOOK_SIGNING_SECRET")
  .update(`${timestamp}.${rawBody}`)
  .digest("base64url");

// Step 4: Compare (use constant-time comparison)
const expectedBuf = Buffer.from(expected);
const actualBuf = Buffer.from(v2Sig);
if (expectedBuf.length !== actualBuf.length || !crypto.timingSafeEqual(expectedBuf, actualBuf)) {
  throw new Error("Signature verification failed");
}
// Ensure you read the raw body BEFORE any middleware parses it
string body;
using (var reader = new StreamReader(request.Body, Encoding.UTF8, false, 4096, true))
{
    body = await reader.ReadToEndAsync();
}
request.Body.Position = 0;

// Extract timestamp and v2 signature from Snappt-Signature-v2 header
var header = request.Headers["Snappt-Signature-v2"].ToString();
string? timestamp = null;
string? v2Sig = null;
foreach (var part in header.Split(','))
{
    var kv = part.Split('=', 2);
    if (kv[0] == "t") timestamp = kv[1];
    else if (kv[0] == "v2") v2Sig = kv[1];
}

// Build the signed payload and compute HMAC-SHA256
var toSign = $"{timestamp}.{body}";
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes("YOUR_WEBHOOK_SIGNING_SECRET"));
var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(toSign));
var expected = Convert.ToBase64String(hash)
    .TrimEnd('=')
    .Replace('+', '-')
    .Replace('/', '_');

// Use constant-time comparison to prevent timing attacks
if (!CryptographicOperations.FixedTimeEquals(
    Encoding.UTF8.GetBytes(expected),
    Encoding.UTF8.GetBytes(v2Sig)))
    throw new Exception("Webhook signature verification failed");

Migrating from v1

If you are currently verifying webhooks using the Snappt-Signature header (API Key ID-based signing), follow these steps to migrate to v2:

  1. Retrieve your signing secret — Call GET /webhooks/:id/signing-secret to obtain the whsec_-prefixed secret for your webhook.
  2. Update your verification code — Switch from reading the Snappt-Signature header to Snappt-Signature-v2, extract the v2 component, and use your webhook signing secret as the HMAC key.
  3. Deploy — Once you are verifying against Snappt-Signature-v2, the legacy Snappt-Signature header can be ignored.
📘

Transition period

During the transition period, Snappt sends both headers so existing integrations continue to work. The legacy Snappt-Signature header will eventually be removed.

Troubleshooting

If signatures do not match, verify the following:

  1. You are using the raw request body — not a parsed-and-re-serialized version. Frameworks like ASP.NET and Express can silently modify the body (adding whitespace, reordering keys, or inserting \r\n line endings).
  2. You are using base64url — not standard base64. Check for +, /, or trailing = in your output.
  3. You are using the webhook signing secret — the whsec_-prefixed value from POST /webhooks or GET /webhooks/:id/signing-secret, not the API Key ID.
  4. You are extracting the timestamp from the header — use the t= value from Snappt-Signature-v2, not your own system clock.
  5. You are using constant-time comparison — use crypto.timingSafeEqual (Node.js), CryptographicOperations.FixedTimeEquals (C#), or the equivalent in your language to prevent timing attacks.