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 …
}