Webhooks
Receive real-time notifications for events in your workspace with cryptographically signed payloads.
Overview
Webhooks allow your application to receive real-time HTTP callbacks when events occur in your workspace. Instead of polling the API, webhooks push data to your server as events happen.
All webhook requests are cryptographically signed using HMAC-SHA256, allowing you to verify that requests genuinely came from our platform and haven't been tampered with.
Security: Always verify webhook signatures before processing. Never trust unverified webhook requests.
Setup
To configure webhooks:
- Go to Settings → Webhooks in your workspace
- Click Add Webhook
- Enter your endpoint URL (must be HTTPS in production)
- Select the events you want to receive
- Save and securely store your webhook secret
Your webhook secret is only shown once when you create the endpoint. Store it securely - you'll need it to verify incoming webhooks.
Event Types
Available webhook events:
| Event | Description |
|---|---|
bio.created |
A new biolink was created |
bio.updated |
A biolink was updated |
bio.deleted |
A biolink was deleted |
link.created |
A new link was created |
link.clicked |
A link was clicked (high volume) |
qrcode.created |
A QR code was generated |
qrcode.scanned |
A QR code was scanned (high volume) |
* |
Subscribe to all events (wildcard) |
Payload Format
Webhook payloads are sent as JSON with a consistent structure:
{
"id": "evt_abc123xyz456",
"type": "bio.created",
"created_at": "2024-01-15T10:30:00Z",
"workspace_id": 1,
"data": {
"id": 123,
"url": "mypage",
"type": "biolink"
}
}
Request Headers
Every webhook request includes the following headers:
| Header | Description |
|---|---|
X-Webhook-Signature |
HMAC-SHA256 signature for verification |
X-Webhook-Timestamp |
Unix timestamp when the webhook was sent |
X-Webhook-Event |
The event type (e.g., bio.created) |
X-Webhook-Id |
Unique delivery ID for idempotency |
Content-Type |
Always application/json |
Signature Verification
To verify a webhook signature, compute the HMAC-SHA256 of the timestamp concatenated with the raw request body using your webhook secret. The signature includes the timestamp to prevent replay attacks.
Verification Algorithm
- Extract
X-Webhook-SignatureandX-Webhook-Timestampheaders - Concatenate:
timestamp + "." + raw_request_body - Compute:
HMAC-SHA256(concatenated_string, your_webhook_secret) - Compare using timing-safe comparison (prevents timing attacks)
- Verify timestamp is within 5 minutes of current time (prevents replay attacks)
PHP
<?php
// Get request data
$payload = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';
$timestamp = $_SERVER['HTTP_X_WEBHOOK_TIMESTAMP'] ?? '';
$secret = getenv('WEBHOOK_SECRET');
// Verify timestamp (5 minute tolerance)
$tolerance = 300;
if (abs(time() - (int)$timestamp) > $tolerance) {
http_response_code(401);
die('Webhook timestamp expired');
}
// Compute expected signature
$signedPayload = $timestamp . '.' . $payload;
$expectedSignature = hash_hmac('sha256', $signedPayload, $secret);
// Verify signature (timing-safe comparison)
if (!hash_equals($expectedSignature, $signature)) {
http_response_code(401);
die('Invalid webhook signature');
}
// Signature valid - process the webhook
$event = json_decode($payload, true);
processWebhook($event);
Node.js
const crypto = require('crypto');
const express = require('express');
const app = express();
app.use(express.raw({ type: 'application/json' }));
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET;
const TOLERANCE = 300; // 5 minutes
app.post('/webhook', (req, res) => {
const signature = req.headers['x-webhook-signature'];
const timestamp = req.headers['x-webhook-timestamp'];
const payload = req.body;
// Verify timestamp
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - parseInt(timestamp)) > TOLERANCE) {
return res.status(401).send('Webhook timestamp expired');
}
// Compute expected signature
const signedPayload = `${timestamp}.${payload}`;
const expectedSignature = crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(signedPayload)
.digest('hex');
// Verify signature (timing-safe comparison)
if (!crypto.timingSafeEqual(
Buffer.from(expectedSignature),
Buffer.from(signature)
)) {
return res.status(401).send('Invalid webhook signature');
}
// Signature valid - process the webhook
const event = JSON.parse(payload);
processWebhook(event);
res.status(200).send('OK');
});
Python
import hmac
import hashlib
import time
import os
from flask import Flask, request, abort
app = Flask(__name__)
WEBHOOK_SECRET = os.environ['WEBHOOK_SECRET']
TOLERANCE = 300 # 5 minutes
@app.route('/webhook', methods=['POST'])
def webhook():
signature = request.headers.get('X-Webhook-Signature', '')
timestamp = request.headers.get('X-Webhook-Timestamp', '')
payload = request.get_data(as_text=True)
# Verify timestamp
if abs(time.time() - int(timestamp)) > TOLERANCE:
abort(401, 'Webhook timestamp expired')
# Compute expected signature
signed_payload = f'{timestamp}.{payload}'
expected_signature = hmac.new(
WEBHOOK_SECRET.encode(),
signed_payload.encode(),
hashlib.sha256
).hexdigest()
# Verify signature (timing-safe comparison)
if not hmac.compare_digest(expected_signature, signature):
abort(401, 'Invalid webhook signature')
# Signature valid - process the webhook
event = request.get_json()
process_webhook(event)
return 'OK', 200
Ruby
require 'sinatra'
require 'openssl'
require 'json'
WEBHOOK_SECRET = ENV['WEBHOOK_SECRET']
TOLERANCE = 300 # 5 minutes
post '/webhook' do
signature = request.env['HTTP_X_WEBHOOK_SIGNATURE'] || ''
timestamp = request.env['HTTP_X_WEBHOOK_TIMESTAMP'] || ''
payload = request.body.read
# Verify timestamp
if (Time.now.to_i - timestamp.to_i).abs > TOLERANCE
halt 401, 'Webhook timestamp expired'
end
# Compute expected signature
signed_payload = "#{timestamp}.#{payload}"
expected_signature = OpenSSL::HMAC.hexdigest(
'sha256',
WEBHOOK_SECRET,
signed_payload
)
# Verify signature (timing-safe comparison)
unless Rack::Utils.secure_compare(expected_signature, signature)
halt 401, 'Invalid webhook signature'
end
# Signature valid - process the webhook
event = JSON.parse(payload)
process_webhook(event)
200
end
Go
package main
import (
"crypto/hmac"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"io"
"math"
"net/http"
"os"
"strconv"
"time"
)
const tolerance = 300 // 5 minutes
func webhookHandler(w http.ResponseWriter, r *http.Request) {
signature := r.Header.Get("X-Webhook-Signature")
timestamp := r.Header.Get("X-Webhook-Timestamp")
secret := os.Getenv("WEBHOOK_SECRET")
payload, _ := io.ReadAll(r.Body)
// Verify timestamp
ts, _ := strconv.ParseInt(timestamp, 10, 64)
if math.Abs(float64(time.Now().Unix()-ts)) > tolerance {
http.Error(w, "Webhook timestamp expired", 401)
return
}
// Compute expected signature
signedPayload := timestamp + "." + string(payload)
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(signedPayload))
expectedSignature := hex.EncodeToString(mac.Sum(nil))
// Verify signature (timing-safe comparison)
if subtle.ConstantTimeCompare(
[]byte(expectedSignature),
[]byte(signature),
) != 1 {
http.Error(w, "Invalid webhook signature", 401)
return
}
// Signature valid - process the webhook
processWebhook(payload)
w.WriteHeader(http.StatusOK)
}
Retry Policy
If your endpoint returns a non-2xx status code or times out, we'll retry with exponential backoff:
| Attempt | Delay |
|---|---|
| 1 (initial) | Immediate |
| 2 | 1 minute |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 (final) | 2 hours |
After 5 failed attempts, the delivery is marked as failed. If your endpoint fails 10 consecutive deliveries, it will be automatically disabled. You can re-enable it from your webhook settings.
Best Practices
- Always verify signatures - Never process webhooks without verification
- Respond quickly - Return 200 within 30 seconds to avoid timeouts
- Process asynchronously - Queue webhook processing for long-running tasks
-
Handle duplicates - Use
X-Webhook-Idfor idempotency - Use HTTPS - Always use HTTPS endpoints in production
- Rotate secrets regularly - Rotate your webhook secret periodically