Skip to main content

Webhook Security

This guide covers how to verify webhook signatures, handle retries, and build reliable webhook handlers.

Signature Verification

Every webhook request includes a signature that you should verify to ensure the request genuinely came from WorkFunder and has not been tampered with.

Signature Headers

Each webhook request includes two security headers:

HeaderDescription
X-WorkFunder-SignatureHMAC-SHA256 signature of the payload
X-WorkFunder-TimestampUnix timestamp of when the event was sent

How Signatures Work

The signature is computed as:

HMAC-SHA256(webhook_secret, "{timestamp}.{raw_body}")

The X-WorkFunder-Signature header contains the signature prefixed with v1=:

X-WorkFunder-Signature: v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd

Webhook Secret

Your webhook secret is generated when you first configure a webhook URL. You can find it in your Dashboard under Webhook Settings. The secret is a random string used as the HMAC key.

caution

Keep your webhook secret secure. If you suspect it has been compromised, regenerate it from the dashboard. After regeneration, update your server immediately -- the old secret will stop working.

Verification Example (Node.js)

import crypto from "crypto";

function verifyWebhookSignature(
rawBody: string,
signatureHeader: string,
timestampHeader: string,
webhookSecret: string
): boolean {
// 1. Check timestamp to prevent replay attacks (allow 5 min tolerance)
const timestamp = parseInt(timestampHeader, 10);
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - timestamp) > 300) {
return false; // Timestamp too old or too far in the future
}

// 2. Compute expected signature
const signedPayload = `${timestamp}.${rawBody}`;
const expectedSignature = crypto
.createHmac("sha256", webhookSecret)
.update(signedPayload)
.digest("hex");

// 3. Compare signatures (timing-safe)
const expected = `v1=${expectedSignature}`;
return crypto.timingSafeEqual(
Buffer.from(signatureHeader),
Buffer.from(expected)
);
}

Verification Example (Python)

import hashlib
import hmac
import time


def verify_webhook_signature(
raw_body: str,
signature_header: str,
timestamp_header: str,
webhook_secret: str,
) -> bool:
# 1. Check timestamp (5 min tolerance)
timestamp = int(timestamp_header)
now = int(time.time())
if abs(now - timestamp) > 300:
return False

# 2. Compute expected signature
signed_payload = f"{timestamp}.{raw_body}"
expected = hmac.new(
webhook_secret.encode("utf-8"),
signed_payload.encode("utf-8"),
hashlib.sha256,
).hexdigest()

# 3. Compare (timing-safe)
return hmac.compare_digest(f"v1={expected}", signature_header)

Full Express.js Handler

import express from "express";
import crypto from "crypto";

const app = express();
const WEBHOOK_SECRET = process.env.WORKFUNDER_WEBHOOK_SECRET!;

// Important: use raw body for signature verification
app.post(
"/webhooks/workfunder",
express.raw({ type: "application/json" }),
(req, res) => {
const rawBody = req.body.toString("utf-8");
const signature = req.headers["x-workfunder-signature"] as string;
const timestamp = req.headers["x-workfunder-timestamp"] as string;

// Verify signature
if (!verifyWebhookSignature(rawBody, signature, timestamp, WEBHOOK_SECRET)) {
console.error("Invalid webhook signature");
return res.status(401).send("Invalid signature");
}

// Parse and process
const event = JSON.parse(rawBody);
console.log(`Received event: ${event.event}`);

// Process asynchronously to return quickly
processWebhookEvent(event).catch(console.error);

res.status(200).json({ received: true });
}
);
warning

Always verify the signature before processing the event. Never trust the payload without verification, as an attacker could send fake events to your endpoint.

Retry Policy

If WorkFunder does not receive a 2xx response within 30 seconds, the delivery is considered failed and will be retried.

Retry Schedule

AttemptDelay After PreviousTotal Time Since First Attempt
1Immediate0
21 minute1 minute
35 minutes6 minutes

After 3 failed attempts, the delivery status is set to exhausted. The failed delivery is logged in your dashboard and an alert is sent to your account email.

What Counts as a Failure

  • HTTP response status code >= 300
  • Connection timeout (30 seconds)
  • Connection refused or DNS resolution failure
  • TLS/SSL errors

Monitoring Failed Deliveries

View failed webhook deliveries in the Dashboard or via the admin API. Each delivery record includes:

  • Event type and payload
  • Response status code from your server
  • Response body (first 1000 characters)
  • Number of attempts
  • Next retry time (if applicable)

Idempotency

Because webhooks use at-least-once delivery, your handler may receive the same event more than once. Build your handler to be idempotent -- processing the same event twice should produce the same result.

Strategies for Idempotency

1. Track processed event IDs

Store the combination of event type and resource ID, and skip duplicates:

async function processWebhookEvent(event: WebhookEvent) {
const eventKey = `${event.event}:${event.data.id}:${event.timestamp}`;

// Check if we already processed this event
const alreadyProcessed = await db.get("processed_events", eventKey);
if (alreadyProcessed) {
console.log(`Skipping duplicate event: ${eventKey}`);
return;
}

// Process the event
switch (event.event) {
case "task.completed":
await handleTaskCompleted(event.data);
break;
// ... other events
}

// Mark as processed
await db.set("processed_events", eventKey, true);
}

2. Use database transactions with unique constraints

async function handleTaskCompleted(data: TaskData) {
// Using upsert ensures processing twice has no side effects
await db
.insertInto("completed_tasks")
.values({
task_id: data.id,
completed_at: data.completed_at,
payout_cents: data.worker_payout_cents,
})
.onConflict("task_id")
.doNothing()
.execute();
}

3. Check resource state before acting

async function handleTaskCompleted(data: TaskData) {
const existing = await db.get("tasks", data.id);

// Only process if we haven't already handled completion
if (existing.status === "completed") {
return; // Already processed
}

await db.update("tasks", data.id, { status: "completed" });
await sendCompletionNotification(data);
}

Best Practices

  1. Return 200 quickly. Process events asynchronously (e.g., add to a queue) and return 200 OK immediately. Long processing times risk timeouts and unnecessary retries.

  2. Verify every signature. Never skip signature verification, even in development. Use your test webhook secret for test events.

  3. Handle unknown event types gracefully. Return 200 OK for event types you do not handle. This prevents retries for events you intentionally ignore.

  4. Log all events. Store raw webhook payloads for debugging. Include the event type, timestamp, and delivery attempt number.

  5. Use the timestamp to prevent replay attacks. Reject events with timestamps older than 5 minutes.

  6. Test with the webhook test endpoint. Use POST /v1/account/webhook/test to send sample events to your endpoint during development.