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:

  1. Go to Settings → Webhooks in your workspace
  2. Click Add Webhook
  3. Enter your endpoint URL (must be HTTPS in production)
  4. Select the events you want to receive
  5. 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

  1. Extract X-Webhook-Signature and X-Webhook-Timestamp headers
  2. Concatenate: timestamp + "." + raw_request_body
  3. Compute: HMAC-SHA256(concatenated_string, your_webhook_secret)
  4. Compare using timing-safe comparison (prevents timing attacks)
  5. Verify timestamp is within 5 minutes of current time (prevents replay attacks)

PHP

webhook-handler.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

webhook-handler.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

webhook_handler.py
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

webhook_handler.rb
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

webhook_handler.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-Id for idempotency
  • Use HTTPS - Always use HTTPS endpoints in production
  • Rotate secrets regularly - Rotate your webhook secret periodically