Skip to main content

Overview

Adapt can send webhook notifications to your endpoints when crawl jobs complete, fail, or reach specific milestones. This enables you to integrate Adapt into your existing workflows, trigger downstream processes, or build custom notification systems. Key Features:
  • Event-driven notifications (job completed, failed, etc.)
  • HMAC signature validation for security
  • Automatic retry with exponential backoff
  • Idempotent payload design
  • Configurable timeout and rate limits
Custom webhook registration is coming soon. Currently, Adapt supports incoming webhooks from Webflow. Use this documentation to understand the webhook patterns for when custom webhooks become available.

Webhook Events

Adapt will send webhooks for the following events:

Job Completed

Triggered when a crawl job finishes successfully. Event: job.completed Payload:
{
  "event": "job.completed",
  "timestamp": "2024-03-03T14:45:00Z",
  "data": {
    "job_id": "job_123abc",
    "organisation_id": "org_456def",
    "domain": "example.com",
    "status": "completed",
    "total_tasks": 150,
    "completed_tasks": 150,
    "failed_tasks": 5,
    "stats": {
      "avg_response_time": 234,
      "cache_hit_ratio": 0.85,
      "total_bytes": 2048576
    },
    "started_at": "2024-03-03T14:30:00Z",
    "completed_at": "2024-03-03T14:45:00Z",
    "duration_seconds": 900
  },
  "signature": "sha256=a1b2c3d4e5f6..."
}

Job Failed

Triggered when a crawl job fails permanently. Event: job.failed Payload:
{
  "event": "job.failed",
  "timestamp": "2024-03-03T14:50:00Z",
  "data": {
    "job_id": "job_123abc",
    "organisation_id": "org_456def",
    "domain": "example.com",
    "status": "failed",
    "error": {
      "code": "timeout",
      "message": "Job exceeded maximum duration of 1 hour"
    },
    "total_tasks": 150,
    "completed_tasks": 45,
    "failed_tasks": 2,
    "started_at": "2024-03-03T13:50:00Z",
    "failed_at": "2024-03-03T14:50:00Z"
  },
  "signature": "sha256=a1b2c3d4e5f6..."
}

Job Progress

Triggered at 25%, 50%, 75% completion for long-running jobs. Event: job.progress Payload:
{
  "event": "job.progress",
  "timestamp": "2024-03-03T14:38:00Z",
  "data": {
    "job_id": "job_123abc",
    "organisation_id": "org_456def",
    "domain": "example.com",
    "status": "running",
    "progress": {
      "percentage": 50,
      "total_tasks": 150,
      "completed_tasks": 75,
      "failed_tasks": 2
    },
    "stats": {
      "avg_response_time": 245,
      "cache_hit_ratio": 0.83
    }
  },
  "signature": "sha256=a1b2c3d4e5f6..."
}

Security

HMAC Signature Validation

All webhook payloads include an HMAC-SHA256 signature in the signature field. Verify this signature to ensure the webhook came from Adapt. Signature Format:
sha256=<hex_encoded_hmac>
Validation Example (Go):
import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "encoding/json"
)

func validateWebhook(payload []byte, signature, secret string) bool {
    // Remove "sha256=" prefix
    signature = strings.TrimPrefix(signature, "sha256=")
    
    // Calculate expected signature
    h := hmac.New(sha256.New, []byte(secret))
    h.Write(payload)
    expectedSignature := hex.EncodeToString(h.Sum(nil))
    
    // Compare signatures (constant time)
    return hmac.Equal(
        []byte(expectedSignature),
        []byte(signature),
    )
}
Validation Example (Python):
import hmac
import hashlib

def validate_webhook(payload: bytes, signature: str, secret: str) -> bool:
    # Remove "sha256=" prefix
    signature = signature.replace("sha256=", "")
    
    # Calculate expected signature
    expected = hmac.new(
        secret.encode(),
        payload,
        hashlib.sha256
    ).hexdigest()
    
    # Compare signatures (constant time)
    return hmac.compare_digest(expected, signature)
Validation Example (Node.js):
const crypto = require('crypto');

function validateWebhook(payload, signature, secret) {
    // Remove "sha256=" prefix
    signature = signature.replace('sha256=', '');
    
    // Calculate expected signature
    const hmac = crypto.createHmac('sha256', secret);
    hmac.update(payload);
    const expected = hmac.digest('hex');
    
    // Compare signatures (constant time)
    return crypto.timingSafeEqual(
        Buffer.from(expected),
        Buffer.from(signature)
    );
}
Always use constant-time comparison functions to prevent timing attacks. Never use simple string equality (== or ===).

Test Signature Validation

From internal/api/webhook_test.go:16-103, Adapt includes comprehensive tests for signature validation:
func TestWebhookSignatureValidation(t *testing.T) {
    secret := "test-webhook-secret"
    
    tests := []struct {
        name           string
        payload        map[string]any
        signature      string
        expectedStatus int
    }{
        {
            name: "valid_signature",
            payload: map[string]any{
                "site":  "example.com",
                "event": "site_publish",
            },
            signature:      "", // Calculated in test
            expectedStatus: http.StatusOK,
        },
        {
            name: "invalid_signature",
            payload: map[string]any{
                "site":  "example.com",
                "event": "site_publish",
            },
            signature:      "invalid-signature",
            expectedStatus: http.StatusUnauthorized,
        },
    }
}

Receiving Webhooks

Endpoint Requirements

Your webhook endpoint must:
  1. Accept POST requests with Content-Type: application/json
  2. Return 200-299 status codes within 5 seconds
  3. Validate the signature before processing
  4. Be idempotent - handle duplicate deliveries gracefully

Example Handler (Go)

func handleAdaptWebhook(w http.ResponseWriter, r *http.Request) {
    // Read body
    body, err := io.ReadAll(r.Body)
    if err != nil {
        http.Error(w, "Failed to read body", http.StatusBadRequest)
        return
    }
    
    // Get signature from payload
    var payload map[string]any
    if err := json.Unmarshal(body, &payload); err != nil {
        http.Error(w, "Invalid JSON", http.StatusBadRequest)
        return
    }
    
    signature, ok := payload["signature"].(string)
    if !ok {
        http.Error(w, "Missing signature", http.StatusUnauthorized)
        return
    }
    
    // Validate signature
    secret := os.Getenv("ADAPT_WEBHOOK_SECRET")
    if !validateWebhook(body, signature, secret) {
        http.Error(w, "Invalid signature", http.StatusUnauthorized)
        return
    }
    
    // Process webhook
    event := payload["event"].(string)
    switch event {
    case "job.completed":
        handleJobCompleted(payload["data"])
    case "job.failed":
        handleJobFailed(payload["data"])
    case "job.progress":
        handleJobProgress(payload["data"])
    }
    
    // Return success
    w.WriteHeader(http.StatusOK)
    json.NewEncoder(w).Encode(map[string]string{"status": "received"})
}

Example Handler (Python/Flask)

from flask import Flask, request, jsonify
import json

app = Flask(__name__)

@app.route('/webhooks/adapt', methods=['POST'])
def handle_adapt_webhook():
    # Read body
    body = request.get_data()
    
    # Parse JSON
    payload = request.get_json()
    if not payload:
        return jsonify({"error": "Invalid JSON"}), 400
    
    # Validate signature
    signature = payload.get('signature')
    if not signature:
        return jsonify({"error": "Missing signature"}), 401
    
    secret = os.environ['ADAPT_WEBHOOK_SECRET']
    if not validate_webhook(body, signature, secret):
        return jsonify({"error": "Invalid signature"}), 401
    
    # Process webhook
    event = payload['event']
    if event == 'job.completed':
        handle_job_completed(payload['data'])
    elif event == 'job.failed':
        handle_job_failed(payload['data'])
    elif event == 'job.progress':
        handle_job_progress(payload['data'])
    
    return jsonify({"status": "received"}), 200

Retry Logic

Adapt implements automatic retries for failed webhook deliveries:
1

Initial Attempt

Webhook sent immediately when event occurs.
2

First Retry

After 1 minute if initial delivery fails.
3

Second Retry

After 5 minutes (exponential backoff).
4

Third Retry

After 15 minutes (final attempt).
5

Give Up

After 3 failed attempts, webhook is marked as failed.
Failure Conditions:
  • HTTP status code >= 500
  • Network timeout (5 seconds)
  • Connection refused
  • TLS errors
4xx status codes (except 429) are not retried, as they indicate client errors that won’t be resolved by retrying.

Idempotency

Handling Duplicates

From internal/api/webhook_test.go:211-295, Adapt’s webhook system is designed to be idempotent:
func TestWebhookDuplication(t *testing.T) {
    processedWebhooks := make(map[string]bool)
    
    tests := []struct {
        webhookID      string
        expectedStatus int
    }{
        {"webhook-123", http.StatusOK},     // First
        {"webhook-123", http.StatusOK},     // Duplicate
        {"webhook-456", http.StatusOK},     // Different
    }
    
    // Handler checks if webhook already processed
    if _, exists := processedWebhooks[webhookID]; exists {
        // Return success but don't reprocess
        return
    }
    
    processedWebhooks[webhookID] = true
}
Your Implementation: Store processed webhook IDs (from data.job_id + timestamp) to detect duplicates:
type WebhookLog struct {
    ID        string
    Event     string
    JobID     string
    Timestamp time.Time
    Processed bool
}

// Check if already processed
webhookID := fmt.Sprintf("%s:%s", payload["data"].(map[string]any)["job_id"], payload["timestamp"])
if db.WebhookExists(webhookID) {
    return // Already processed
}

// Process and mark as complete
db.CreateWebhookLog(webhookID, event, jobID)

Rate Limits

From internal/api/webhook_test.go:368-451, Adapt respects rate limits:
  • Max requests: 100 per minute per endpoint
  • Burst capacity: 10 requests
  • Headers returned:
    • X-RateLimit-Limit: 100
    • X-RateLimit-Remaining: 95
    • Retry-After: 60 (when limited)
If your endpoint returns 429 (Too Many Requests), Adapt will respect the Retry-After header and delay the next attempt.

Testing

Local Testing with ngrok

Expose your local endpoint to receive webhooks:
# Install ngrok
brew install ngrok  # macOS

# Start tunnel
ngrok http 3000

# Use the HTTPS URL as your webhook endpoint
https://abc123.ngrok.io/webhooks/adapt

Test Payload

Send a test webhook to your endpoint:
curl -X POST https://your-domain.com/webhooks/adapt \
  -H "Content-Type: application/json" \
  -d '{
    "event": "job.completed",
    "timestamp": "2024-03-03T14:45:00Z",
    "data": {
      "job_id": "job_test123",
      "organisation_id": "org_test",
      "domain": "example.com",
      "status": "completed",
      "total_tasks": 10,
      "completed_tasks": 10
    },
    "signature": "sha256=calculated_signature"
  }'

Common Issues

Signature Validation Fails

Symptom: All webhooks rejected with 401 Causes:
  1. Using wrong secret for validation
  2. Modifying payload before validation
  3. Not using constant-time comparison
  4. Character encoding mismatch
Solution: Validate signature against raw request body before parsing JSON

Duplicate Events

Symptom: Same webhook received multiple times Cause: Retry logic after transient failures Solution: Implement idempotency checking using webhook ID + timestamp

Timeout Errors

Symptom: Webhooks fail despite endpoint working Cause: Response time > 5 seconds Solution: Return 200 OK immediately, process webhook asynchronously
func handleWebhook(w http.ResponseWriter, r *http.Request) {
    // Validate and read payload
    body, _ := io.ReadAll(r.Body)
    
    // Return success immediately
    w.WriteHeader(http.StatusOK)
    json.NewEncoder(w).Encode(map[string]string{"status": "received"})
    
    // Process asynchronously
    go func() {
        processWebhookAsync(body)
    }()
}

Webhook Registration (Coming Soon)

In the future, you’ll be able to register custom webhooks via the API:
POST /v1/webhooks
Authorization: Bearer <your_jwt_token>
Content-Type: application/json

{
  "url": "https://your-domain.com/webhooks/adapt",
  "events": ["job.completed", "job.failed"],
  "secret": "your-webhook-secret"
}

Best Practices

1

Always Validate Signatures

Verify HMAC signature before processing any webhook.
2

Respond Quickly

Return 200 OK within 5 seconds. Process webhooks asynchronously.
3

Handle Duplicates

Implement idempotency checks using webhook ID.
4

Monitor Failures

Log failed webhook deliveries and alert on repeated failures.
5

Use HTTPS

Always use HTTPS endpoints to protect webhook secrets in transit.

Next Steps

Webflow Integration

Learn about incoming Webflow webhooks

API Reference

Explore the full API documentation

Build docs developers (and LLMs) love