Webhook Security
Secure your webhook endpoints with HMAC signature verification, timestamp validation, and proper secret management.
HMAC Signature Verification
Every webhook request includes an X-Webhook-Signature header containing an HMAC-SHA256 signature. This signature proves the request originated from Editframe and hasn't been tampered with.
Signature Format
X-Webhook-Signature: <hmac-sha256-hex-digest>
The signature is computed as:
HMAC-SHA256(webhook_secret, request_body)
Where:
webhook_secretis your API key's webhook secretrequest_bodyis the raw JSON request body as a string
Verification Implementation
Node.js / TypeScript
import crypto from "node:crypto";function verifyWebhookSignature(payload: string | Buffer,signature: string,secret: string): boolean {// Compute expected signatureconst expectedSignature = crypto.createHmac("sha256", secret).update(payload).digest("hex");// Use timing-safe comparison to prevent timing attacksreturn crypto.timingSafeEqual(Buffer.from(signature),Buffer.from(expectedSignature));}// Express middlewareapp.post("/webhooks/editframe", (req, res) => {const signature = req.headers["x-webhook-signature"] as string;const secret = process.env.EDITFRAME_WEBHOOK_SECRET!;// Get raw body (before JSON parsing)const rawBody = JSON.stringify(req.body);if (!verifyWebhookSignature(rawBody, signature, secret)) {return res.status(401).send("Invalid signature");}// Signature is valid - process eventconst event = req.body;res.status(200).send("OK");});
Critical: Hash the raw request body string, not the parsed JSON object. JSON serialization order can vary, producing different hashes.
Express with Raw Body
To verify signatures correctly, you need access to the raw request body:
import express from "express";import bodyParser from "body-parser";const app = express();// Capture raw body for signature verificationapp.use(bodyParser.json({verify: (req, res, buf) => {(req as any).rawBody = buf.toString('utf8');}}));app.post("/webhooks/editframe", (req, res) => {const signature = req.headers["x-webhook-signature"] as string;const rawBody = (req as any).rawBody;const secret = process.env.EDITFRAME_WEBHOOK_SECRET!;if (!verifyWebhookSignature(rawBody, signature, secret)) {return res.status(401).send("Invalid signature");}// Process eventres.status(200).send("OK");});
Next.js API Routes
import { NextApiRequest, NextApiResponse } from "next";import crypto from "node:crypto";// Disable body parsing to access raw bodyexport const config = {api: {bodyParser: false,},};async function getRawBody(req: NextApiRequest): Promise<string> {return new Promise((resolve, reject) => {let data = "";req.on("data", (chunk) => { data += chunk; });req.on("end", () => resolve(data));req.on("error", reject);});}export default async function handler(req: NextApiRequest,res: NextApiResponse) {if (req.method !== "POST") {return res.status(405).send("Method not allowed");}const signature = req.headers["x-webhook-signature"] as string;const rawBody = await getRawBody(req);const secret = process.env.EDITFRAME_WEBHOOK_SECRET!;const expectedSignature = crypto.createHmac("sha256", secret).update(rawBody).digest("hex");if (signature !== expectedSignature) {return res.status(401).send("Invalid signature");}const event = JSON.parse(rawBody);// Process eventres.status(200).send("OK");}
Python (Flask)
import hmacimport hashlibfrom flask import Flask, requestapp = Flask(__name__)WEBHOOK_SECRET = "your-webhook-secret"@app.route("/webhooks/editframe", methods=["POST"])def handle_webhook():signature = request.headers.get("X-Webhook-Signature")payload = request.get_data()expected_signature = hmac.new(WEBHOOK_SECRET.encode(),payload,hashlib.sha256).hexdigest()if not hmac.compare_digest(signature, expected_signature):return "Invalid signature", 401event = request.get_json()# Process eventreturn "OK", 200
Ruby (Rails)
require 'openssl'class WebhooksController < ApplicationControllerskip_before_action :verify_authenticity_tokendef editframesignature = request.headers["X-Webhook-Signature"]payload = request.raw_postsecret = ENV["EDITFRAME_WEBHOOK_SECRET"]expected_signature = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('sha256'),secret,payload)unless ActiveSupport::SecurityUtils.secure_compare(signature, expected_signature)render plain: "Invalid signature", status: :unauthorizedreturnendevent = JSON.parse(payload)# Process eventrender plain: "OK", status: :okendend
Secret Management
Retrieving Your Webhook Secret
When you create an API key, you receive:
- API Key: For authenticating API requests
- Webhook Secret: For verifying webhook signatures
Important: The webhook secret is only shown once during API key creation. Store it securely immediately.
Storing Secrets
Never hardcode secrets in your application code. Use environment variables or a secrets manager:
# .envEDITFRAME_API_KEY=ef_live_...EDITFRAME_WEBHOOK_SECRET=abc123...
// Load from environmentconst secret = process.env.EDITFRAME_WEBHOOK_SECRET;if (!secret) {throw new Error("EDITFRAME_WEBHOOK_SECRET not configured");}
Rotating Secrets
To rotate your webhook secret:
- Go to your API key detail page
- Click "Regenerate Webhook Secret"
- Update your application with the new secret
- Deploy the updated application
Warning: Old signatures will fail after secret rotation. Update your application before rotating secrets in production.
Replay Attack Prevention
Prevent replay attacks by implementing timestamp validation:
interface WebhookEvent {topic: string;data: {created_at: string; // ISO 8601 timestamp// ... other fields};}function isWebhookRecent(event: WebhookEvent, maxAgeSeconds: number = 300): boolean {const eventTime = new Date(event.data.created_at).getTime();const now = Date.now();const ageSeconds = (now - eventTime) / 1000;return ageSeconds >= 0 && ageSeconds <= maxAgeSeconds;}// In webhook handlerapp.post("/webhooks/editframe", (req, res) => {const event = req.body;// Verify signature firstif (!verifySignature(req)) {return res.status(401).send("Invalid signature");}// Check timestamp (5 minute tolerance)if (!isWebhookRecent(event, 300)) {return res.status(400).send("Webhook timestamp out of range");}// Process eventres.status(200).send("OK");});
Idempotency
Webhooks may be delivered multiple times due to retries. Implement idempotency to prevent duplicate processing:
const processedEventIds = new Set<string>();app.post("/webhooks/editframe", async (req, res) => {const event = req.body;const eventId = `${event.topic}:${event.data.id}`;// Check if already processedif (processedEventIds.has(eventId)) {console.log(`Duplicate webhook ignored: ${eventId}`);return res.status(200).send("OK"); // Still return 200 to stop retries}// Verify signatureif (!verifySignature(req)) {return res.status(401).send("Invalid signature");}// Process eventawait processWebhookEvent(event);// Mark as processedprocessedEventIds.add(eventId);res.status(200).send("OK");});
For production, use a database or Redis to track processed events:
import { db } from "./database";async function isEventProcessed(eventId: string): Promise<boolean> {const record = await db.query("SELECT 1 FROM processed_webhooks WHERE event_id = $1",[eventId]);return record.rows.length > 0;}async function markEventProcessed(eventId: string): Promise<void> {await db.query("INSERT INTO processed_webhooks (event_id, processed_at) VALUES ($1, NOW())",[eventId]);}
Security Checklist
- Verify HMAC signature on every webhook request
- Use timing-safe comparison for signature verification
- Hash the raw request body, not parsed JSON
- Store webhook secrets in environment variables or secrets manager
- Implement timestamp validation to prevent replay attacks
- Implement idempotency to prevent duplicate processing
- Use HTTPS for webhook endpoints (required)
- Return 200 OK quickly, process events asynchronously
- Log signature verification failures for security monitoring
- Rotate webhook secrets periodically
Common Security Mistakes
1. Hashing Parsed JSON
Wrong:
const signature = crypto.createHmac("sha256", secret).update(JSON.stringify(req.body)) // Different serialization!.digest("hex");
Right:
const signature = crypto.createHmac("sha256", secret).update(req.rawBody) // Raw body as received.digest("hex");
2. Non-Timing-Safe Comparison
Wrong:
if (signature === expectedSignature) { // Vulnerable to timing attacks// ...}
Right:
if (crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expectedSignature))) {// ...}
3. Ignoring Signature Verification
Never do this:
// DON'T: Process webhooks without verificationapp.post("/webhooks/editframe", (req, res) => {// No signature check - anyone can send fake webhooks!processEvent(req.body);res.status(200).send("OK");});
Next Steps
- testing.md — Test signature verification locally
- troubleshooting.md — Debug signature verification issues
- getting-started.md — Complete webhook setup guide