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.
RecommendationVerify 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 viaPOST /webhooks.- Retrieving your secret: If you lose your signing secret, call
GET /webhooks/:id/signing-secretto 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
base64urlencoding (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>| Header | Description |
|---|---|
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:
- Retrieve your signing secret — Call
GET /webhooks/:id/signing-secretto obtain thewhsec_-prefixed secret for your webhook. - Update your verification code — Switch from reading the
Snappt-Signatureheader toSnappt-Signature-v2, extract thev2component, and use your webhook signing secret as the HMAC key. - Deploy — Once you are verifying against
Snappt-Signature-v2, the legacySnappt-Signatureheader can be ignored.
Transition periodDuring the transition period, Snappt sends both headers so existing integrations continue to work. The legacy
Snappt-Signatureheader will eventually be removed.
Troubleshooting
If signatures do not match, verify the following:
- 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\nline endings).- You are using
base64url— not standardbase64. Check for+,/, or trailing=in your output.- You are using the webhook signing secret — the
whsec_-prefixed value fromPOST /webhooksorGET /webhooks/:id/signing-secret, not the API Key ID.- You are extracting the timestamp from the header — use the
t=value fromSnappt-Signature-v2, not your own system clock.- You are using constant-time comparison — use
crypto.timingSafeEqual(Node.js),CryptographicOperations.FixedTimeEquals(C#), or the equivalent in your language to prevent timing attacks.
Updated 1 day ago