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:
| Header | Description |
|---|---|
X-WorkFunder-Signature | HMAC-SHA256 signature of the payload |
X-WorkFunder-Timestamp | Unix 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.
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 });
}
);
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
| Attempt | Delay After Previous | Total Time Since First Attempt |
|---|---|---|
| 1 | Immediate | 0 |
| 2 | 1 minute | 1 minute |
| 3 | 5 minutes | 6 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
-
Return 200 quickly. Process events asynchronously (e.g., add to a queue) and return
200 OKimmediately. Long processing times risk timeouts and unnecessary retries. -
Verify every signature. Never skip signature verification, even in development. Use your test webhook secret for test events.
-
Handle unknown event types gracefully. Return
200 OKfor event types you do not handle. This prevents retries for events you intentionally ignore. -
Log all events. Store raw webhook payloads for debugging. Include the event type, timestamp, and delivery attempt number.
-
Use the timestamp to prevent replay attacks. Reject events with timestamps older than 5 minutes.
-
Test with the webhook test endpoint. Use
POST /v1/account/webhook/testto send sample events to your endpoint during development.