Webhooks are a simple, efficient way for one system to notify another in real time whenever something important happens. In AFrame, you can configure webhooks to fire whenever contacts or transactions are created, updated, or deleted. Each event sends a secure HTTP POST (including a timestamped HMAC signature) to your designated endpoint, allowing your other applications to stay in sync with every change in your AFrame data. Use the FAQ below to learn how to safely verify and process these incoming webhook requests.
What is a Secret Webhook Token?
Each Webhook Endpoint that you set up in AFrame has an associated token that you can use to validate the incoming requests. This how you can tell that the webhook request is legitimate and came from our servers.
Do I have to use a token to use webhooks?
In short, no. But it is highly recommended so that you can be sure each webhook delivery is legitimate.
Where can I find my token(s)?
When logged into AFrame, go to Admin > Webhooks. Then click on a webhook endpoint. This page will show you your token for that particular endpoint.
How do I use the token to verify the webhook request?
When you receive a webhook request from AFrame, we include a signature and timestamp in the header of the request. The header keys are: X-AFrame-Signature and X-AFrame-Timestamp You will need to get the values of these headers (along with the entire web request data) in order to validate.
How often will a webhook request be retried if a request fails?
AFrame will attempt to deliver a webhook event up to 10 times over 4 hours.
Java / Spring Example
Spring Controller
This is the endpoint that receives the request and validates the request prior to doing anything.
// Replace with your actual webhook secret private static final String webhookSecret = "wsec_xxxxxxxxxxxxxxxxxxx"; /** * Webhook endpoint to receive events from AFrame * * @param request the HTTP request containing the webhook payload */ @PostMapping(value = "/my-webhook-endpoint") public ResponseEntity<String> myWebhookEndpoint(HttpServletRequest request) throws Exception { String payload = IOUtils.toString(request.getReader()); String headerSignature = request.getHeader("X-AFrame-Signature"); String headerTimestamp = request.getHeader("X-AFrame-Timestamp"); if (WebhookSignatureUtil.isRequestValid(payload, headerTimestamp, headerSignature, webhookSecret)) { // It's a valid request, process the payload } return ResponseEntity.ok("success"); }
WebhookSignatureUtil.java
This is the code that will validate the payload
import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; import java.nio.charset.StandardCharsets; import java.security.InvalidKeyException; import java.security.NoSuchAlgorithmException; import java.time.Instant; /** * Utility for generating and verifying HMAC-SHA256 webhook signatures with optional timestamp tolerance. */ public class WebhookSignatureUtil { /** * Verifies that a webhook request is valid based on its payload, timestamp, and HMAC signature. * * @param payload The raw JSON payload body. * @param timestampHeader The timestamp from the request header (epoch seconds). * @param receivedSignature The received HMAC signature (hex-encoded). * @param secret The shared secret used to compute the HMAC. * @param toleranceSeconds Allowed time drift in seconds. If ≤ 0, no timestamp check is performed. * @return {@code true} if the signature is valid and the timestamp (if checked) is within tolerance. */ public static boolean isRequestValid(String payload, String timestampHeader, String receivedSignature, String secret, long toleranceSeconds) { try { long requestTimestamp = Long.parseLong(timestampHeader); long currentTimestamp = Instant.now().getEpochSecond(); if (toleranceSeconds > 0 && Math.abs(currentTimestamp - requestTimestamp) > toleranceSeconds) { return false; // timestamp too far from current time } String signedContent = timestampHeader + "." + payload; String expectedSignature = computeHmacSha256(secret, signedContent); return constantTimeEquals(expectedSignature, receivedSignature); } catch (Exception e) { return false; } } /** * Verifies a webhook request without enforcing timestamp tolerance. * Equivalent to calling {@link #isRequestValid(String, String, String, String, long)} with 0 tolerance. * * @param payload The raw JSON payload body. * @param timestampHeader The timestamp from the request header (epoch seconds). * @param receivedSignature The received HMAC signature (hex-encoded). * @param secret The shared secret used to compute the HMAC. * @return {@code true} if the signature is valid. */ public static boolean isRequestValid(String payload, String timestampHeader, String receivedSignature, String secret) { return isRequestValid(payload, timestampHeader, receivedSignature, secret, 0); } /** * Computes a hex-encoded HMAC-SHA256 signature for the given message. * * @param key The secret key. * @param message The message to sign (typically "timestamp.payload"). * @return Hex-encoded HMAC-SHA256 signature. * @throws NoSuchAlgorithmException If HMAC-SHA256 is not available. * @throws InvalidKeyException If the key is invalid. */ public static String computeHmacSha256(String key, String message) throws NoSuchAlgorithmException, InvalidKeyException { Mac hasher = Mac.getInstance("HmacSHA256"); hasher.init(new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); byte[] hash = hasher.doFinal(message.getBytes(StandardCharsets.UTF_8)); StringBuilder result = new StringBuilder(); for (byte b : hash) { result.append(String.format("%02x", b)); } return result.toString(); } /** * Constant-time comparison to prevent timing attacks. * * @param a First string. * @param b Second string. * @return {@code true} if strings are equal; {@code false} otherwise. */ private static boolean constantTimeEquals(String a, String b) { if (a == null || b == null || a.length() != b.length()) return false; int result = 0; for (int i = 0; i < a.length(); i++) { result |= a.charAt(i) ^ b.charAt(i); } return result == 0; } }
Node.js Example
// app.js (or your main server file) import express from 'express' import crypto from 'crypto' import getRawBody from 'raw-body' const app = express() // Replace with process.env.WEBHOOK_SECRET or similar const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET || 'wsec_xxxxxxxxxxxxxxxxxxx' const HEADER_SIGNATURE = 'x-aframe-signature' // lowercase for Express headers const HEADER_TIMESTAMP = 'x-aframe-timestamp' // Middleware to parse raw body for HMAC verification app.post('/my-webhook-endpoint', async (req, res) => { try { // 1) Read raw body const rawBody = await getRawBody(req, { length: req.headers['content-length'], limit: '1mb', encoding: true }) // 2) Grab headers const receivedSignature = req.headers[HEADER_SIGNATURE] const timestampHeader = req.headers[HEADER_TIMESTAMP] // 3) Validate if (isRequestValid(rawBody, timestampHeader, receivedSignature, WEBHOOK_SECRET, 300)) { // Valid: process `rawBody` JSON const payload = JSON.parse(rawBody) // … your business logic here … res.status(200).send('success') } else { // Invalid signature or timestamp res.status(400).send('invalid signature') } } catch (err) { console.error('Webhook handling error:', err) res.sendStatus(500) } }) // Start server app.listen(3000, () => { console.log('Listening on port 3000') }) /** * Compute hex-encoded HMAC-SHA256. */ function computeHmacSha256(secret, message) { return crypto .createHmac('sha256', secret) .update(message, 'utf8') .digest('hex') } /** * Constant-time string comparison. */ function constantTimeEquals(a, b) { if (typeof a !== 'string' || typeof b !== 'string' || a.length !== b.length) { return false } // Convert to Buffer for timingSafeEqual const bufA = Buffer.from(a, 'utf8') const bufB = Buffer.from(b, 'utf8') return crypto.timingSafeEqual(bufA, bufB) } /** * Verify a webhook request. * * @param {string} payload Raw request body. * @param {string} timestampHeader Header value (epoch seconds). * @param {string} receivedSig Hex-encoded signature from header. * @param {string} secret Your webhook secret. * @param {number} toleranceSec Seconds of allowed clock skew (0 to skip). * @returns {boolean} */ function isRequestValid(payload, timestampHeader, receivedSig, secret, toleranceSec = 0) { try { const requestTs = parseInt(timestampHeader, 10) const nowTs = Math.floor(Date.now() / 1000) if ( toleranceSec > 0 && Math.abs(nowTs - requestTs) > toleranceSec ) { return false // timestamp out of tolerance } const signedContent = `${timestampHeader}.${payload}` const expectedSignature = computeHmacSha256(secret, signedContent) return constantTimeEquals(expectedSignature, receivedSig) } catch { return false } }
Python Example
# app.py import os import time import hmac import hashlib from flask import Flask, request, abort app = Flask(__name__) # Replace with your env var or config WEBHOOK_SECRET = os.getenv('WEBHOOK_SECRET', 'wsec_xxxxxxxxxxxxxxxxxxx') HEADER_SIGNATURE = 'X-AFrame-Signature' HEADER_TIMESTAMP = 'X-AFrame-Timestamp' # Allow ±5 minutes clock skew TIMESTAMP_TOLERANCE = 300 @app.route('/my-webhook-endpoint', methods=['POST']) def my_webhook_endpoint(): # 1) Get the raw request body as text payload = request.get_data(as_text=True) # 2) Read signature & timestamp headers received_sig = request.headers.get(HEADER_SIGNATURE) ts_header = request.headers.get(HEADER_TIMESTAMP) if not received_sig or not ts_header: abort(400, 'Missing signature or timestamp') # 3) Validate if not is_request_valid(payload, ts_header, received_sig, WEBHOOK_SECRET, TIMESTAMP_TOLERANCE): abort(400, 'Invalid signature or timestamp') # 4) Safe to parse and process data = request.get_json() # … your business logic here … return 'success', 200 def compute_hmac_sha256(secret: str, message: str) -> str: """Return hex-encoded HMAC-SHA256 of message.""" mac = hmac.new(secret.encode('utf-8'), message.encode('utf-8'), hashlib.sha256) return mac.hexdigest() def is_request_valid( payload: str, timestamp_header: str, received_signature: str, secret: str, tolerance_seconds: int = 0 ) -> bool: """Verify timestamp drift and HMAC signature (using constant-time compare).""" try: req_ts = int(timestamp_header) now_ts = int(time.time()) # Check clock skew if tolerance_seconds > 0 and abs(now_ts - req_ts) > tolerance_seconds: return False signed_content = f"{timestamp_header}.{payload}" expected_sig = compute_hmac_sha256(secret, signed_content) # Use hmac.compare_digest for constant-time comparison return hmac.compare_digest(expected_sig, received_signature) except Exception: return False if __name__ == '__main__': # Only for local testing; use a proper WSGI server in production app.run(port=5000, debug=True)
C# / ASP .NET Example
// Program.cs (ASP.NET Core minimal API) var builder = WebApplication.CreateBuilder(args); var app = builder.Build(); const string WEBHOOK_SECRET = "wsec_xxxxxxxxxxxxxxxxxxx"; const string HEADER_SIGNATURE = "X-AFrame-Signature"; const string HEADER_TIMESTAMP = "X-AFrame-Timestamp"; const long TOLERANCE_SEC = 300; // 5 minutes app.MapPost("/my-webhook-endpoint", async (HttpRequest req) => { // 1) Read raw body req.EnableBuffering(); using var reader = new StreamReader(req.Body, leaveOpen: true); var payload = await reader.ReadToEndAsync(); req.Body.Position = 0; // 2) Get headers if (!req.Headers.TryGetValue(HEADER_SIGNATURE, out var sigHeader) || !req.Headers.TryGetValue(HEADER_TIMESTAMP, out var tsHeader)) { return Results.BadRequest("Missing signature or timestamp"); } // 3) Validate if (!WebhookValidator.IsValid(payload, tsHeader, sigHeader, WEBHOOK_SECRET, TOLERANCE_SEC)) { return Results.BadRequest("Invalid signature or timestamp"); } // 4) Safe to parse JSON var data = JsonSerializer.Deserialize<MyPayloadDto>(payload); // … your business logic … return Results.Ok("success"); }); app.Run(); public static class WebhookValidator { public static bool IsValid( string payload, string timestampHeader, string receivedSignature, string secret, long toleranceSeconds = 0) { if (!long.TryParse(timestampHeader, out var requestTs)) return false; var nowTs = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); if (toleranceSeconds > 0 && Math.Abs(nowTs - requestTs) > toleranceSeconds) return false; var signedContent = $"{timestampHeader}.{payload}"; var expectedSignature = ComputeHmacSha256(secret, signedContent); // constant-time compare return CryptographicOperations.FixedTimeEquals( Convert.FromHexString(expectedSignature), Convert.FromHexString(receivedSignature) ); } private static string ComputeHmacSha256(string key, string message) { using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(key)); var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(message)); return Convert.ToHexString(hash).ToLowerInvariant(); } } // DTO for your payload public class MyPayloadDto { public int Id { get; set; } public string EventType { get; set; } // … other properties … }