# Hook0 Documentation > Hook0 sends webhooks on your behalf: one API call per event, HMAC signatures, retries with a dead letter queue, delivery logs, and a subscriber portal your customers manage themselves. Open-source (SSPL-1.0) — self-host it, or use the cloud hosted in the EU (France). Every page published here is listed below. This is the full-text companion to /llms.txt: every page it lists is inlined below in full, in the same order, so an assistant can read the whole documentation in one file. # Hook0 Documentation Welcome to Hook0, the open-source webhook server for reliable event delivery at scale. ## What is Hook0? Hook0 is a production-ready webhook server that ensures your events reach their destinations with enterprise-grade reliability, security, and observability. Available as a cloud service or self-hosted.
### New to Hook0? 1. **[What is Hook0?](explanation/what-is-hook0.md)** - Understand core concepts 2. **[Quick Start](tutorials/getting-started.md)** - Get running in 5 minutes 3. **[First Webhook](tutorials/first-webhook-integration.md)** - Build your first integration 4. **[Hook0 Play](https://play.hook0.com)** -- Test webhooks instantly, no signup
### Ready to Build? 1. **[Event Types & Subscriptions](tutorials/event-types-subscriptions.md)** - Configure events 2. **[API Reference](/api)** - Complete REST API docs 3. **[JavaScript SDK](reference/sdk/javascript.md)** - Official client library 4. **[MCP Server](reference/mcp.md)** - Control Hook0 with AI assistants
--- ## Documentation Sections This documentation follows the [Diataxis methodology](https://diataxis.fr/): | Section | Purpose | Start Here | |---------|---------|------------| | **[Tutorials](tutorials/index.md)** | Step-by-step learning | [Getting Started](tutorials/getting-started.md) | | **[How-to Guides](how-to-guides/index.md)** | Solve specific problems | [Debug Webhooks](how-to-guides/debug-failed-webhooks.md) | | **[Reference](reference/index.md)** | Technical specifications | [API Reference](/api) | | **[Explanation](explanation/index.md)** | Deep understanding | [Architecture](explanation/hook0-architecture.md) | --- ## Key Features | Feature | Description | |---------|-------------| | **Reliable Delivery** | Automatic retries with a fixed retry schedule ([request attempts](/concepts/request-attempts)) | | **Security** | HMAC-SHA-256 signatures via [subscription secrets](/concepts/subscriptions#subscription-secrets), TLS encryption | | **Observable** | Built-in metrics, logs, and delivery tracking | | **High Performance** | Rust-based, handles thousands of events/second | | **Flexible** | Event filtering, label-based routing | | **Open Source** | Self-host or use our cloud service | | **AI-Ready** | Control via Claude, Cursor, or any MCP-compatible assistant | --- ## Quick Links ### By Role **Developers** - [API Reference](/api) - REST API documentation - [SDKs](reference/sdk/index.md) - Eleven clients, one page each, all held to the same contract - [CLI](reference/cli.mdx) - Send events and receive webhooks on localhost from your terminal - [JavaScript SDK](reference/sdk/javascript.md) - TypeScript/JS client - [Rust SDK](reference/sdk/rust.md) - Rust client library - [MCP Server](reference/mcp.md) - AI assistant integration - [Error Codes](reference/error-codes.md) - Troubleshooting **DevOps** - [Configuration](reference/configuration.md) - All options - [Monitor Performance](how-to-guides/monitor-webhook-performance.md) - Observability **Architects** - [Architecture](explanation/hook0-architecture.md) - System design - [Security Model](explanation/security-model.md) - Security architecture - [Event Processing](explanation/event-processing.md) - Delivery pipeline --- ## Resources - **[GitHub](https://github.com/hook0/hook0)** - Source code - **[Discord](https://www.hook0.com/community)** - Community chat - **[Blog](https://www.hook0.com/blog)** - Articles & tutorials - **[Status](https://status.hook0.com)** - Service status --- *Hook0 - Enterprise-grade webhook delivery for modern applications.* import Head from '@docusaurus/Head'; # What is Hook0? Hook0 is an open-source webhook server (Webhooks as a Service). It receives events from your applications and delivers them to configured webhook endpoints, handling retries, signatures, and monitoring for you. ## The problem When your application needs to send webhooks, you end up building: - Retry logic for when the receiving system is down - Queue management for thousands of deliveries - Monitoring to track and debug failures - Signature generation for authenticity Hook0 handles all of that so you don't have to. ## Before and after Hook0 ### Without Hook0 ```mermaid flowchart TD subgraph YourApp["Your Application"] BL["Core Business Logic"]:::customer WL["Webhook Logic
(you maintain)
- Retry logic & backoff
- Failure handling
- Queue management
- Monitoring & logging
- Security signatures
- Dead letter queues"]:::customer BL --> WL end WL --> CW["Customer Webhook
Is it down?"]:::danger classDef customer fill:#ffedd5,stroke:#fb923c,color:#7c2d12 classDef danger fill:#fee2e2,stroke:#f87171,color:#7f1d1d ``` Problems: - Webhook code scattered across your application - No centralized visibility on delivery status - Retry and failure logic you have to maintain yourself - Hard to debug failed deliveries ### With Hook0 ```mermaid flowchart TD subgraph YourApp["Your Application"] BL["Business Logic"]:::customer end subgraph H0["Hook0"] EP["Event Processing
- Validation
- Storage"]:::hook0 WDE["Webhook Delivery Engine
- Automatic retries
- Fixed retry schedule
- Signature generation
- Rate limiting
- Failed attempts kept for replay"]:::hook0 OD["Observability Dashboard
- All delivery attempts
- Failure analytics
- Debug tools"]:::hook0 EP --> WDE --> OD end BL -- "Simple HTTP POST" --> EP OD --> CW["Customer Webhook
Reliable"]:::external classDef external fill:#dbeafe,stroke:#60a5fa,color:#1e3a5f classDef hook0 fill:#dcfce7,stroke:#4ade80,color:#14532d classDef customer fill:#ffedd5,stroke:#fb923c,color:#7c2d12 click EP "/explanation/event-processing" "Event Processing" click WDE "/explanation/webhook-retry-logic" "Retry Logic" click CW "/concepts/subscriptions" "Subscriptions" ``` What changes: - One API call to send events - One dashboard for all deliveries - Retry logic works out of the box - Full visibility and debugging tools ## How Hook0 works 1. Your application sends events to Hook0 via REST API 2. Hook0 validates and stores events 3. Hook0 delivers events to configured webhook endpoints 4. Failed deliveries are retried on a fixed schedule (3s, 10s, 3min, 30min, 1h, 3h, 5h, 10h) 5. All attempts are logged and viewable in the dashboard ## Core concepts ### [Organizations](/concepts/organizations) The top-level entity. Organizations group users and applications together, providing isolation and access control. ### [Applications](/concepts/applications) Applications represent your services or products within an organization. Each application can define event types and have multiple subscriptions. ### [Event types](/concepts/event-types) Event types define what events your application can emit. Examples: - `user.account.created` - `payment.transaction.completed` - `order.shipment.shipped` ### [Events](/concepts/events) An event is a specific occurrence of an event type, containing: - Event type identifier - Payload data - Metadata - Timestamp ### [Subscriptions](/concepts/subscriptions) A subscription defines where and how webhook notifications get delivered: - Target webhook URL - Which event types to receive - Authentication headers - Retry configuration ### [Request attempts](/concepts/request-attempts) Hook0 tracks every delivery attempt: - Response status codes - Response bodies - Timestamps - Retry attempts ## Why Hook0? - Open source (SSPL v1), self-hostable, or use the [cloud version](https://www.hook0.com/) - Written in Rust, scales horizontally - RESTful API with [official SDKs](../reference/sdk/index.md) for eleven languages - Automatic retries, failed deliveries kept for [replay](webhook-dead-letter-queue.md), rate limiting - Full delivery visibility in the dashboard ## Use cases - SaaS integration: let customers receive webhook notifications from your platform - Microservices: decouple services with async webhook delivery - Audit and compliance: track all events and their delivery status - Third-party integrations: connect to Slack, Discord, or any HTTP endpoint ## Next steps - [Getting Started Tutorial](../tutorials/getting-started.md) - [Hook0 Play](https://play.hook0.com) -- Try webhooks instantly: generate a URL and watch events arrive in real-time - [System Architecture](./hook0-architecture.md) - Detailed technical architecture - [API Reference](../openapi/intro) - [Webhook best practices](../how-to-guides/webhook-best-practices.md) - Production patterns for sending and receiving webhooks - [How Hook0 compares](../comparisons/) - Side-by-side comparison with Svix, Hookdeck, and other providers # Getting Started with Hook0 This tutorial will guide you through creating an application and sending your first webhook event. By the end, you will have a working webhook integration. ## Prerequisites - **For Self-Hosted**: Docker and Docker Compose installed. **🚨 You'll need to replace the API base URL with your own domain or http://localhost:8081 in the examples.** - **For Cloud**: A Hook0 account at [hook0.com](https://www.hook0.com/) - Basic understanding of HTTP APIs - cURL or similar HTTP client - Familiarity with [Hook0 Core Concepts](../explanation/what-is-hook0.md#core-concepts) ### Set Up Environment Variables ```bash # Set your service token (from dashboard) export HOOK0_TOKEN="YOUR_TOKEN_HERE" export HOOK0_API="https://app.hook0.com/api/v1" # Replace by your domain (or http://localhost:8081 locally) # Set your application ID (shown in dashboard URL or application details) export APP_ID="YOUR_APPLICATION_ID_HERE" ``` Save these values: ```bash # Save to .env file for later use cat > .env < ```json { "service_name": "user", "resource_type_name": "account", "verb_name": "created", "event_type_name": "user.account.created" } ``` This creates an event type named `user.account.created` (composed from `service.resource_type.verb`). ## Step 5: Create a Webhook Subscription [Subscriptions](/concepts/subscriptions) define where Hook0 should send webhook notifications. For this tutorial, use [webhook.site](https://webhook.site) to create a test endpoint: 1. **Visit [webhook.site](https://webhook.site)** 2. **Copy your unique URL** (e.g., `https://webhook.site/abc123`) :::tip Test webhooks instantly Use [Hook0 Play](https://play.hook0.com) to generate a webhook URL in one click — no signup required. Paste the URL as your subscription endpoint to see events arrive in real-time. ::: ### Create the Subscription ```bash curl -X POST "$HOOK0_API/subscriptions" \ -H "Authorization: Bearer $HOOK0_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "application_id": "'"$APP_ID"'", "is_enabled": true, "event_types": ["user.account.created"], "description": "Tutorial webhook endpoint", "labels": { "environment": "tutorial" }, "target": { "type": "http", "method": "POST", "url": "https://webhook.site/YOUR_UNIQUE_ID", "headers": { "X-Custom-Header": "my-value" } } }' ``` You'll receive a response with the subscription ID and secret: ```json { "subscription_id": "{SUBSCRIPTION_ID}", "secret": "{SECRET}", ... } ``` ⚠️ **Important**: Save the `secret` - you'll need it to verify webhook signatures. Keep the `subscription_id`, you'll need it in step 8. ## Step 6: Send Your First Event Now let's trigger a webhook by sending an event to Hook0: :::warning Labels Required Every event must include at least one label. Labels are used to route events to the correct subscriptions. An empty `labels: {}` object will be rejected by the API. ::: ```bash curl -X POST "$HOOK0_API/event" \ -H "Authorization: Bearer $HOOK0_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "application_id": "'"$APP_ID"'", "event_type": "user.account.created", "payload": "{\"user_id\": 123, \"email\": \"john.doe@example.com\"}", "payload_content_type": "application/json", "labels": { "environment": "tutorial" }, "occurred_at": "'$(date -u +"%Y-%m-%dT%H:%M:%SZ")'" }' ``` :::warning Payload Format The `payload` field must be a **JSON-encoded string**, not a raw object. See [Understanding Payload Format](#understanding-payload-format) below for details. ::: ### Understanding Payload Format A common source of confusion is the payload format. **The `payload` field must be a JSON-encoded string, not a JavaScript/JSON object.** #### Why String Format? Hook0 forwards payloads exactly as received without re-serialization. This ensures: - No data transformation or modification - Preserved formatting and whitespace - Support for any content type (not just JSON) - Zero overhead processing #### Correct vs Incorrect Format **✅ Correct** - String with escaped quotes: ```json { "event_type": "user.account.created", "payload": "{\"user_id\": \"123\", \"email\": \"user@example.com\"}", "payload_content_type": "application/json" } ``` **❌ Incorrect** - Raw object: ```json { "event_type": "user.account.created", "payload": {"user_id": "123", "email": "user@example.com"}, "payload_content_type": "application/json" } ``` ### Expected Response ```json { "application_id": "{APP_ID}", "event_id": "{EVENT_ID}", "received_at": "2024-01-15T10:30:01Z" } ``` ## Step 7: Verify Webhook Delivery 1. **Check webhook.site** - you should see a new request containing your event payload. 2. **Check the Hook0 dashboard** - navigate to Request Attempts to see: - Event details - Delivery status - Retry attempts (if any) ## Step 8: Verify Webhook Signature Hook0 signs all webhook deliveries with HMAC-SHA256. You should always verify signatures to ensure webhooks are authentic. :::tip Forgot your application secret? You can get it with: ```bash curl "$HOOK0_API/subscriptions/$SUBSCRIPTION_ID?application_id=$APP_ID" \ -H "Authorization: Bearer $HOOK0_TOKEN" ``` Note the `secret` field in the response. ::: ### Verify the Signature See [Implementing Webhook Authentication](./webhook-authentication.md) for complete signature verification code in JavaScript, Python, and Go. ## 🎉 Congrats, you made it to the end! ### What You've Learned ✅ Created a Hook0 organization and application ✅ Used the API token that came with your application ✅ Defined event types for your application ✅ Set up webhook subscriptions ✅ Sent events through the Hook0 API ✅ Verified webhook delivery and signatures ## Next Steps Now that you have the basics, try these advanced tutorials: - [Building Your First Webhook Integration](./first-webhook-integration.md) - [Setting up Event Types and Subscriptions](./event-types-subscriptions.md) - [Implementing Webhook Authentication](./webhook-authentication.md) - [Webhook best practices](../how-to-guides/webhook-best-practices.md) - Production patterns for payload design, retries, and security - [Webhook retry logic](../explanation/webhook-retry-logic.md) - How Hook0 handles failed deliveries ## Common Issues ### Event Not Delivered - Check subscription is enabled - Verify event type and labels match subscription - Check that the URL you specified as target in your subscription is accessible ### Authentication Errors - Ensure token has correct permissions - Check token hasn't expired - Verify Bearer token format - Test with a fresh token ### Signature Verification Failed - Use the raw request body for signature verification - Check secret is correct - Ensure consistent character encoding - Verify HMAC algorithm (SHA256) ## API Reference - [Events API](../openapi/intro) - [Subscriptions API](../openapi/intro) - [Event Types API](../openapi/intro) # Concepts Understand the fundamental building blocks of Hook0. ## Architecture Overview ```mermaid flowchart TD ORG["Organization"]:::external APP["Application"]:::hook0 ET["Event Type
(defines structure)"]:::customer EVT["Event
(sent by your app)"]:::customer LBL["Labels
(routing metadata)"]:::processing SUB["Subscription
(delivery target)"]:::hook0 RA["Request Attempt
(delivery tracking)"]:::processing WH["Webhook Endpoint"]:::customer ORG --> APP APP --> ET APP --> EVT APP --> SUB EVT --> LBL SUB --> RA RA --> WH classDef external fill:#dbeafe,stroke:#60a5fa,color:#1e3a5f classDef hook0 fill:#dcfce7,stroke:#4ade80,color:#14532d classDef customer fill:#ffedd5,stroke:#fb923c,color:#7c2d12 classDef processing fill:#ede9fe,stroke:#a78bfa,color:#3b0764 click ORG "/concepts/organizations" "Organizations" click APP "/concepts/applications" "Applications" click ET "/concepts/event-types" "Event Types" click EVT "/concepts/events" "Events" click LBL "/concepts/labels" "Labels" click SUB "/concepts/subscriptions" "Subscriptions" click RA "/concepts/request-attempts" "Request Attempts" ``` Hook0 connects your applications to external systems through a hierarchical structure: 1. **Organizations** group your team and applications 2. **Applications** represent your services that emit events 3. **Events** are notifications sent when actions occur 4. **Event Types** categorize and validate your events 5. **Labels** route events to the correct subscriptions 6. **Subscriptions** define where to deliver webhooks 7. **Request Attempts** track each delivery attempt ## Core Concepts ### Structure & Organization - **[Organizations](organizations.md)** - Multi-tenant containers for teams and applications - **[Applications](applications.md)** - Logical containers grouping events and subscriptions ### Events & Routing - **[Events](events.md)** - Notifications sent from your applications to Hook0 - **[Event Types](event-types.md)** - Categorize and structure your events - **[Labels](labels.md)** - Filter and route events to subscriptions - **[Metadata](metadata.md)** - Attach arbitrary key-value data to objects ### Delivery & Security - **[Subscriptions](subscriptions.md)** - Configure where and how to receive notifications, and hold the secret that signs their deliveries - **[Request Attempts](request-attempts.md)** - Track webhook delivery status and retries - **[Application Secrets](application-secrets.md)** - API tokens for calling the Hook0 API on an application's behalf - **[Service Tokens](service-tokens.md)** - API authentication for automated systems # Organizations An organization is the top-level container in Hook0 that groups [applications](applications.md), manages team members, and controls billing. Organizations enable multi-tenant architectures where different teams or customers have isolated webhook infrastructures. ## Key Points - Organizations contain [Applications](applications.md) and team members - Each organization has its own quotas, plan, and billing - Members can have different roles (Editor, Viewer) - Organizations provide complete isolation between tenants ## Relationship to Other Concepts ```mermaid flowchart TD ORG["Organization
(Acme Corp)"]:::external MEM["Members"]:::hook0 ALICE["Alice (Editor)"]:::customer BOB["Bob (Viewer)"]:::customer APPS["Applications"]:::hook0 ORD["Order Service"]:::customer USR["User Service"]:::customer PLAN["Plan & Quotas"]:::processing ORG --> MEM ORG --> APPS ORG --> PLAN MEM --> ALICE MEM --> BOB APPS --> ORD APPS --> USR classDef external fill:#dbeafe,stroke:#60a5fa,color:#1e3a5f classDef hook0 fill:#dcfce7,stroke:#4ade80,color:#14532d classDef customer fill:#ffedd5,stroke:#fb923c,color:#7c2d12 classDef processing fill:#ede9fe,stroke:#a78bfa,color:#3b0764 click APPS "/concepts/applications" "Applications" click ORD "/concepts/applications" "Application" click USR "/concepts/applications" "Application" ``` ## Multi-Tenancy Organizations are the foundation of Hook0's multi-tenant architecture: - **SaaS providers** can create one organization per customer - **Enterprises** can create one organization per department or team - **Agencies** can create one organization per client project Each organization operates independently with its own: - [Applications](applications.md) and [event types](event-types.md) - [Subscriptions](subscriptions.md) and [secrets](application-secrets.md) - Usage quotas and billing - Team members and permissions ## Member Roles Organizations support role-based access control: - **Editor** - Full access: create, edit, delete [applications](applications.md), manage members - **Viewer** - Read-only access to [applications](applications.md) and [events](events.md) ## Quotas Organizations have quotas that limit resource usage based on the billing plan: - **Members per organization** - Maximum team members - **Applications per organization** - Maximum [applications](applications.md) - **[Events](events.md) per day** - Maximum events across all applications - **Event retention** - How long [events](events.md) are stored ## Onboarding Progress Hook0 tracks organization setup through steps: 1. **[Application](applications.md)** - Create at least one application 2. **[Event Type](event-types.md)** - Define at least one event type 3. **[Subscription](subscriptions.md)** - Configure at least one webhook endpoint 4. **[Event](events.md)** - Send your first event ## Lifecycle Before deleting an organization: - All [applications](applications.md) must be deleted first - This is enforced to prevent accidental data loss :::danger Irreversible Action Organization deletion cannot be undone. Ensure all [applications](applications.md) are removed and data is backed up before proceeding. ::: ## What's Next? - [Applications](applications.md) - Creating applications within your organization - [Service Tokens](service-tokens.md) - API access for automated systems - [Events](events.md) - Sending your first webhook event - [Getting Started](/tutorials/getting-started) - Set up your first organization # Applications An application is a logical container within Hook0 that groups related [event types](event-types.md), [subscriptions](subscriptions.md), and [events](events.md). Applications represent your services or products that emit webhook events. ## Key Points - Applications belong to an [Organization](organizations.md) - Each application has its own [event types](event-types.md), [subscriptions](subscriptions.md), and [secrets](application-secrets.md) - Applications provide usage quotas and consumption tracking - Deleting an application cancels all pending webhook deliveries ## Relationship to Other Concepts ```mermaid flowchart TD ORG["Organization"]:::external APP["Application"]:::hook0 ET["Event Types
(categories of events)"]:::customer SUB["Subscriptions
(webhook endpoints)"]:::customer SEC["Application Secrets
(API keys)"]:::processing EVT["Events
(notifications sent)"]:::customer ORG --> APP APP --> ET APP --> SUB APP --> SEC APP --> EVT classDef external fill:#dbeafe,stroke:#60a5fa,color:#1e3a5f classDef hook0 fill:#dcfce7,stroke:#4ade80,color:#14532d classDef customer fill:#ffedd5,stroke:#fb923c,color:#7c2d12 classDef processing fill:#ede9fe,stroke:#a78bfa,color:#3b0764 click ORG "/concepts/organizations" "Organizations" click ET "/concepts/event-types" "Event Types" click SUB "/concepts/subscriptions" "Subscriptions" click SEC "/concepts/application-secrets" "Application Secrets" click EVT "/concepts/events" "Events" ``` Applications act as the boundary between your services. A typical setup might have: - **Order Service Application** - Emits `order.created`, `order.shipped` [events](events.md) - **User Service Application** - Emits `user.registered`, `user.updated` [events](events.md) - **Billing Service Application** - Emits `invoice.paid`, `subscription.renewed` [events](events.md) ## Quotas and Limits Each application operates within quotas defined by your [organization's](organizations.md) plan: - **[Events](events.md) per day** - Maximum events that can be sent daily - **Event retention** - How long [events](events.md) are stored for replay When quotas are exceeded, new [events](events.md) are rejected until the next billing period (for organizations on Free Plan) or billed as extra (see [pricing](https://www.hook0.com/pricing)). ## Onboarding Progress Hook0 tracks application setup through onboarding steps: 1. **[Event Type](event-types.md)** - Define at least one event type 2. **[Subscription](subscriptions.md)** - Configure at least one webhook endpoint 3. **[Event](events.md)** - Send your first event ## Lifecycle When an application is deleted: - The application is soft-deleted (can be audited) - All pending webhook deliveries are cancelled - No new [events](events.md) can be sent - Existing [subscriptions](subscriptions.md) stop receiving webhooks :::danger Irreversible Action Deleting an application cannot be undone. All pending webhook deliveries will be cancelled immediately. ::: ## What's Next? - [Organizations](organizations.md) - Managing the parent container - [Event Types](event-types.md) - Categorizing your events - [Application Secrets](application-secrets.md) - API tokens for calling the Hook0 API - [Events](events.md) - Sending notifications - [Getting Started](/tutorials/getting-started) - Create your first application # Events An event is a notification sent from your [applications](applications.md) to Hook0 when something happens. Events carry data from producers to consumers through the webhook system. ## Key points - Events belong to an [Application](applications.md) - Each event has an [Event Type](event-types.md) that categorizes it - Events carry a payload in JSON, plain text, or base64 format - [Labels](labels.md) on events control routing to matching [Subscriptions](subscriptions.md) - Events are immutable once created ## Event lifecycle ```mermaid flowchart TD A["Your Application"]:::customer B["Hook0 API"]:::hook0 C["Label Matching"]:::processing D["Sub A"]:::external E["Sub B"]:::external F["Sub C"]:::external G["Request Attempts Created"]:::hook0 A -- "sends event" --> B B -- "matches subscriptions" --> C C --> D & E & F D & E & F --> G classDef external fill:#dbeafe,stroke:#60a5fa,color:#1e3a5f classDef hook0 fill:#dcfce7,stroke:#4ade80,color:#14532d classDef customer fill:#ffedd5,stroke:#fb923c,color:#7c2d12 classDef processing fill:#ede9fe,stroke:#a78bfa,color:#3b0764 click B "/openapi/intro" "Hook0 API" click C "/concepts/labels" "Label Matching" click D "/concepts/subscriptions" "Subscriptions" click E "/concepts/subscriptions" "Subscriptions" click F "/concepts/subscriptions" "Subscriptions" click G "/concepts/request-attempts" "Request Attempts" ``` ## Payload formats Hook0 supports three payload content types: - `application/json`: structured data for API integrations - `text/plain`: simple string data - `application/octet-stream`: binary data encoded as base64 The payload is delivered exactly as received, with no transformation. :::warning Payload as String When sending JSON payloads via the API, the payload must be a JSON-encoded string, not a raw object. This ensures Hook0 forwards it exactly as provided. ::: ## Routing with labels Every event must include at least one [label](labels.md). Labels are key-value pairs that Hook0 uses to match events with [subscriptions](subscriptions.md): 1. Event is created with [labels](labels.md) (e.g., `tenant_id: "acme"`) 2. Hook0 finds [subscriptions](subscriptions.md) with matching label filters 3. [Request attempts](request-attempts.md) are created for each match ## Idempotency Each event has a unique `event_id` (UUID). The `event_id` field is optional when ingesting events — if omitted, the server generates a UUIDv7 automatically. If you provide your own `event_id` and send the same ID twice, Hook0 rejects the duplicate. This prevents accidental double-delivery when clients retry with the same `event_id`. ## What's next? - [Event Types](event-types.md) - Categorize your events - [Labels](labels.md) - Route events to subscriptions - [Applications](applications.md) - Container for your events - [Request Attempts](request-attempts.md) - Track delivery status - [Send your first event](/tutorials/getting-started) - Quick start guide # Event Types Event types categorize [events](events.md) sent to Hook0. An event type consists of three dot-separated parts: service name, resource type, and verb. ## Naming Conventions Event types follow a dot-separated format: `service.resource.verb` - **Service**: The system or domain generating the [event](events.md) (e.g., `iam`, `billing`, `storage`) - **Resource**: The entity being acted upon (e.g., `user`, `invoice`, `file`) - **Verb**: The action in past tense (e.g., `created`, `updated`, `deleted`) ### Good Examples - `iam.user.created` - IAM service, user resource, creation action - `billing.invoice.paid` - Billing service, invoice resource, payment action - `storage.file.removed` - Storage service, file resource, removal action - `api.application.created` - API service, [application](applications.md) resource, creation action ### Bad Examples - `IAM_USER_CREATED` - Uses uppercase and underscores instead of lowercase dot-separated format - `UserCreated` - Missing service context and uses PascalCase - `user-created` - Missing service context and uses dashes instead of dots - `iam_user_created` - Uses underscores instead of dots ## Key Functions Event types serve two primary purposes: 1. **Payload Structure**: [Events](events.md) with the same event type are expected to have the same payload structure, making it simpler for webhook receivers to process data. 2. **[Subscription](subscriptions.md) Filtering**: Users creating [subscriptions](subscriptions.md) can choose which event types they want to hear about, allowing Hook0 to forward only matching [events](events.md) for specific [subscriptions](subscriptions.md). ## What's Next? - [Events](events.md) - Send notifications with event types - [Applications](applications.md) - Manage event types per application - [Subscriptions](subscriptions.md) - Filter subscriptions by event type - [Labels](labels.md) - Route events to subscriptions - [Send your first event](/tutorials/getting-started) - Quick start guide # Labels Labels are key-value pairs attached to [events](events.md). Hook0 uses them to route events to the right [subscriptions](subscriptions.md). You can use labels for multi-tenancy, environment separation, or any filtering logic you need. ## Key points - Every [event](events.md) must include at least one label - [Subscriptions](subscriptions.md) filter [events](events.md) by matching label values - Labels are how you implement multi-tenant routing - Matching is case-sensitive and exact :::warning Minimum Requirement Every [event](events.md) must include **at least one label**. The API rejects [events](events.md) with empty labels. ::: ## How labels work ```mermaid flowchart TD A["Event with labels"]:::external B["tenant_id: "acme"
environment: "prod""]:::external C["Hook0 finds subscriptions
with matching labels"]:::processing D["Sub A
tenant_id: "acme""]:::hook0 E["Sub B
tenant_id: "beta""]:::customer F["Delivered"]:::hook0 G["No match"]:::customer A --> B --> C C --> D C --> E D --> F E -. "no match" .-> G classDef external fill:#dbeafe,stroke:#60a5fa,color:#1e3a5f classDef hook0 fill:#dcfce7,stroke:#4ade80,color:#14532d classDef customer fill:#ffedd5,stroke:#fb923c,color:#7c2d12 classDef processing fill:#ede9fe,stroke:#a78bfa,color:#3b0764 click A "/concepts/events" "Events" click D "/concepts/subscriptions" "Subscriptions" click E "/concepts/subscriptions" "Subscriptions" ``` ## Matching rules - A [subscription](subscriptions.md) receives an [event](events.md) only if all its label filters match the event's labels - If an [event](events.md) lacks a label that the [subscription](subscriptions.md) filters on, the event is not delivered - [Subscriptions](subscriptions.md) can have fewer labels than [events](events.md) (partial matching) - Label values are case-sensitive and must match exactly ## Common patterns | Pattern | Example label | Purpose | |---------|---------------|---------| | Multi-tenancy | `tenant_id: "acme_corp"` | Isolate [events](events.md) per customer | | Environment | `environment: "production"` | Separate prod/staging/dev | | Priority | `priority: "critical"` | Route urgent [events](events.md) differently | | Geographic | `region: "eu-west-1"` | Route by location | | Source | `source: "mobile_app"` | Identify [event](events.md) origin | ## Naming conventions - Use `snake_case` for keys: `tenant_id`, `event_source` - Be descriptive: `payment_provider` not `pp` - Avoid sensitive data: never use `password`, `ssn`, `credit_card` - Use consistent naming across all [events](events.md) ## What's next? - [Events](events.md) - Attach labels to events - [Subscriptions](subscriptions.md) - Filter events with labels - [Event Types](event-types.md) - Categorize your events - [Applications](applications.md) - Manage labels per application # Subscriptions A subscription tells Hook0 where to deliver [events](events.md) and which events to deliver. It pairs a webhook endpoint URL with filtering criteria so only relevant events get sent. ## Key points - Subscriptions belong to an [Application](applications.md) - Each subscription specifies a target URL and filtering criteria - Filtering uses [Event Types](event-types.md) and [Labels](labels.md) - Subscriptions can be enabled or disabled without deletion - Each subscription has its own [secret](#subscription-secrets) for signature verification ## How subscriptions work ```mermaid flowchart TD A["Event arrives at Hook0"]:::external B["Match Event Type"]:::processing C["Match Labels"]:::processing D["Create Request Attempt"]:::hook0 E["Deliver to
Subscription Target"]:::customer A --> B --> C --> D --> E classDef external fill:#dbeafe,stroke:#60a5fa,color:#1e3a5f classDef hook0 fill:#dcfce7,stroke:#4ade80,color:#14532d classDef customer fill:#ffedd5,stroke:#fb923c,color:#7c2d12 classDef processing fill:#ede9fe,stroke:#a78bfa,color:#3b0764 click A "/concepts/events" "Events" click B "/concepts/event-types" "Event Types" click C "/concepts/labels" "Labels" click D "/concepts/request-attempts" "Request Attempts" ``` ## Filtering Subscriptions filter [events](events.md) in two ways: ### Event type filtering Subscribe to specific [event types](event-types.md) (e.g., `order.created`, `user.updated`). Only [events](events.md) with matching types trigger deliveries. ### Label filtering Narrow down further using [labels](labels.md). A subscription with label `tenant_id: "acme"` only receives [events](events.md) that have that exact label. Both filters must match for an [event](events.md) to be delivered. ## Target types Subscriptions support HTTP targets where webhooks are delivered via POST (or other methods) to your endpoint. The target configuration includes: - URL where the webhook is sent - HTTP method (typically POST) - Custom headers ## Subscription secrets Each subscription has its own secret, used to sign the payloads Hook0 delivers to it. It is a different value from the [application secret](application-secrets.md), which is an API token and never signs anything. Recipients use the subscription secret to verify: - The webhook came from Hook0 - The payload wasn't modified in transit - The webhook is fresh (timestamp validation) ## What's next? - [Events](events.md) - Understanding event structure - [Labels](labels.md) - Filtering events with labels - [Request Attempts](request-attempts.md) - Track delivery status and retries - [Application Secrets](application-secrets.md) - API tokens for calling the Hook0 API - [Secure Webhook Endpoints](/how-to-guides/secure-webhook-endpoints) - Complete security guide # Request attempts A request attempt is a single delivery try for a webhook. When an [event](events.md) matches a [subscription](subscriptions.md), Hook0 creates a request attempt and tracks it until delivery succeeds or all retries are exhausted. ## Key points - Each [event](events.md)/[subscription](subscriptions.md) pair generates one or more request attempts - Hook0 retries failed deliveries automatically with increasing backoff - Request attempts move through distinct statuses - Full delivery history is kept for debugging ## Delivery lifecycle ```mermaid flowchart TD A["Event Created"]:::external B["PENDING
(Queued for delivery)"]:::processing C["IN_PROGRESS
(Being delivered)"]:::processing D["SUCCESSFUL"]:::hook0 E["FAILED"]:::customer F["WAITING
(Backoff delay)"]:::customer G["Retry cycle"]:::processing P["PAUSED
(Subscription degraded)"]:::customer A --> B --> C A -- "subscription degraded or recovering" --> P P -- "released gradually" --> B C --> D C -- "fail, retries remaining" --> F C -- "fail, retries exhausted" --> E F --> G --> B classDef external fill:#dbeafe,stroke:#60a5fa,color:#1e3a5f classDef hook0 fill:#dcfce7,stroke:#4ade80,color:#14532d classDef customer fill:#ffedd5,stroke:#fb923c,color:#7c2d12 classDef processing fill:#ede9fe,stroke:#a78bfa,color:#3b0764 click A "/concepts/events" "Events" click E "/explanation/webhook-retry-logic" "Retry Logic" click F "/explanation/webhook-retry-logic" "Backoff Delay" click P "/explanation/webhook-retry-logic#subscription-health" "Subscription Health" ``` ## Status states Request attempts go through these states (the API value of `status.type` is given in parentheses): - Pending (`pending`): queued, waiting to be picked up for delivery - Paused (`paused`): held back because most recent deliveries to the subscription's endpoint failed; released gradually (see [Subscription health](/explanation/webhook-retry-logic#subscription-health)) - In progress (`in_progress`): currently being delivered to the endpoint - Waiting (`waiting`): delivery failed, waiting for retry (backoff delay) - Successful (`successful`): webhook delivered and endpoint returned 2xx - Failed (`failed`): all retry attempts exhausted or permanently failed ## Retry behavior When a delivery fails, Hook0 schedules retries with increasing backoff delays: | Retry | Base delay | |-------|-------| | 1 | 3 seconds | | 2 | 10 seconds | | 3 | 3 minutes | | 4 | 30 minutes | | 5 | 1 hour | | 6 | 3 hours | | 7 | 5 hours | | 8+ | 10 hours | A small random amount is added on top of each delay -- never subtracted, so a retry never fires earlier than its base delay -- so that deliveries that failed together do not retry together. See [Why delays are not exact](/explanation/webhook-retry-logic#why-delays-are-not-exact). Retries are bounded by both `MAX_RETRIES` and `MAX_RETRY_WINDOW` (whichever limit is reached first) at the Output Worker level. After all retries are exhausted, the attempt is marked as permanently failed. While a subscription is degraded or recovering, each retry waits at least 1 hour (see [Subscription health](/explanation/webhook-retry-logic#subscription-health)). Transient failures won't cause data loss, and struggling endpoints won't get hammered. ## Failure scenarios Request attempts fail when: - Endpoint returns 4xx or 5xx status codes - Connection times out - DNS resolution fails - SSL/TLS handshake fails - [Application](applications.md) is deleted (all pending attempts cancelled) ## Debugging failed webhooks When webhooks fail, request attempts give you: - Timestamps for when each phase occurred - Retry count (how many attempts were made) - Response reference (link to the endpoint's response) This helps you figure out whether the problem is endpoint availability, authentication, or payload processing. ## What's next? - [Debug Failed Webhooks](/how-to-guides/debug-failed-webhooks) - Troubleshooting guide - [Monitor Webhook Performance](/how-to-guides/monitor-webhook-performance) - Tracking metrics - [Subscriptions](subscriptions.md) - Configuring webhook endpoints - [Events](events.md) - Understanding event structure # Application secrets An application secret is the API token of one [application](applications.md). You send it as `Authorization: Bearer ` to call the Hook0 API on that application's behalf — starting with sending events. :::note Depends on a compatibility setting Accepting a raw application secret as a Bearer token is a legacy path, kept for backward compatibility and controlled by the `ENABLE_APPLICATION_SECRET_COMPATIBILITY` setting (see the [configuration reference](../reference/configuration.md)). It is enabled by default, on [app.hook0.com](https://app.hook0.com) as well as in a default self-hosted deployment. On a self-hosted instance where it has been turned off, application secrets authenticate nothing — call the API with a [service token](/how-to-guides/manage-service-tokens) instead. ::: ## What an application secret can do An application secret is not limited to sending events. It carries the full set of permissions on the application it belongs to: - Send events, and read events and delivery attempts - Create and delete event types - Create, update and delete subscriptions (including reading their signing secrets) - List, create and revoke that application's secrets — including itself - Rename or delete the application It cannot reach anything outside that application: another application, even in the same organization, is refused, and so is everything at the organization level (members, billing, service tokens). Treat it as you would a password: whoever holds it controls the application. **Every application is created with one secret already provisioned, named `Default`**, so a new application can send its first event with no extra step. It is an ordinary secret: rename it, revoke it, or replace it with one of your own at any time. Applications created before this behaviour shipped do not have one — create a key from the **API keys** page of the application. An application secret cannot be narrowed down: its permissions are fixed. When you need a credential that may send events but must not administer the application — a CI job, an AI assistant, a third party — create a [service token](/how-to-guides/manage-service-tokens) and attenuate it (single application, read-only, expiration date) instead. ## Key points - Each [application](applications.md) can have multiple secrets for key rotation - Every new application starts with a secret named `Default` - A secret is scoped to a single application and grants full control over it - Revoking a secret immediately invalidates every call made with it ## Why signatures matter :::note Everything below is about the signature Hook0 puts on the webhooks it delivers. That signature is computed with the [subscription](subscriptions.md)'s own secret, which is a different value from the API token described above. ::: Without signature verification, webhook endpoints are open to: - Spoofing (attackers sending fake webhooks) - Tampering (payload modification in transit) - Replay attacks (resending captured webhooks) Signature verification confirms: 1. The webhook came from Hook0 2. The payload hasn't been modified 3. The webhook is fresh (timestamp validation) ## How signing works ```mermaid flowchart TD A["Event Payload + Timestamp"]:::external B["HMAC-SHA256
(secret key)"]:::processing C["Signature"]:::processing D["hook0-signature
header added"]:::hook0 E["Webhook Sent"]:::hook0 A --> B --> C --> D --> E classDef external fill:#dbeafe,stroke:#60a5fa,color:#1e3a5f classDef hook0 fill:#dcfce7,stroke:#4ade80,color:#14532d classDef processing fill:#ede9fe,stroke:#a78bfa,color:#3b0764 click B "/tutorials/webhook-authentication" "HMAC Signature Verification" ``` The signature header contains: - Timestamp: when the signature was generated - Signature: HMAC-SHA256 hash of timestamp + payload ## Secret rotation To rotate secrets without downtime: 1. Create a new secret 2. Update consumers to accept both secrets 3. Wait for in-flight webhooks to complete 4. Revoke the old secret ## Security considerations - Treat secrets like passwords - Don't log or display secrets - Rotate periodically - Immediately revoke leaked secrets :::warning Save the Token The secret is displayed only once at creation time. Store it securely before leaving the page. ::: ## What's next? - [Secure Webhook Endpoints](/how-to-guides/secure-webhook-endpoints) - Complete verification guide - [Applications](applications.md) - Managing your applications - [Subscriptions](subscriptions.md) - Configuring webhook delivery # Service tokens A service token authenticates API requests to Hook0 on behalf of your [organization](organizations.md). Unlike user credentials (which require interactive login), service tokens are for automated systems and scripts. ## Key points - Service tokens authenticate non-interactive API requests - Tokens are scoped to an [organization](organizations.md) by default - You can attenuate (restrict) tokens without contacting Hook0 - Revoking a root token invalidates all derived tokens ## Service tokens vs user credentials ```mermaid flowchart TD A["User Login"]:::customer B["Service Token"]:::external C["Interactive
Browser session
MFA possible"]:::customer D["Non-interactive
API calls
Automated"]:::external A --> C B --> D classDef external fill:#dbeafe,stroke:#60a5fa,color:#1e3a5f classDef customer fill:#ffedd5,stroke:#fb923c,color:#7c2d12 click B "/concepts/service-tokens" "Service Tokens" ``` Service tokens are the right choice for: - CI/CD pipelines - Backend services - Scripts and automation - AI assistants and MCP servers ## Biscuit token format Hook0 uses [Biscuit tokens](https://www.biscuitsec.org/), a cryptographic token format with these properties: - Offline attenuation: create restricted tokens without contacting Hook0 - Cryptographic verification: tokens are tamper-proof - Cascading revocation: revoking a parent invalidates all children ## Attenuation You can create restricted versions of a token without calling the Hook0 API: ```mermaid flowchart TD Root["Root Token
(full access)"]:::processing Root --> A["Attenuated Token A
App: Orders, Expires: 30 days"]:::external Root --> B["Attenuated Token B
App: Users, No expiration"]:::external Root --> C["Attenuated Token C
App: Payments, Expires: 1 hour"]:::external classDef external fill:#dbeafe,stroke:#60a5fa,color:#1e3a5f classDef processing fill:#ede9fe,stroke:#a78bfa,color:#3b0764 click Root "/how-to-guides/manage-service-tokens" "Manage Service Tokens" ``` Common restrictions: - [Application](applications.md) scope: limit to specific [applications](applications.md) - Expiration: automatic token expiry - Operation limits: restrict to read-only operations This is useful when sharing tokens with third-party services or temporary workers. ## Security practices - Treat service tokens like passwords - Always restrict tokens before giving them to third-party tools - Rotate periodically: create new tokens and revoke old ones - Monitor API calls made with each token ## What's next? - [Managing Service Tokens](/how-to-guides/manage-service-tokens) - Create, attenuate, and revoke tokens - [Organizations](organizations.md) - Understanding token scope - [Applications](applications.md) - Restricting tokens to specific apps # Metadata Metadata lets you attach arbitrary key-value data to [events](events.md) and [subscriptions](subscriptions.md). Hook0 stores it but does not act on it -- it's for your own use. ## Key points - Metadata can be attached to [events](events.md) and [subscriptions](subscriptions.md) - Hook0 stores it but does not process it - Searchable via the Search API - Not visible to webhook consumers unless you explicitly include it ## Metadata vs labels A common question: when should you use metadata instead of [labels](labels.md)? ```mermaid flowchart LR subgraph Labels L1["Used for routing
events to subs"] L2["Affects delivery"] L3["Required (min 1)"] end subgraph Metadata M1["Not used for
routing"] M2["No effect on
delivery"] M3["Optional"] end classDef hook0 fill:#dcfce7,stroke:#4ade80,color:#14532d classDef external fill:#dbeafe,stroke:#60a5fa,color:#1e3a5f class Labels hook0 class Metadata external click L1 "/concepts/labels" "Labels" ``` Use [labels](labels.md) when you need to route [events](events.md) to specific [subscriptions](subscriptions.md). Use metadata when you need to store extra context for your own systems. ## Common use cases - Correlation IDs to link [events](events.md) to external systems - User context (the user ID who triggered the [event](events.md)) - Debug info like request IDs or trace identifiers - Domain-specific identifiers ## Description field A separate `description` field exists for human-readable annotations. For example: "Customer onboarding webhook for Acme Corp". :::warning Security Don't store sensitive information (bank accounts, card details, passwords) in metadata or the description field. ::: ## What's next? - [Events](events.md) - Attach metadata to events - [Subscriptions](subscriptions.md) - Attach metadata to subscriptions - [Labels](labels.md) - Use labels for event routing # Tutorials Step-by-step guides to get you from zero to working webhooks. ## Learning path Follow these tutorials in order: ### 1. [Getting Started](getting-started.md) Install, configure, and send your first webhook event. **What you'll learn:** - Installing Hook0 locally - Basic configuration - Creating your first application - Sending and receiving webhook events **Time:** ~15 minutes --- ### 2. [First Webhook Integration](first-webhook-integration.md) Build an e-commerce webhook integration from scratch. **What you'll learn:** - Setting up event types for e-commerce events - Creating webhook subscriptions - Handling webhook payloads in your application - Testing webhook delivery **Time:** ~30 minutes --- ### 3. [Event Types & Subscriptions](event-types-subscriptions.md) Event management in Hook0: types, schemas, subscriptions. **What you'll learn:** - Creating and managing [event types](/concepts/event-types) - Setting up [subscriptions](/concepts/subscriptions) with filters - Using event schemas for validation - Managing subscription lifecycles **Time:** ~25 minutes --- ### 4. [Webhook Authentication](webhook-authentication.md) Signature verification and authentication. **What you'll learn:** - Understanding Hook0 authentication (Biscuit tokens for user sessions, Service tokens for programmatic access) - Generating and validating tokens - Implementing signature verification - Security best practices **Time:** ~20 minutes --- ## Prerequisites Before starting these tutorials, make sure you have: - Basic familiarity with webhooks and HTTP APIs - Docker installed (for self-hosting tutorial) - A code editor of your choice - Terminal/command line access ## Getting help If you get stuck: 1. Check the [How-to Guides](../how-to-guides/index.md) for specific solutions 2. See the [API Reference](../openapi/intro) for technical details 3. Ask on [Discord](https://www.hook0.com/community) 4. Report issues on [GitHub](https://github.com/hook0/hook0/issues) # Building your first webhook system with Hook0 This tutorial walks through integrating Hook0 into a SaaS platform so your customers can receive webhooks. It uses curl commands throughout, so you can follow along in any language. ## What you'll build A webhook delivery system that: - Sends events from your SaaS to Hook0 - Lets customers subscribe to specific events - Handles delivery, retries, and monitoring automatically - Filters events per tenant using labels ## Prerequisites - [Hook0 cloud account](https://www.hook0.com/) or [self-hosted instance running](./getting-started.md) - [API token](./getting-started.md#step-3-get-your-api-token) - Application ID from Hook0 - Basic understanding of REST APIs and webhooks ### Set up environment variables ```bash # Set your service token (from dashboard) export HOOK0_TOKEN="YOUR_TOKEN_HERE" export HOOK0_API="https://app.hook0.com/api/v1" # Replace by your domain (or http://localhost:8081 locally) # Set your application ID (shown in dashboard URL or application details) export APP_ID="YOUR_APPLICATION_ID_HERE" ``` Save these values: ```bash # Save to .env file for later use cat > .env <|"POST event"| B["Hook0"]:::hook0 B -->|"POST webhook"| C["Customer Endpoint"]:::customer classDef external fill:#dbeafe,stroke:#60a5fa,color:#1e3a5f classDef hook0 fill:#dcfce7,stroke:#4ade80,color:#14532d classDef customer fill:#ffedd5,stroke:#fb923c,color:#7c2d12 click B "/explanation/what-is-hook0" "What is Hook0?" click C "/concepts/subscriptions" "Subscriptions" ``` Your SaaS sends events to Hook0, which handles delivery, retries, signature verification, and monitoring. ## Understanding the API flow The integration has four steps: 1. **Create event types** - define what events your SaaS emits 2. **Create subscriptions** - let customers receive webhooks 3. **Send events** - push events to Hook0 when actions occur 4. **Monitor delivery** - track webhook success and failures ## Step 1: Define your event types Hook0 uses a three-part structure for [event types](/concepts/event-types): `service.resource_type.verb`. This gives clear semantics and makes filtering straightforward. ### Why define event types first? Event types act as a contract between your SaaS and customer webhooks. They must be created before you can send events or create subscriptions. ### Create a "user created" event type ```bash curl -X POST "$HOOK0_API/event_types" \ -H "Authorization: Bearer $HOOK0_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "application_id": "'"$APP_ID"'", "service": "user", "resource_type": "account", "verb": "created" }' ``` **Response:** ```json { "service_name": "user", "resource_type_name": "account", "verb_name": "created", "event_type_name": "user.account.created" } ``` ### Create an "order completed" event type ```bash curl -X POST "$HOOK0_API/event_types" \ -H "Authorization: Bearer $HOOK0_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "application_id": "'"$APP_ID"'", "service": "order", "resource_type": "purchase", "verb": "completed" }' ``` **Response:** ```json { "service_name":"order", "resource_type_name":"purchase", "verb_name":"completed", "event_type_name":"order.purchase.completed" } ``` ## Step 2: Send events to Hook0 Once event types are defined, your SaaS can start sending events. The `/api/v1/event` endpoint (singular) accepts individual events. ### Why this order? You must create event types before sending events. Hook0 validates that the event type exists before accepting the event. ### Send a user created event When a user signs up in your SaaS: ```bash curl -X POST "$HOOK0_API/event" \ -H "Authorization: Bearer $HOOK0_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "application_id": "'"$APP_ID"'", "event_type": "user.account.created", "payload": "{\"user_id\": \"usr_789\", \"email\": \"john@example.com\", \"plan\": \"premium\"}", "payload_content_type": "application/json", "occurred_at": "2024-01-15T10:30:00Z", "labels": { "tenant_id": "customer_123", "environment": "production", "region": "us-east-1" } }' ``` **Response:** ```json { "application_id": "{APP_ID}", "event_id": "{EVENT_ID}", "received_at": "2025-12-12T09:25:28.084734Z" } ``` **Key points:** - `event_id`: must be a valid UUID (e.g., `69f69ecf-0d9e-4c92-a6e0-3b2676343940`) - `payload`: must be a JSON-encoded string, not an object (see warning below) - `labels`: required for multi-tenant filtering, must have at least one key-value pair :::warning Payload Format The `payload` field must be a **JSON-encoded string**, not a JSON object: ✅ **Correct**: `"payload": "{\"user_id\": \"usr_789\"}"` ❌ **Incorrect**: `"payload": {"user_id": "usr_789"}` This allows Hook0 to forward the exact payload to webhooks without re-serialization. ::: The `tenant_id` label ensures events only go to the right customer. ### Send an order completed event When an order is fulfilled: ```bash curl -X POST "$HOOK0_API/event" \ -H "Authorization: Bearer $HOOK0_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "application_id": "'"$APP_ID"'", "event_type": "order.purchase.completed", "payload": "{\"order_id\": \"ord_456\", \"amount\": 299.99, \"items\": 3}", "payload_content_type": "application/json", "occurred_at": "2024-01-15T11:00:00Z", "labels": { "tenant_id": "customer_123", "environment": "production" } }' ``` **Response:** ```json { "application_id": "{APP_ID}", "event_id": "{EVENT_ID}", "received_at": "2025-12-12T09:27:27.045191Z" } ``` ## Step 3: Create customer subscriptions [Subscriptions](/concepts/subscriptions) define where and how webhooks get delivered. Each subscription filters events by [labels](/concepts/labels), so customers only receive their own events. ### Why labels matter for multi-tenancy The `labels` object creates a filter. Only events with matching labels are delivered to that subscription. This is how Hook0 supports multi-tenant SaaS platforms. ### Create a subscription for a customer To follow along, you need a working endpoint URL. Go to [play.hook0.com](https://play.hook0.com) to generate one instantly, then replace the `url` field in the subscription below with your Play URL. ```bash curl -X POST "$HOOK0_API/subscriptions" \ -H "Authorization: Bearer $HOOK0_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "application_id": "'"$APP_ID"'", "is_enabled": true, "event_types": [ "user.account.created", "order.purchase.completed" ], "description": "Webhook for Customer ABC Corp", "labels": { "tenant_id": "customer_123" }, "target": { "type": "http", "method": "POST", "url": "https://customer-abc.com/webhooks/hook0", "headers": { "X-Customer-Id": "customer_123" } }, "metadata": { "customer_name": "ABC Corp", "created_by": "api", "plan": "enterprise" } }' ``` **Response:** ```json { "application_id": "{APP_ID}", "subscription_id": "{SUBSCRIPTION_ID}", "is_enabled": true, "event_types": [ "user.account.created", "order.purchase.completed" ], "description": "Webhook for Customer ABC Corp", "secret": "d48488f1-cddc...", "metadata": { "customer_name": "ABC Corp", "plan": "enterprise", "created_by": "api" }, "labels": { "tenant_id": "customer_123" }, "target": { "type": "http", "method": "POST", "url": "https://customer-abc.com/webhooks/hook0", "headers": { "x-customer-id": "customer_123" } }, "created_at": "2025-12-12T09:30:13.822397Z", "dedicated_workers": [] } ``` **Important:** Save the `secret` - customers need it to verify webhook signatures. ### How multi-tenant filtering works ``` Event labels Subscription filter Result ------------------------ ------------------------- ------------ tenant_id: "customer_123" -> tenant_id = "customer_123" -> ✅ Delivered tenant_id: "customer_456" -> tenant_id = "customer_123" -> ❌ Skipped ``` ## Step 4: Monitor webhook deliveries ### List recent events See what events have been sent: ```bash curl -X GET "$HOOK0_API/events?application_id=$APP_ID" \ -H "Authorization: Bearer $HOOK0_TOKEN" ``` **Response:** ```json [ { "event_id": "{EVENT_ID}", "event_type_name": "order.purchase.completed", "payload_content_type": "application/json", "ip": "192.168.97.1", "metadata": {}, "occurred_at": "2024-01-15T11:00:00Z", "received_at": "2025-12-12T09:27:27.045191Z", "labels": { "environment": "production", "tenant_id": "customer_123" } }, { "event_id": "{EVENT_ID}", "event_type_name": "user.account.created", "payload_content_type": "application/json", "ip": "192.168.97.1", "metadata": {}, "occurred_at": "2024-01-15T10:30:00Z", "received_at": "2025-12-12T09:25:28.084734Z", "labels": { "environment": "production", "region": "us-east-1", "tenant_id": "customer_123" } }, { "event_id": "{EVENT_ID}", "event_type_name": "user.account.created", "payload_content_type": "application/json", "ip": "192.168.97.1", "metadata": {}, "occurred_at": "2025-12-12T08:39:19Z", "received_at": "2025-12-12T08:39:19.407621Z", "labels": { "environment": "tutorial" } } ] ``` ### Check delivery attempts Look at webhook delivery attempts ([request attempts](/concepts/request-attempts)) and their status: ```bash curl -X GET "$HOOK0_API/request_attempts/?application_id=$APP_ID" \ -H "Authorization: Bearer $HOOK0_TOKEN" ``` **Response:** ```json [ { "request_attempt_id": "{REQUEST_ATTEMPT_ID}", "event_id": "{EVENT_ID}", "subscription": { "subscription_id": "{SUBSCRIPTION_ID}", "description": "Customer webhook" }, "created_at": "2024-01-15T10:30:01Z", "picked_at": "2024-01-15T10:30:02Z", "succeeded_at": "2024-01-15T10:30:03Z", "failed_at": null, "retry_count": 0, "response_id": "{RESPONSE_ID}", "status": { "type": "succeeded", "at": "2024-01-15T10:30:03Z" } } ] ``` ### Replay events You can manually replay an event when needed for business purposes (e.g., re-triggering a workflow). Note that failed deliveries are automatically retried by Hook0: ```bash curl -X POST "$HOOK0_API/events/{EVENT_ID}/replay" \ -H "Authorization: Bearer $HOOK0_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "application_id": "'"$APP_ID"'" }' ``` ## Advanced patterns ### Bulk event processing When you need to send multiple events (e.g., batch import), send them individually but with correlation: ```bash # Event 1 of batch curl -X POST "$HOOK0_API/event" \ -H "Authorization: Bearer $HOOK0_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "application_id": "'"$APP_ID"'", "event_type": "user.account.created", "payload": "{\"user_id\": \"usr_789\", \"email\": \"john@example.com\", \"plan\": \"premium\"}", "labels": { "tenant_id": "customer_123", "batch_id": "batch-001", "batch_size": "100" } }' # Event 2 of batch curl -X POST "$HOOK0_API/event" \ -H "Authorization: Bearer $HOOK0_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "application_id": "'"$APP_ID"'", "event_type": "user.account.created", "payload": "{\"user_id\": \"usr_7892\", \"email\": \"john2@example.com\", \"plan\": \"premium\"}", "labels": { "tenant_id": "customer_123", "batch_id": "batch-001", "batch_size": "100" } }' ``` ### Environment-based filtering Use labels to separate environments: ```bash # Production event curl -X POST "$HOOK0_API/event" \ -H "Authorization: Bearer $HOOK0_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "application_id": "'"$APP_ID"'", "event_type": "user.account.created", "payload": "{\"user_id\": \"usr_123\"}", "payload_content_type": "application/json", "occurred_at": "'$(date -u +"%Y-%m-%dT%H:%M:%SZ")'", "labels": { "tenant_id": "customer_123", "environment": "production" } }' ``` **Response:** ```json { "application_id": "{APP_ID}", "event_id": "{EVENT_ID}", "received_at": "2025-12-12T09:41:39.757315Z" } ``` ```bash # Staging subscription (will not receive production events) curl -X POST "$HOOK0_API/subscriptions" \ -H "Authorization: Bearer $HOOK0_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "application_id": "'"$APP_ID"'", "is_enabled": true, "event_types": ["user.account.created"], "description": "Staging webhook", "labels": { "environment": "staging" }, "target": { "type": "http", "method": "POST", "url": "https://staging.example.com/webhook", "headers": { "X-Environment": "staging" } } }' ``` **Response:** ```json { "application_id": "{APP_ID}", "subscription_id": "{SUBSCRIPTION_ID}", "is_enabled": true, "event_types": ["user.account.created"], "description": "Staging webhook", "secret": "{SECRET}", "labels": { "environment": "staging" }, "target": { "type": "http", "method": "POST", "url": "https://staging.example.com/webhook", "headers": {"x-environment": "staging"} }, "created_at": "2025-12-12T09:41:41.304925Z" } ``` Since this subscription filters on `environment: "staging"`, it will **not** receive the production event above. ## Complete integration example Full flow from event creation to webhook delivery: ### Step 1: Create Event Type (one-time setup) ```bash curl -X POST "$HOOK0_API/event_types" \ -H "Authorization: Bearer $HOOK0_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "application_id": "'"$APP_ID"'", "service": "billing", "resource_type": "invoice", "verb": "paid" }' ``` **Response:** ```json { "service_name": "billing", "resource_type_name": "invoice", "verb_name": "paid", "event_type_name": "billing.invoice.paid" } ``` ### Step 2: Create Subscription (customer setup) ```bash curl -X POST "$HOOK0_API/subscriptions" \ -H "Authorization: Bearer $HOOK0_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "application_id": "'"$APP_ID"'", "is_enabled": true, "event_types": ["billing.invoice.paid"], "description": "Customer billing webhook", "labels": { "tenant_id": "customer_789" }, "target": { "type": "http", "method": "POST", "url": "https://customer.example.com/webhooks", "headers": { "X-Tenant-Id": "customer_789" } } }' ``` **Response:** ```json { "application_id": "{APP_ID}", "subscription_id": "{SUBSCRIPTION_ID}", "is_enabled": true, "event_types": ["billing.invoice.paid"], "description": "Customer billing webhook", "secret": "{SECRET}", "labels": { "tenant_id": "customer_789" }, "target": { "type": "http", "method": "POST", "url": "https://customer.example.com/webhooks", "headers": {"x-tenant-id": "customer_789"} }, "created_at": "2025-12-12T09:43:22.425687Z" } ``` ⚠️ **Save the `secret`** - customers need it to verify webhook signatures. ### Step 3: Send Event (when invoice is paid) ```bash curl -X POST "$HOOK0_API/event" \ -H "Authorization: Bearer $HOOK0_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "application_id": "'"$APP_ID"'", "event_type": "billing.invoice.paid", "payload": "{\"invoice_id\": \"inv_456\", \"amount\": 1500.00}", "payload_content_type": "application/json", "occurred_at": "'$(date -u +"%Y-%m-%dT%H:%M:%SZ")'", "labels": { "tenant_id": "customer_789" } }' ``` **Response:** ```json { "application_id": "{APP_ID}", "event_id": "{EVENT_ID}", "received_at": "2025-12-12T09:43:24.351417Z" } ``` ### Step 4: Automatic delivery Hook0 delivers the webhook to `https://customer.example.com/webhooks` with: - `X-Hook0-Signature` header for verification - The event payload - Automatic retries on failure See [Implementing Webhook Authentication](./webhook-authentication.md) for signature verification code in Node.js, Python, and Go. ## Implementation in other languages This tutorial uses curl, but here is the same thing in a few languages: ### Python ```python import requests import uuid import json from datetime import datetime, timezone # Send event event = { "application_id": "{APP_ID}", "event_type": "user.account.created", "payload": json.dumps({"user_id": "usr_123"}), "payload_content_type": "application/json", "occurred_at": datetime.now(timezone.utc).isoformat(), "labels": {"tenant_id": "customer_123"} } response = requests.post( "https://app.hook0.com/api/v1/event", headers={"Authorization": "Bearer {YOUR_TOKEN}"}, json=event ) ``` ### Go ```go import ( "bytes" "encoding/json" "net/http" "time" "github.com/google/uuid" ) // Send event event := map[string]interface{}{ "application_id": "{APP_ID}", "event_type": "user.account.created", "payload": `{"user_id": "usr_123"}`, "payload_content_type": "application/json", "occurred_at": time.Now().UTC().Format(time.RFC3339), "labels": map[string]string{ "tenant_id": "customer_123", }, } jsonData, _ := json.Marshal(event) req, _ := http.NewRequest("POST", "https://app.hook0.com/api/v1/event", bytes.NewBuffer(jsonData)) req.Header.Set("Authorization", "Bearer {YOUR_TOKEN}") req.Header.Set("Content-Type", "application/json") client := &http.Client{} resp, _ := client.Do(req) defer resp.Body.Close() ``` ### Ruby ```ruby require 'net/http' require 'json' require 'securerandom' require 'time' # Send event event = { application_id: "{APP_ID}", event_id: SecureRandom.uuid, event_type: "user.account.created", payload: {user_id: "usr_123"}.to_json, payload_content_type: "application/json", occurred_at: Time.now.utc.iso8601, labels: {tenant_id: "customer_123"} } uri = URI('https://app.hook0.com/api/v1/event') http = Net::HTTP.new(uri.host, uri.port) request = Net::HTTP::Post.new(uri) request['Authorization'] = 'Bearer {YOUR_TOKEN}' request['Content-Type'] = 'application/json' request.body = event.to_json response = http.request(request) ``` ## Best practices ### 1. Event design - Use semantic naming: `service.resource.verb` - Include enough context for consumers to act without extra API calls - Keep the structure consistent across all events - Plan for schema evolution from the start ### 2. Label strategy - `tenant_id`: always include for multi-tenancy - `environment`: separate prod/staging/dev - `region`: geographic filtering when needed ### 3. Error handling - Use unique `event_id` values to prevent duplicates - Implement retry logic with backoff on your side - Fail fast when Hook0 is unreachable (circuit breaker) - Buffer events locally during outages ### 4. Security - Never log tokens - Customers should verify webhook signatures - Do not send PII in plaintext - Rate-limit your event sending ## Troubleshooting ### Event not delivered ```bash # Check if event was received curl -X GET "$HOOK0_API/events?application_id=$APP_ID" \ -H "Authorization: Bearer $HOOK0_TOKEN" # Check subscription filters curl -X GET "$HOOK0_API/subscriptions?application_id=$APP_ID" \ -H "Authorization: Bearer $HOOK0_TOKEN" # Verify labels match your events ``` ### 401 Unauthorized ```bash # Verify token is included curl -H "Authorization: Bearer $HOOK0_TOKEN" # ✅ Correct curl -H "Authorization: $HOOK0_TOKEN" # ❌ Wrong - missing Bearer ``` ### Event type not found ```bash # List existing event types curl -X GET "$HOOK0_API/event_types?application_id=$APP_ID" \ -H "Authorization: Bearer $HOOK0_TOKEN" # Create missing event type before sending events ``` ## Summary You now know how to: 1. Create event types using the three-part structure 2. Send events with labels for multi-tenancy 3. Create subscriptions with label-based filtering 4. Monitor deliveries and handle failures 5. Process events in bulk The label system is what makes multi-tenant delivery work without extra complexity. Define types, send events, create subscriptions, check results. ## Next steps - [Event Types and Subscriptions Deep Dive](./event-types-subscriptions.md) - [Webhook Authentication and Security](./webhook-authentication.md) - [Debugging Failed Webhooks](../how-to-guides/debug-failed-webhooks.md) - [Multi-Tenant Architecture](../how-to-guides/multi-tenant-architecture.md) # Setting up event types and subscriptions This tutorial covers how to design event schemas, organize [event type](/concepts/event-types) hierarchies, and configure [subscription](/concepts/subscriptions) patterns for your application. ## Prerequisites - Completed [Getting Started](./getting-started.md) tutorial - Understanding of JSON schemas - Familiarity with webhook concepts - **Node.js 18+** (for the testing script) ### Set up environment variables ```bash # Set your service token (from dashboard) export HOOK0_TOKEN="YOUR_TOKEN_HERE" export HOOK0_API="https://app.hook0.com/api/v1" # Replace by your domain (or http://localhost:8081 locally) # Set your application ID (shown in dashboard URL or application details) export APP_ID="YOUR_APPLICATION_ID_HERE" ``` Save these values: ```bash # Save to .env file for later use cat > .env < ```json { "service_name": "user", "resource_type_name": "account", "verb_name": "created", "event_type_name": "user.account.created" } ``` ## Step 3: Create a full event type hierarchy Here is a set of event types for a typical SaaS application: ### User lifecycle events ```bash # User registration curl -X POST "$HOOK0_API/event_types" \ -H "Authorization: Bearer $HOOK0_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "application_id": "'"$APP_ID"'", "service": "user", "resource_type": "account", "verb": "registered" }' # Email verification curl -X POST "$HOOK0_API/event_types" \ -H "Authorization: Bearer $HOOK0_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "application_id": "'"$APP_ID"'", "service": "user", "resource_type": "email", "verb": "verified" }' # Profile updates curl -X POST "$HOOK0_API/event_types" \ -H "Authorization: Bearer $HOOK0_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "application_id": "'"$APP_ID"'", "service": "user", "resource_type": "profile", "verb": "updated" }' # Account deactivation curl -X POST "$HOOK0_API/event_types" \ -H "Authorization: Bearer $HOOK0_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "application_id": "'"$APP_ID"'", "service": "user", "resource_type": "account", "verb": "deactivated" }' ``` ### Subscription events ```bash # Subscription created curl -X POST "$HOOK0_API/event_types" \ -H "Authorization: Bearer $HOOK0_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "application_id": "'"$APP_ID"'", "service": "subscription", "resource_type": "plan", "verb": "created" }' # Subscription upgraded curl -X POST "$HOOK0_API/event_types" \ -H "Authorization: Bearer $HOOK0_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "application_id": "'"$APP_ID"'", "service": "subscription", "resource_type": "plan", "verb": "upgraded" }' # Payment failed curl -X POST "$HOOK0_API/event_types" \ -H "Authorization: Bearer $HOOK0_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "application_id": "'"$APP_ID"'", "service": "subscription", "resource_type": "payment", "verb": "failed" }' # Subscription cancelled curl -X POST "$HOOK0_API/event_types" \ -H "Authorization: Bearer $HOOK0_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "application_id": "'"$APP_ID"'", "service": "subscription", "resource_type": "plan", "verb": "cancelled" }' ``` ## Step 4: Subscription patterns ### Single event type subscription Simple subscription for one specific event: ```bash curl -X POST "$HOOK0_API/subscriptions" \ -H "Authorization: Bearer $HOOK0_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "application_id": "'"$APP_ID"'", "is_enabled": true, "event_types": ["user.account.registered"], "description": "Welcome email trigger", "labels": { "environment": "production" }, "target": { "type": "http", "method": "POST", "url": "https://email-service.com/webhooks/welcome", "headers": { "Content-Type": "application/json", "X-Service": "welcome-emails" } } }' ``` **Response:** ```json { "application_id": "{APP_ID}", "subscription_id": "{{SUBSCRIPTION_ID}}", "is_enabled": true, "event_types": ["user.account.registered"], "description": "Welcome email trigger", "secret": "{SECRET}", "labels": { "environment": "production" }, "target": { "type": "http", "method": "POST", "url": "https://email-service.com/webhooks/welcome", "headers": { "content-type": "application/json", "x-service": "welcome-emails" } }, "created_at": "2025-12-12T10:26:24.044337Z", "updated_at": "2025-12-12T10:26:24.044337Z" } ``` ### Multiple event type subscription Subscribe to multiple related events: ```bash curl -X POST "$HOOK0_API/subscriptions" \ -H "Authorization: Bearer $HOOK0_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "application_id": "'"$APP_ID"'", "is_enabled": true, "event_types": [ "user.account.registered", "user.email.verified", "user.profile.updated" ], "description": "CRM system user sync", "labels": { "environment": "production" }, "target": { "type": "http", "method": "POST", "url": "https://crm.company.com/api/webhooks/users", "headers": { "Content-Type": "application/json", "Authorization": "Bearer crm-api-key" } } }' ``` ### Pattern-based subscription (all user events) Subscribe to all events matching a pattern: ```bash curl -X POST "$HOOK0_API/subscriptions" \ -H "Authorization: Bearer $HOOK0_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "application_id": "'"$APP_ID"'", "is_enabled": true, "event_types": [ "user.account.registered", "user.email.verified", "user.profile.updated", "user.account.deactivated" ], "description": "Analytics tracking for all user events", "labels": { "environment": "production" }, "target": { "type": "http", "method": "POST", "url": "https://analytics.company.com/api/events", "headers": { "Content-Type": "application/json", "X-Analytics-Source": "user-events" } } }' ``` ### Critical events subscription High-priority events with special handling: ```bash curl -X POST "$HOOK0_API/subscriptions" \ -H "Authorization: Bearer $HOOK0_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "application_id": "'"$APP_ID"'", "is_enabled": true, "event_types": [ "subscription.payment.failed", "user.account.deactivated" ], "description": "Alert system for critical events", "labels": { "environment": "production" }, "target": { "type": "http", "method": "POST", "url": "https://alerts.company.com/api/critical", "headers": { "Content-Type": "application/json", "X-Priority": "high", "X-Alert-Channel": "slack" } } }' ``` ## Step 5: Subscription configuration details ### Custom headers and authentication ```bash curl -X POST "$HOOK0_API/subscriptions" \ -H "Authorization: Bearer $HOOK0_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "application_id": "'"$APP_ID"'", "is_enabled": true, "event_types": ["subscription.plan.created"], "description": "Billing system integration", "labels": { "environment": "production" }, "target": { "type": "http", "method": "POST", "url": "https://billing.company.com/webhooks/subscriptions", "headers": { "Content-Type": "application/json", "Authorization": "Bearer billing-api-token", "X-Webhook-Source": "hook0", "X-Environment": "production" } }, "metadata": { "service": "billing", "priority": "high", "team": "finance" } }' ``` ### Subscription with metadata Add metadata to organize subscriptions in the dashboard: :::info Metadata Purpose The [`metadata`](/concepts/metadata) field is for your internal organization only. It is stored with the subscription and returned in API responses, but is **not** sent in webhook payloads. Use it to tag subscriptions by team, service, priority, etc. ::: ```bash curl -X POST "$HOOK0_API/subscriptions" \ -H "Authorization: Bearer $HOOK0_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "application_id": "'"$APP_ID"'", "is_enabled": true, "event_types": ["user.account.registered", "user.account.deactivated"], "description": "Marketing automation system", "labels": { "department": "marketing" }, "target": { "type": "http", "method": "POST", "url": "https://marketing.company.com/api/webhooks", "headers": { "Content-Type": "application/json" } }, "metadata": { "system": "automation", "criticality": "medium" } }' ``` ## Step 6: Test your event types Create a testing script to validate your event types: ```javascript // test-events.js (requires Node.js 18+) const { randomUUID } = require('crypto'); const HOOK0_TOKEN = '{YOUR_TOKEN}'; const HOOK0_API = 'https://app.hook0.com/api/v1/event'; const APPLICATION_ID = '{APP_ID}'; async function sendTestEvent(eventType, payload) { const response = await fetch(HOOK0_API, { method: 'POST', headers: { 'Authorization': `Bearer ${HOOK0_TOKEN}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ application_id: APPLICATION_ID, event_id: randomUUID(), event_type: eventType, payload: JSON.stringify(payload), payload_content_type: 'application/json', occurred_at: new Date().toISOString(), labels: { environment: 'test', source: 'event-testing' } }) }); if (!response.ok) { const error = await response.text(); console.error(`❌ ${eventType}: ${response.status} - ${error}`); return null; } const result = await response.json(); console.log(`✅ ${eventType}:`, result.event_id); return result; } async function runTests() { console.log('🧪 Testing Event Types...\n'); // Test user events (event types follow service.resource_type.verb format) await sendTestEvent('user.account.registered', { user_id: 'user_123', email: 'test@example.com', name: 'Test User', plan: 'free', created_at: new Date().toISOString(), metadata: { source: 'web', campaign: 'social_media' } }); await sendTestEvent('user.email.verified', { user_id: 'user_123', email: 'test@example.com', verified_at: new Date().toISOString() }); await sendTestEvent('user.profile.updated', { user_id: 'user_123', changes: ['name', 'avatar'], previous_values: { name: 'Old Name', avatar: null }, updated_at: new Date().toISOString() }); // Test subscription events await sendTestEvent('subscription.plan.created', { subscription_id: 'sub_123', user_id: 'user_123', plan: 'pro', amount: 29.99, currency: 'USD', billing_cycle: 'monthly', created_at: new Date().toISOString() }); await sendTestEvent('subscription.payment.failed', { subscription_id: 'sub_123', user_id: 'user_123', payment_id: 'pay_123', amount: 29.99, currency: 'USD', error_code: 'card_declined', error_message: 'Insufficient funds', failed_at: new Date().toISOString() }); console.log('\n✨ Test completed!'); } runTests().catch(console.error); ``` Run the tests: ```bash node test-events.js ``` ## Step 7: Monitor event types and subscriptions ### List all event types ```bash curl "$HOOK0_API/event_types/?application_id=$APP_ID" \ -H "Authorization: Bearer $HOOK0_TOKEN" ``` **Response:** ```json [ { "service_name": "user", "resource_type_name": "account", "verb_name": "registered", "event_type_name": "user.account.registered" }, { "service_name": "user", "resource_type_name": "email", "verb_name": "verified", "event_type_name": "user.email.verified" }, { "service_name": "subscription", "resource_type_name": "plan", "verb_name": "created", "event_type_name": "subscription.plan.created" } ] ``` ### List all subscriptions ```bash curl "$HOOK0_API/subscriptions/?application_id=$APP_ID" \ -H "Authorization: Bearer $HOOK0_TOKEN" ``` **Response:** ```json [ { "application_id": "{APP_ID}", "subscription_id": "{SUBSCRIPTION_ID}", "is_enabled": true, "event_types": ["user.account.registered"], "description": "Welcome email trigger", "secret": "591b414d-8cd4-...", "metadata": {}, "labels": { "environment": "production" }, "target": { "type": "http", "method": "POST", "url": "https://email-service.com/webhooks/welcome", "headers": { "content-type": "application/json", "x-service": "welcome-emails" } }, "created_at": "2025-12-12T10:26:24.044337Z", "updated_at": "2025-12-12T10:26:24.044337Z", "dedicated_workers": [] } ] ``` ### Get subscription details ```bash curl "$HOOK0_API/subscriptions/{SUBSCRIPTION_ID}?application_id=$APP_ID" \ -H "Authorization: Bearer $HOOK0_TOKEN" ``` **Response:** ```json { "application_id": "{APP_ID}", "subscription_id": "{SUBSCRIPTION_ID}", "is_enabled": true, "event_types": ["user.account.registered"], "description": "Welcome email trigger", "secret": "591b414d-8cd4-...", "metadata": {}, "labels": { "environment": "production" }, "target": { "type": "http", "method": "POST", "url": "https://email-service.com/webhooks/welcome", "headers": { "content-type": "application/json", "x-service": "welcome-emails" } }, "created_at": "2025-12-12T10:26:24.044337Z", "updated_at": "2025-12-12T10:26:24.044337Z", "dedicated_workers": [] } ``` ## Step 8: Update and manage subscriptions ### Update subscription configuration :::warning Required Fields The PUT endpoint requires all subscription fields, not just the ones you want to change. You must include `application_id`, `is_enabled`, `event_types`, `labels`, and `target`. ::: ```bash curl -X PUT "$HOOK0_API/subscriptions/{SUBSCRIPTION_ID}" \ -H "Authorization: Bearer $HOOK0_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "application_id": "'"$APP_ID"'", "is_enabled": true, "event_types": ["user.account.registered", "user.email.verified"], "description": "Updated CRM integration - removed profile updates", "labels": { "environment": "production" }, "target": { "type": "http", "method": "POST", "url": "https://new-crm.company.com/api/webhooks/users", "headers": { "Content-Type": "application/json", "Authorization": "Bearer new-crm-api-key" } } }' ``` ## Step 9: Delete event types and subscriptions ### Delete event type To permanently delete an event type: ```bash curl -X DELETE "$HOOK0_API/event_types/{EVENT_TYPE_NAME}?application_id=$APP_ID" \ -H "Authorization: Bearer $HOOK0_TOKEN" ``` :::warning Permanent Deletion This permanently deletes the event type. Existing events are preserved, but you won't be able to send new events of this type. This action cannot be undone - you'll need to recreate the event type if needed. ::: ### Disable subscription To disable a subscription, set `is_enabled` to `false`: ```bash curl -X PUT "$HOOK0_API/subscriptions/{SUBSCRIPTION_ID}" \ -H "Authorization: Bearer $HOOK0_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "application_id": "'"$APP_ID"'", "is_enabled": false, "event_types": ["user.account.created"], "labels": { "environment": "production" }, "target": { "type": "http", "method": "POST", "url": "https://your-endpoint.com/webhook", "headers": { "Content-Type": "application/json" } } }' ``` ## Production best practices Designing for webhooks requires a different mindset than REST APIs. These rules matter: ### 1. Consumer idempotency is mandatory Webhooks implement "At-Least-Once" delivery. In case of network errors, Hook0 may retry sending the same event. - **Rule:** Consumers must verify the `event_id`. - **Implementation:** Store processed `event_id`s in a database. If an ID is seen again, return `200 OK` immediately without re-processing logic. ### 2. Async processing (the 200 OK rule) Your webhook endpoint must be fast. If you perform long operations (sending emails, generating PDFs) synchronously, you risk timeouts. - **Rule:** Acknowledge receipt first, process later. - **Implementation:** Receive the webhook → Push to an internal queue (e.g., SQS, RabbitMQ, Redis) → Return `200 OK`. Workers will process the actual job. ### 3. Thin payloads vs. fat payloads Sending massive objects in webhooks increases latency and failure rates. - **Recommendation:** Prefer "Thin Payloads" (IDs + changed fields) over "Fat Payloads" (full database record). - **Why:** Large payloads may be rejected by intermediate proxies. It also ensures the consumer fetches the *freshest* data via your API using the ID provided in the webhook. ### 4. Security verification Never trust the payload blindly. Any public endpoint can be flooded with fake data. - **Rule:** Verify the HMAC signature. - **Implementation:** Use the signing secret provided by Hook0 to hash the incoming body and compare it with the `X-Hook0-Signature` header. Reject any request where the signature does not match. See [Webhook Authentication](./webhook-authentication.md) for implementation examples. ## What you've learned - Designed hierarchical event type schemas - Created a full event type set for a SaaS app - Built subscription patterns (single, multi, pattern-based, critical) - Configured subscriptions with headers, auth, and metadata - Tested event types with a script - Managed the event type and subscription lifecycle ## Next steps - [Implementing Webhook Authentication](./webhook-authentication.md) - [Debugging Failed Webhook Deliveries](../how-to-guides/debug-failed-webhooks.md) ## Troubleshooting ### Event not triggering webhooks 1. Check event type exists in application 2. Verify subscription is enabled 3. Confirm event type matches subscription 4. Check webhook endpoint accessibility ### Subscription not receiving events 1. Verify subscription configuration 2. Check event type matching logic 3. Review webhook endpoint logs 4. Monitor Hook0 dashboard for delivery attempts # Implementing Webhook Authentication Hook0 signs every delivery with HMAC-SHA256 and sends the result in the `X-Hook0-Signature` header. Verifying it well comes down to five practices: compute the HMAC over the raw request body, never re-serialized JSON; compare signatures in constant time; reject timestamps outside a tolerance window (300 seconds is a sensible default) to block replays; accept both the old and the new secret while you rotate; and never rely on an IP allowlist instead of the signature. This tutorial puts these practices into code in Python, Node.js and Go, using the [signing secret of the subscription](/concepts/subscriptions#subscription-secrets) that delivered the webhook, then moves on to advanced security patterns. :::tip Get a real signature to test against Point a subscription at [Hook0 Play](https://play.hook0.com) to inspect signed requests, or [create a free Hook0 Cloud account](https://app.hook0.com/register) (100 events a day, no credit card). ::: ## Prerequisites - Completed [Getting Started](./getting-started.md) tutorial up to the signature verification. - Understanding of cryptographic concepts (HMAC, hashing) - Basic knowledge of HTTP security ### Set Up Environment Variables ```bash # Set your service token (from dashboard) export HOOK0_TOKEN="YOUR_TOKEN_HERE" export HOOK0_API="https://app.hook0.com/api/v1" # Replace by your domain (or http://localhost:8081 locally) # Set your application ID (shown in dashboard URL or application details) export APP_ID="YOUR_APPLICATION_ID_HERE" ``` Save these values: ```bash # Save to .env file for later use cat > .env < { req.rawBody = buf; } })); function verifyHook0Signature(rawBody, signature, headers, secret) { // An unset secret is one anyone can sign with: never let it verify anything if (!secret) return false; const parts = Object.fromEntries(signature.split(',').map(p => p.split('='))); const headerNames = parts.h ? parts.h.split(' ') : []; const headerValues = headerNames.map(h => headers[h] || '').join('.'); // Use raw body string directly, not JSON.stringify(parsedBody) const signedData = parts.h ? `${parts.t}.${parts.h}.${headerValues}.${rawBody}` : `${parts.t}.${rawBody}`; const expected = crypto.createHmac('sha256', secret).update(signedData).digest('hex'); return parts.v1 === expected; } app.post('/webhooks/secure', (req, res) => { const signature = req.headers['x-hook0-signature']; const rawBodyString = req.rawBody.toString('utf8'); if (!signature || !verifyHook0Signature(rawBodyString, signature, req.headers, process.env.WEBHOOK_SECRET)) { return res.status(401).json({ error: 'Invalid signature' }); } console.log('Verified webhook:', req.body); res.json({ status: 'verified' }); }); app.listen(3000); ``` :::tip Using the Official SDK For production use, reach for the official client for your language rather than the code above. All eleven verify signatures, and each page shows the call: [JavaScript](../reference/sdk/javascript.md), [Rust](../reference/sdk/rust.md), [Python](../reference/sdk/python.md), [Go](../reference/sdk/go.md), [Ruby](../reference/sdk/ruby.md), [PHP](../reference/sdk/php.md), [C#](../reference/sdk/csharp.md), [Java](../reference/sdk/java.md), [Kotlin](../reference/sdk/kotlin.md), [Lua](../reference/sdk/lua.md), [Zig](../reference/sdk/zig.md). In Node.js, that is the `hook0-client` npm package: ```javascript const { verifyWebhookSignature } = require('hook0-client'); // Capture raw body for signature verification app.post('/webhooks', express.json({ verify: (req, res, buf) => { req.rawBody = buf; } }), (req, res) => { const signature = req.headers['x-hook0-signature']; const rawBodyString = req.rawBody.toString('utf8'); try { verifyWebhookSignature(signature, rawBodyString, req.headers, process.env.WEBHOOK_SECRET, 300); // Process webhook... res.json({ received: true }); } catch (error) { res.status(401).json({ error: error.message }); } }); ``` ::: ### Python Implementation :::tip The Python SDK does this for you `hook0-client` ships `verify_webhook_signature`, which handles both signature schemes, the bilateral clock window and the covered headers. The code below is here to show what it computes. See the [Python SDK](../reference/sdk/python.md). ::: ```python import hmac import hashlib import os from flask import Flask, request, jsonify app = Flask(__name__) def verify_hook0_signature(body: bytes, signature: str, headers: dict, secret: str) -> bool: # An unset secret is one anyone can sign with: never let it verify anything if not secret: return False parts = dict(p.split('=') for p in signature.split(',')) header_names = parts.get('h', '').split(' ') if parts.get('h') else [] header_values = '.'.join(headers.get(h, '') for h in header_names) signed_data = f"{parts['t']}.{parts['h']}.{header_values}.{body.decode()}" if parts.get('h') else f"{parts['t']}.{body.decode()}" expected = hmac.new(secret.encode(), signed_data.encode(), hashlib.sha256).hexdigest() return hmac.compare_digest(expected, parts['v1']) @app.route('/webhooks/secure', methods=['POST']) def handle_webhook(): signature = request.headers.get('X-Hook0-Signature') if not signature or not verify_hook0_signature(request.data, signature, request.headers, os.environ['WEBHOOK_SECRET']): return jsonify({'error': 'Invalid signature'}), 401 payload = request.get_json() print(f"Verified webhook: {payload}") return jsonify({'status': 'verified'}) if __name__ == '__main__': app.run(port=3000) ``` ### Go Implementation :::tip The Go SDK does this for you `hook0.VerifyWebhookSignature` handles both signature schemes, the bilateral clock window and the covered headers, and returns errors you match with `errors.Is`. The code below is here to show what it computes. See the [Go SDK](../reference/sdk/go.md). ::: ```go package main import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "io" "net/http" "os" "strings" ) func verifyHook0Signature(body []byte, signature, secret string, headers http.Header) bool { // os.Getenv answers "" for an unset variable, and anyone can sign with that if secret == "" { return false } parts := make(map[string]string) for _, p := range strings.Split(signature, ",") { kv := strings.SplitN(p, "=", 2) if len(kv) == 2 { parts[kv[0]] = kv[1] } } var signedData string if h, ok := parts["h"]; ok && h != "" { headerNames := strings.Split(h, " ") var headerValues []string for _, name := range headerNames { headerValues = append(headerValues, headers.Get(name)) } signedData = fmt.Sprintf("%s.%s.%s.%s", parts["t"], h, strings.Join(headerValues, "."), body) } else { signedData = fmt.Sprintf("%s.%s", parts["t"], body) } mac := hmac.New(sha256.New, []byte(secret)) mac.Write([]byte(signedData)) expected := hex.EncodeToString(mac.Sum(nil)) return hmac.Equal([]byte(expected), []byte(parts["v1"])) } func webhookHandler(w http.ResponseWriter, r *http.Request) { body, _ := io.ReadAll(r.Body) signature := r.Header.Get("X-Hook0-Signature") if !verifyHook0Signature(body, signature, os.Getenv("WEBHOOK_SECRET"), r.Header) { http.Error(w, "Invalid signature", http.StatusUnauthorized) return } var payload map[string]interface{} json.Unmarshal(body, &payload) fmt.Printf("Verified webhook: %v\n", payload) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{"status": "verified"}) } func main() { http.HandleFunc("/webhooks/secure", webhookHandler) http.ListenAndServe(":3000", nil) } ``` ## Step 3: Advanced Signature Verification ### Secret Rotation Support During rotation, accept signatures from both old and new secrets: ```javascript function verifyWithSecrets(rawBody, signature, headers, secrets) { return secrets.some(s => verifyHook0Signature(rawBody, signature, headers, s)); } const secrets = [process.env.WEBHOOK_SECRET, process.env.WEBHOOK_SECRET_OLD].filter(Boolean); const rawBodyString = req.rawBody.toString('utf8'); if (!verifyWithSecrets(rawBodyString, signature, req.headers, secrets)) { return res.status(401).json({ error: 'Invalid signature' }); } ``` ### Replay Attack Protection The timestamp (`t`) in the signature prevents replay attacks. To enforce a time window: ```javascript function verifyHook0Signature(payload, signature, headers, secret, toleranceSec = 300) { if (!secret) return false; // an unset secret must never verify anything const parts = Object.fromEntries(signature.split(',').map(p => p.split('='))); const now = Math.floor(Date.now() / 1000); if (Math.abs(now - parseInt(parts.t)) > toleranceSec) return false; const headerNames = parts.h ? parts.h.split(' ') : []; const headerValues = headerNames.map(h => headers[h] || '').join('.'); const signedData = parts.h ? `${parts.t}.${parts.h}.${headerValues}.${payload}` : `${parts.t}.${payload}`; const expected = crypto.createHmac('sha256', secret).update(signedData).digest('hex'); return parts.v1 === expected; } ``` ## Step 4: Custom Header Authentication Add additional authentication headers to your [subscriptions](/concepts/subscriptions): ### Create Subscription with Custom Headers ```bash curl -X POST "$HOOK0_API/subscriptions" \ -H "Authorization: Bearer $HOOK0_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "application_id": "'"$APP_ID"'", "is_enabled": true, "event_types": ["user.account.created"], "description": "Webhook with custom authentication", "labels": { "environment": "production" }, "target": { "type": "http", "method": "POST", "url": "https://your-api.com/webhooks/authenticated", "headers": { "Content-Type": "application/json", "X-API-Key": "your-api-key-here", "X-Webhook-Source": "hook0", "Authorization": "Bearer your-bearer-token" } } }' ``` ### Verify Custom Headers ```javascript // Note: Express.js normalizes all header names to lowercase app.post('/webhooks/authenticated', (req, res) => { const signature = req.headers['x-hook0-signature']; const rawBodyString = req.rawBody.toString('utf8'); const apiKey = req.headers['x-api-key']; const webhookSource = req.headers['x-webhook-source']; const authorization = req.headers['authorization']; // Verify Hook0 signature if (!verifyHook0Signature(rawBodyString, signature, req.headers, process.env.WEBHOOK_SECRET)) { return res.status(401).json({ error: 'Invalid signature' }); } // Verify custom API key if (apiKey !== process.env.EXPECTED_API_KEY) { return res.status(401).json({ error: 'Invalid API key' }); } // Verify webhook source if (webhookSource !== 'hook0') { return res.status(401).json({ error: 'Invalid webhook source' }); } // Verify bearer token const expectedToken = `Bearer ${process.env.EXPECTED_BEARER_TOKEN}`; if (authorization !== expectedToken) { return res.status(401).json({ error: 'Invalid authorization' }); } console.log('Multi-factor authenticated webhook:', req.body.event_type); res.json({ status: 'authenticated' }); }); ``` ## Step 5: About IP Allowlisting :::danger Not Recommended IP allowlisting is **not recommended** for Hook0 webhooks. Hook0 does not guarantee a fixed set of IP addresses for webhook delivery, and IPs may change without notice. **Always rely on signature verification** as your primary authentication mechanism - it's cryptographically secure and doesn't depend on network configuration. If you have specific compliance requirements that mandate IP filtering, contact Hook0 support about enterprise options. ::: ## Step 6: Authentication Middleware Create reusable authentication middleware that properly handles the Hook0 signature format (`t=...,v1=...`): ```javascript // auth-middleware.js const crypto = require('crypto'); class WebhookAuth { constructor(options = {}) { this.secrets = options.secrets || []; this.timestampTolerance = options.timestampTolerance || 300; this.requiredHeaders = options.requiredHeaders || {}; } // Parse the Hook0 signature format: "t=123456,h=content-type,v1=abc123..." parseSignature(signature) { return Object.fromEntries( signature.split(',').map(part => { const [key, ...valueParts] = part.split('='); return [key, valueParts.join('=')]; // Handle '=' in values }) ); } // Compute the expected signature based on Hook0's spec computeSignature(payload, headers, parts, secret) { let signedData; if (parts.h) { const headerNames = parts.h.split(' '); const headerValues = headerNames.map(h => headers[h.toLowerCase()] || '').join('.'); signedData = `${parts.t}.${parts.h}.${headerValues}.${payload}`; } else { signedData = `${parts.t}.${payload}`; } return crypto.createHmac('sha256', secret).update(signedData).digest('hex'); } verifyHeaders(headers) { for (const [key, expectedValue] of Object.entries(this.requiredHeaders)) { if (headers[key.toLowerCase()] !== expectedValue) { return false; } } return true; } middleware() { return (req, res, next) => { // 1. Custom header verification if (!this.verifyHeaders(req.headers)) { return res.status(401).json({ error: 'Required headers missing or invalid' }); } // 2. Signature header presence const signatureHeader = req.headers['x-hook0-signature']; if (!signatureHeader) { return res.status(401).json({ error: 'Missing signature' }); } // 3. Parse the signature format: t=...,v1=... const parts = this.parseSignature(signatureHeader); if (!parts.t || !parts.v1) { return res.status(401).json({ error: 'Invalid signature format' }); } // 4. Timestamp validation (replay attack protection) const now = Math.floor(Date.now() / 1000); if (Math.abs(now - parseInt(parts.t)) > this.timestampTolerance) { return res.status(401).json({ error: 'Timestamp too old or in future' }); } // 5. Verify signature against all secrets (rotation support) const payload = req.rawBody.toString('utf8'); let signatureValid = false; for (const secret of this.secrets) { const expected = this.computeSignature(payload, req.headers, parts, secret); try { if (crypto.timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected))) { signatureValid = true; break; } } catch (e) { // Length mismatch - signatures don't match continue; } } if (!signatureValid) { return res.status(401).json({ error: 'Invalid signature' }); } // 6. Attach parsed body for the controller req.webhook = req.body; next(); }; } } module.exports = WebhookAuth; ``` ### Use the Middleware ```javascript const WebhookAuth = require('./auth-middleware'); const webhookAuth = new WebhookAuth({ secrets: [ process.env.WEBHOOK_SECRET_CURRENT, process.env.WEBHOOK_SECRET_PREVIOUS // For rotation support ].filter(Boolean), timestampTolerance: 300, // 5 minutes requiredHeaders: { 'x-webhook-source': 'hook0' // Optional custom header check } }); // Capture raw body for signature verification app.use('/webhooks', express.json({ verify: (req, res, buf) => { req.rawBody = buf; } })); app.use('/webhooks', webhookAuth.middleware()); app.post('/webhooks/secure', (req, res) => { // req.webhook contains the parsed and verified payload console.log('Authenticated webhook:', req.webhook.event_type); res.json({ status: 'authenticated' }); }); ``` ## Step 7: Testing Authentication Create a test script to verify your authentication. The script must generate signatures in Hook0's format (`t=...,v1=...`): ```javascript // test-auth.js (requires Node.js 18+) const crypto = require('crypto'); // Generate a Hook0-compatible signature: t={timestamp},v1={hmac} function generateHook0Signature(payload, secret, timestamp) { // Simple format without header signing (h= is optional) const signedData = `${timestamp}.${payload}`; const v1 = crypto.createHmac('sha256', secret).update(signedData).digest('hex'); return `t=${timestamp},v1=${v1}`; } async function testWebhook(url, payloadObj, secret, extraHeaders = {}) { const payload = JSON.stringify(payloadObj); const timestamp = Math.floor(Date.now() / 1000); const signature = generateHook0Signature(payload, secret, timestamp); console.log(`\n📤 Sending webhook to ${url}`); console.log(` Signature: ${signature}`); try { const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Hook0-Signature': signature, 'X-Webhook-Source': 'hook0', ...extraHeaders }, body: payload }); const text = await response.text(); if (response.ok) { console.log(` ✅ ${response.status}: ${text}`); } else { console.log(` ❌ ${response.status}: ${text}`); } } catch (err) { console.error(` ❌ Request failed:`, err.message); } } async function runTests() { const SECRET = 'your-webhook-secret'; const URL = 'http://localhost:3000/webhooks/secure'; console.log('🧪 Testing Webhook Authentication\n'); // Test 1: Valid signature console.log('Test 1: Valid signature'); await testWebhook(URL, { event_id: 'evt_123', event_type: 'user.account.created', payload: { user_id: 'user_123' } }, SECRET); // Test 2: Invalid signature (wrong secret) console.log('\nTest 2: Invalid signature (wrong secret)'); await testWebhook(URL, { event_id: 'evt_456', event_type: 'user.account.created', payload: { user_id: 'user_456' } }, 'wrong-secret'); // Test 3: Expired timestamp (replay attack) console.log('\nTest 3: Expired timestamp (6 minutes ago)'); const oldTimestamp = Math.floor(Date.now() / 1000) - 360; const payload = JSON.stringify({ event_id: 'evt_789', event_type: 'test' }); const oldSignature = generateHook0Signature(payload, SECRET, oldTimestamp); const response = await fetch(URL, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Hook0-Signature': oldSignature, 'X-Webhook-Source': 'hook0' }, body: payload }); console.log(` ${response.ok ? '✅' : '❌'} ${response.status}: ${await response.text()}`); console.log('\n✨ Tests completed!'); } runTests(); ``` Run the tests: ```bash node test-auth.js ``` ## Best Practices ### Security - ✅ Always verify HMAC signatures - ✅ Use timing-safe comparison for signatures - ✅ Implement timestamp validation to prevent replay attacks - ✅ Store secrets securely (environment variables, key management) - ✅ Use HTTPS for all webhook endpoints - ✅ Log authentication failures for monitoring - ❌ Do not log webhook secrets - ❌ Do not skip signature verification ### Implementation - ✅ Create reusable authentication middleware - ✅ Support secret rotation with multiple valid secrets - ✅ Return appropriate HTTP status codes - ✅ Validate request format before processing - ❌ Do not process webhooks with invalid authentication - ❌ Do not expose internal error details in responses ## What You've Learned ✅ Implemented HMAC-SHA256 signature verification ✅ Built multi-language webhook authentication ✅ Created advanced security patterns (timestamp validation, replay attack protection) ✅ Configured custom header authentication ✅ Built reusable authentication middleware ✅ Tested authentication implementations ## Next Steps - [Securing Webhook Endpoints](../how-to-guides/secure-webhook-endpoints.md) - [Debugging Failed Webhook Deliveries](../how-to-guides/debug-failed-webhooks.md) - [Webhook best practices](../how-to-guides/webhook-best-practices.md) - Payload design, idempotency, versioning, and more security patterns ## Troubleshooting ### Signature Verification Fails 1. Check you're using the raw request body (not parsed JSON) 2. Verify the correct subscription secret 3. Ensure consistent character encoding (UTF-8) 4. Check HMAC algorithm (SHA256, not SHA1) ### Timestamp Validation Issues 1. Verify timestamp format (Unix timestamp in seconds) 2. Check server clock synchronization 3. Adjust tolerance window if needed 4. Handle timezone differences correctly # Building a Stripe-like webhook system This tutorial walks through building a production-grade webhook system similar to Stripe's, using Hook0. You will set up event types, a webhook receiver with signature verification, multi-tenancy, and monitoring. **Time**: 45 minutes **Level**: Intermediate **Prerequisites**: Docker, Node.js 18+, curl, basic webhook knowledge ## What you'll build A webhook system with: - Payment [event types](/concepts/event-types) (`payment.charge.succeeded`, `payment.charge.failed`) - Secure webhook receiver with signature verification - Multi-tenant event routing using [labels](/concepts/labels) - Webhook testing and debugging tools - Dashboard monitoring **Final architecture**: ```mermaid flowchart LR PS[Payment Service]:::external --> H0[Hook0]:::hook0 H0 --> C1[Customer Endpoint A]:::customer H0 --> C2[Customer Endpoint B]:::customer H0 -.- R[Retries, signatures, monitoring]:::processing classDef external fill:#dbeafe,stroke:#60a5fa,color:#1e3a5f classDef hook0 fill:#dcfce7,stroke:#4ade80,color:#14532d classDef customer fill:#ffedd5,stroke:#fb923c,color:#7c2d12 classDef processing fill:#ede9fe,stroke:#a78bfa,color:#3b0764 click H0 "/explanation/what-is-hook0" "What is Hook0?" click R "/explanation/webhook-retry-logic" "Retries and signatures" ``` ## Part 1: Environment setup ### Step 1.1: Start Hook0 with Docker Compose Clone and start Hook0: ```bash git clone https://github.com/hook0/hook0.git cd hook0 docker compose up -d # Wait for services to be ready (first build takes 10-15 min) docker compose logs -f api # Wait until you see "Listening on 0.0.0.0:8081" # Verify API is running # Use http://localhost:8081 for self-hosted or https://app.hook0.com for cloud curl http://localhost:8081/api/v1/swagger.json | head -1 # Expected: {"openapi":"3.0.3"... ``` ### Step 1.2: Create application and get token ### Create organization and application via dashboard 1. **Access the Hook0 Dashboard** at http://localhost:8001 (self-hosted) or https://app.hook0.com (cloud) 2. **Create an Organization**: Click "Create Organization" and fill in: - Name: `Payment Processor Inc` - Description: `Tutorial organization` 3. **Create an Application**: Within your organization, create a new application: - Name: `Payment API` - Description: `Handles payment webhooks` 4. **Generate a Service Token**: Go to Organization Settings → Service Tokens → Create Token - Name: `Payment API Token` - Copy the generated token immediately (it won't be shown again) :::tip Self-Hosted Email Verification For self-hosted instances, check Mailpit at http://localhost:8025 to access verification emails after registration. ::: ### Set up environment variables ```bash # Set your service token (from dashboard) export HOOK0_TOKEN="YOUR_TOKEN_HERE" export HOOK0_API="https://app.hook0.com/api/v1" # Replace by your domain (or http://localhost:8081/api/v1 locally) # Set your application ID (shown in dashboard URL or application details) export APP_ID="YOUR_APPLICATION_ID_HERE" ``` Save these values: ```bash # Save to .env file for later use cat > .env < p.split('='))); const headerNames = parts.h ? parts.h.split(' ') : []; const headerValues = headerNames.map(h => headers[h] || '').join('.'); // Use raw body string directly, not JSON.stringify(parsedBody) const signedData = parts.h ? `${parts.t}.${parts.h}.${headerValues}.${rawBody}` : `${parts.t}.${rawBody}`; const expected = crypto.createHmac('sha256', secret).update(signedData).digest('hex'); return parts.v1 === expected; } // Capture raw body for signature verification app.use('/webhook/:tenant', express.json({ verify: (req, res, buf) => { req.rawBody = buf; } })); app.post('/webhook/:tenant', (req, res) => { const tenant = req.params.tenant; const signature = req.headers['x-hook0-signature']; const secret = WEBHOOK_SECRETS[tenant]; const rawBodyString = req.rawBody.toString('utf8'); if (!secret) return res.status(404).json({ error: 'Tenant not found' }); if (!verifySignature(rawBodyString, signature, req.headers, secret)) { return res.status(401).json({ error: 'Invalid signature' }); } const event = req.body; // Idempotency check if (processedEvents.has(event.event_id)) { return res.json({ status: 'already_processed' }); } processedEvents.add(event.event_id); // Parse payload (can be string or object) const payload = typeof event.payload === 'string' ? JSON.parse(event.payload) : event.payload; console.log(`[${tenant}] ${event.event_type}:`, payload); res.json({ status: 'ok', event_id: event.event_id }); }); app.get('/health', (req, res) => res.json({ status: 'ok' })); app.listen(3000, () => console.log('Webhook receiver on http://localhost:3000')); ``` :::tip Signature verification For details on timestamp validation and secret rotation, see [Implementing Webhook Authentication](./webhook-authentication.md). ::: ### Step 3.3: Start the receiver ```bash # In webhook-receiver directory node server.js ``` Output: ``` Webhook receiver on http://localhost:3000 ``` Test health endpoint: ```bash curl http://localhost:3000/health ``` ## Part 4: Create subscriptions ### Step 4.1: Create subscription for ACME Corp ```bash # Load environment source .env # Create subscription SUB_ACME=$(curl -s -X POST "$HOOK0_API/subscriptions" \ -H "Authorization: Bearer $HOOK0_TOKEN" \ -H "Content-Type: application/json" \ -d "{ \"application_id\": \"$APP_ID\", \"is_enabled\": true, \"description\": \"ACME Corp payment webhooks\", \"event_types\": [ \"payment.charge.succeeded\", \"payment.charge.failed\", \"payment.refund.created\" ], \"labels\": { \"tenant_id\": \"acme_corp\" }, \"target\": { \"type\": \"http\", \"method\": \"POST\", \"url\": \"http://host.docker.internal:3000/webhook/acme_corp\", \"headers\": { \"X-Tenant\": \"acme_corp\" } } }") # Extract subscription ID and secret export SUB_ACME_ID=$(echo $SUB_ACME | jq -r '.subscription_id') export SUB_ACME_SECRET=$(echo $SUB_ACME | jq -r '.secret') echo "ACME Subscription ID: $SUB_ACME_ID" echo "ACME Secret: $SUB_ACME_SECRET" ``` :::info `host.docker.internal` allows Docker containers to access host machine. For production, use public URLs. ::: ### Step 4.2: Update the receiver with the real secret Update `server.js` to use the real subscription secret: ```javascript const WEBHOOK_SECRETS = { 'acme_corp': process.env.SUB_ACME_SECRET || 'secret_acme_123', 'globex_inc': process.env.SUB_GLOBEX_SECRET || 'secret_globex_456' }; ``` Restart with real secret: ```bash export SUB_ACME_SECRET="" cd webhook-receiver node server.js ``` ### Step 4.3: Create subscription for Globex Inc ```bash source .env SUB_GLOBEX=$(curl -s -X POST "$HOOK0_API/subscriptions" \ -H "Authorization: Bearer $HOOK0_TOKEN" \ -H "Content-Type: application/json" \ -d "{ \"application_id\": \"$APP_ID\", \"is_enabled\": true, \"description\": \"Globex Inc payment webhooks\", \"event_types\": [ \"payment.charge.succeeded\", \"customer.account.created\" ], \"labels\": { \"tenant_id\": \"globex_inc\" }, \"target\": { \"type\": \"http\", \"method\": \"POST\", \"url\": \"http://host.docker.internal:3000/webhook/globex_inc\", \"headers\": { \"X-Tenant\": \"globex_inc\" } } }") export SUB_GLOBEX_ID=$(echo $SUB_GLOBEX | jq -r '.subscription_id') export SUB_GLOBEX_SECRET=$(echo $SUB_GLOBEX | jq -r '.secret') echo "Globex Subscription ID: $SUB_GLOBEX_ID" echo "Globex Secret: $SUB_GLOBEX_SECRET" ``` ## Part 5: Send test events If your local webhook receiver is not running yet, verify event delivery first by pointing a subscription to [play.hook0.com](https://play.hook0.com). Once your receiver is ready, tunnel requests to localhost: ```bash hook0 play listen --forward http://localhost:3000/webhook/acme_corp ``` ### Step 5.1: Send payment success event (ACME Corp) ```bash source .env curl -X POST "$HOOK0_API/event" \ -H "Authorization: Bearer $HOOK0_TOKEN" \ -H "Content-Type: application/json" \ -d "{ \"application_id\": \"$APP_ID\", \"event_id\": \"$(uuidgen)\", \"event_type\": \"payment.charge.succeeded\", \"payload\": \"{\\\"charge_id\\\":\\\"ch_123\\\",\\\"amount\\\":4999,\\\"currency\\\":\\\"USD\\\",\\\"customer_id\\\":\\\"cus_acme_001\\\",\\\"description\\\":\\\"Pro plan subscription\\\"}\", \"payload_content_type\": \"application/json\", \"occurred_at\": \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\", \"labels\": { \"tenant_id\": \"acme_corp\", \"environment\": \"production\", \"priority\": \"high\" } }" ``` **Check webhook receiver logs** (the handler logs `[tenant] event_type:` followed by the parsed payload): ``` [acme_corp] payment.charge.succeeded: { charge_id: 'ch_123', amount: 4999, currency: 'USD', customer_id: 'cus_acme_001', description: 'Pro plan subscription' } ``` ### Step 5.2: Send payment failed event (ACME Corp) ```bash curl -X POST "$HOOK0_API/event" \ -H "Authorization: Bearer $HOOK0_TOKEN" \ -H "Content-Type: application/json" \ -d "{ \"application_id\": \"$APP_ID\", \"event_id\": \"$(uuidgen)\", \"event_type\": \"payment.charge.failed\", \"payload\": \"{\\\"charge_id\\\":\\\"ch_124\\\",\\\"amount\\\":4999,\\\"currency\\\":\\\"USD\\\",\\\"customer_id\\\":\\\"cus_acme_001\\\",\\\"error_code\\\":\\\"card_declined\\\",\\\"error_message\\\":\\\"Insufficient funds\\\"}\", \"payload_content_type\": \"application/json\", \"occurred_at\": \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\", \"labels\": { \"tenant_id\": \"acme_corp\", \"environment\": \"production\", \"priority\": \"critical\" } }" ``` ### Step 5.3: Send customer created event (Globex Inc) ```bash curl -X POST "$HOOK0_API/event" \ -H "Authorization: Bearer $HOOK0_TOKEN" \ -H "Content-Type: application/json" \ -d "{ \"application_id\": \"$APP_ID\", \"event_id\": \"$(uuidgen)\", \"event_type\": \"customer.account.created\", \"payload\": \"{\\\"customer_id\\\":\\\"cus_globex_001\\\",\\\"email\\\":\\\"customer@globex.com\\\",\\\"name\\\":\\\"John Doe\\\",\\\"plan\\\":\\\"enterprise\\\"}\", \"payload_content_type\": \"application/json\", \"occurred_at\": \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\", \"labels\": { \"tenant_id\": \"globex_inc\", \"environment\": \"production\" } }" ``` ### Step 5.4: Test label filtering Send event with wrong tenant label (should not trigger ACME subscription): ```bash curl -X POST "$HOOK0_API/event" \ -H "Authorization: Bearer $HOOK0_TOKEN" \ -H "Content-Type: application/json" \ -d "{ \"application_id\": \"$APP_ID\", \"event_id\": \"$(uuidgen)\", \"event_type\": \"payment.charge.succeeded\", \"payload\": \"{\\\"charge_id\\\":\\\"ch_999\\\"}\", \"payload_content_type\": \"application/json\", \"occurred_at\": \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\", \"labels\": { \"tenant_id\": \"unknown_tenant\" } }" ``` No webhook should be triggered (no subscription matches label). ## Part 6: Verify in dashboard ### Step 6.1: Check event delivery Query events via API: ```bash # List recent events curl "$HOOK0_API/events?application_id=$APP_ID&limit=10" \ -H "Authorization: Bearer $HOOK0_TOKEN" \ | jq '.[] | {event_id, event_type, labels, created_at}' ``` ### Step 6.2: Check delivery attempts ```bash # Get request attempts for subscription curl "$HOOK0_API/subscriptions/$SUB_ACME_ID/request_attempts?limit=10" \ -H "Authorization: Bearer $HOOK0_TOKEN" \ | jq '.[] | { event_type, status_code, duration_ms, created_at, next_retry_at }' ``` Expected output: ```json { "event_type": "payment.charge.succeeded", "status_code": 200, "duration_ms": 15, "created_at": "2025-12-10T12:00:00Z", "next_retry_at": null } ``` ### Step 6.3: Test signature verification failure Temporarily break signature verification to see failure: ```javascript // In server.js, comment out signature verification // if (!verifySignature(rawBodyString, signature, req.headers, secret)) { // return res.status(401).json({ error: 'Invalid signature' }); // } ``` Send event and observe 401 response in request attempts. ## Part 7: Add multi-tenant support ### Step 7.1: Create test script with multiple tenants Create `test-events.js`: ```javascript import fetch from 'node-fetch'; import { v4 as uuidv4 } from 'uuid'; const HOOK0_TOKEN = process.env.HOOK0_TOKEN; const HOOK0_API = process.env.HOOK0_API; const APP_ID = process.env.APP_ID; const tenants = ['acme_corp', 'globex_inc', 'wayne_enterprises']; async function sendEvent(eventType, payload, tenantId) { const event = { application_id: APP_ID, event_id: uuidv4(), event_type: eventType, payload: JSON.stringify(payload), payload_content_type: 'application/json', occurred_at: new Date().toISOString(), labels: { tenant_id: tenantId, environment: 'production' } }; const response = await fetch(`${HOOK0_API}/event`, { method: 'POST', headers: { 'Authorization': `Bearer ${HOOK0_TOKEN}`, 'Content-Type': 'application/json' }, body: JSON.stringify(event) }); if (!response.ok) { throw new Error(`Failed to send event: ${response.statusText}`); } console.log(`✓ Sent ${eventType} for ${tenantId}`); return response.json(); } async function simulatePayments() { console.log('🚀 Simulating multi-tenant payment events...\n'); for (const tenant of tenants) { // Successful payment await sendEvent('payment.charge.succeeded', { charge_id: `ch_${uuidv4()}`, amount: Math.floor(Math.random() * 10000) + 1000, currency: 'USD', customer_id: `cus_${tenant}_${Math.floor(Math.random() * 1000)}` }, tenant); await sleep(100); // Failed payment (10% chance) if (Math.random() < 0.1) { await sendEvent('payment.charge.failed', { charge_id: `ch_${uuidv4()}`, amount: 4999, currency: 'USD', customer_id: `cus_${tenant}_${Math.floor(Math.random() * 1000)}`, error_code: 'card_declined', error_message: 'Insufficient funds' }, tenant); } await sleep(100); } console.log('\n✨ Simulation complete!'); } function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } // Run simulation simulatePayments().catch(console.error); ``` Run simulation: ```bash npm install node-fetch node test-events.js ``` ### Step 7.2: Create webhook router (advanced) For production with many tenants, create a router: ```javascript // webhook-router.js import express from 'express'; const app = express(); const PORT = 4000; // Tenant configuration (from database in production) // NOTE: retryPolicy here illustrates what a custom implementation would require. // Hook0 handles retries automatically with a fixed progressive schedule // (3s → 10s → 3min → 30min → 1h → 3h → 5h → 10h, up to 25 attempts over 8 days). const TENANT_CONFIG = { 'acme_corp': { webhookUrl: 'https://acme.example.com/webhooks/hook0', authToken: 'acme_token_123', retryPolicy: { maxRetries: 3, backoff: 'exponential' } }, 'globex_inc': { webhookUrl: 'https://globex.example.com/api/webhooks', authToken: 'globex_token_456', retryPolicy: { maxRetries: 5, backoff: 'exponential' } } }; app.use(express.json()); app.post('/webhook/:tenant', async (req, res) => { const tenant = req.params.tenant; const event = req.body; const config = TENANT_CONFIG[tenant]; if (!config) { return res.status(404).json({ error: 'Tenant not found' }); } try { // Forward to tenant's webhook endpoint const response = await fetch(config.webhookUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${config.authToken}`, 'X-Forwarded-Event-Id': event.event_id }, body: JSON.stringify(event) }); if (!response.ok) { throw new Error(`Tenant webhook returned ${response.status}`); } res.status(200).json({ status: 'forwarded' }); } catch (error) { console.error(`Failed to forward to ${tenant}:`, error); res.status(500).json({ error: 'Forward failed' }); } }); app.listen(PORT, () => { console.log(`📡 Webhook router running on port ${PORT}`); }); ``` ## Part 8: Troubleshooting ### Common issues #### Events not delivered **Check subscription is enabled**: ```bash curl "$HOOK0_API/subscriptions/$SUB_ACME_ID" \ -H "Authorization: Bearer $HOOK0_TOKEN" \ | jq '.is_enabled' ``` **Check label matching**: ```bash # Event labels curl "$HOOK0_API/events/{event-id}" \ -H "Authorization: Bearer $HOOK0_TOKEN" \ | jq '.labels' # Subscription filter curl "$HOOK0_API/subscriptions/$SUB_ACME_ID" \ -H "Authorization: Bearer $HOOK0_TOKEN" \ | jq '.labels' ``` #### Signature verification failing See [Debugging Failed Webhooks](../how-to-guides/debug-failed-webhooks.md#scenario-3-webhook-signature-verification-failures) for debugging signature issues. #### Webhook endpoint timeout **Respond quickly**: ```javascript app.post('/webhook/:tenant', async (req, res) => { // Respond immediately res.status(200).json({ status: 'received' }); // Process asynchronously processWebhookAsync(req.body).catch(console.error); }); ``` ### Monitoring **Track delivery success rate**: ```bash curl "$HOOK0_API/subscriptions/$SUB_ACME_ID/request_attempts?limit=100" \ -H "Authorization: Bearer $HOOK0_TOKEN" \ | jq '[.[] | select(.status_code >= 200 and .status_code < 300)] | length / 100' ``` **Find slow deliveries**: ```bash curl "$HOOK0_API/subscriptions/$SUB_ACME_ID/request_attempts?limit=100" \ -H "Authorization: Bearer $HOOK0_TOKEN" \ | jq '.[] | select(.duration_ms > 1000) | {event_type, duration_ms}' ``` ## What you've learned - Set up Hook0 with Docker Compose - Created payment event types (Stripe-like) - Built a webhook receiver with signature verification - Routed events by tenant with labels - Created and tested subscriptions - Verified webhook deliveries - Debugged common issues ## Next steps - [Security Model](../explanation/security-model.md) - [Monitor Webhook Performance](../how-to-guides/monitor-webhook-performance.md) ## Production checklist Before going to production: - [ ] Use real authentication tokens (not master key) - [ ] Store webhook secrets securely (env vars, secrets manager) - [ ] Implement idempotency with persistent storage (PostgreSQL) - [ ] Add structured logging with request IDs - [ ] Set up monitoring and alerting - [ ] Review retry behavior (Hook0 uses a fixed progressive schedule: 3s → 10s → 3min → 30min → 1h → 3h → 5h → 10h) - [ ] Enable TLS for webhook endpoints - [ ] Implement rate limiting on webhook receiver - [ ] Set up database backups - [ ] Document webhook integration for customers - [ ] Test failure scenarios (network errors, timeouts) - [ ] Configure CORS if needed ## Resources - [Hook0 API Reference](/openapi/intro) - [Webhook Best Practices](../how-to-guides/secure-webhook-endpoints.md) - [GitHub Repository](https://github.com/hook0/hook0) - [Example Code](https://github.com/hook0/examples) # How-to Guides Problem-solving guides for specific challenges you'll encounter when working with Hook0 in production. These guides assume you have basic Hook0 knowledge and focus on solving real-world problems. ## Production Operations ### [Debug Failed Webhooks](debug-failed-webhooks.md) Troubleshoot webhook delivery failures and diagnose common issues. **When to use:** When webhooks aren't being delivered or are failing consistently **Covers:** Log analysis, retry mechanisms, endpoint debugging, common failure patterns --- ### [Monitor Webhook Performance](monitor-webhook-performance.md) Track webhook delivery metrics, set up alerts, and optimize performance. **When to use:** When setting up production monitoring or investigating performance issues **Covers:** Dashboard usage, API metrics, Sentry integration, key performance indicators, debugging workflows --- ## Integration & Migration ### [Multi-Tenant Architecture](multi-tenant-architecture.md) Implement multi-tenant webhook systems using Hook0's [labels](/concepts/labels) and [subscriptions](/concepts/subscriptions). **When to use:** When building SaaS platforms that need tenant-isolated webhook delivery **Covers:** Label-based filtering, subscription management, tenant isolation patterns --- ## Security & Reliability ### [Managing Service Tokens](manage-service-tokens.md) Create, attenuate, and manage [service tokens](/concepts/service-tokens) for API access. **When to use:** When setting up API access for automation, CI/CD, or AI assistants **Covers:** Token creation, token attenuation, least privilege, rotation, revocation --- ### [Secure Webhook Endpoints](secure-webhook-endpoints.md) Implement robust security measures for webhook endpoints and payloads. **When to use:** When setting up production webhook endpoints that need to be secure **Covers:** Authentication methods, IP allowlisting, payload validation, rate limiting --- ### [Client-side Error Handling](client-error-handling.md) Implement robust error handling for Hook0 API responses. **When to use:** When building API integrations that need to handle errors gracefully **Covers:** Error response parsing, retry logic, error logging, handling strategies --- ## Quick Reference | Problem | Guide | Difficulty | |---------|-------|------------| | Webhooks not delivering | [Debug Failed Webhooks](debug-failed-webhooks.md) | Beginner | | Need performance monitoring | [Monitor Webhook Performance](monitor-webhook-performance.md) | Intermediate | | Setting up API authentication | [Managing Service Tokens](manage-service-tokens.md) | Beginner | | Need security implementation | [Secure Webhook Endpoints](secure-webhook-endpoints.md) | Intermediate | | Need error handling in client | [Client-side Error Handling](client-error-handling.md) | Beginner | | Building multi-tenant SaaS | [Multi-Tenant Architecture](multi-tenant-architecture.md) | Intermediate | ## Before You Start These guides assume you: - Have Hook0 running (see [Getting Started Tutorial](../tutorials/getting-started.md)) - Understand basic webhook concepts - Have access to Hook0 logs and configuration - Are comfortable with command-line tools ## Related Resources - **[Tutorials](../tutorials/index.md)** - If you need to learn Hook0 basics first - **[API Reference](../openapi/intro)** - For technical implementation details - **[Configuration Reference](../reference/configuration.md)** - For all configuration options - **[Error Codes](../reference/error-codes.md)** - For troubleshooting specific errors --- *Need help with a specific problem? Choose the most relevant guide above or ask in our [Discord community](https://www.hook0.com/community).* # Debugging failed webhook deliveries To debug a failed webhook in Hook0, open the event's [request attempts](/concepts/request-attempts), read the HTTP status and error name of the last one, then fetch its response to see the body and headers your endpoint sent back. Any non-2xx response or network error is retried automatically on a fixed schedule, so a 4xx caused by a bug in your handler keeps failing until you fix the handler or disable the subscription. Once the endpoint is fixed, replay the event with `POST /events/{event_id}/replay`. The rest of this guide covers each step: reading the dashboard and the API, the five failure scenarios that cause most incidents, local debugging, alerting on failure rates, and bulk recovery. ## What the delivery log records Every delivery try is a request attempt. Its stored response holds what your endpoint answered. | Field | Stored on | What it tells you | |-------|-----------|-------------------| | `status.type` | Request attempt | `pending`, `paused`, `in_progress`, `waiting`, `successful` or `failed` | | `retry_count` | Request attempt | `0` for the first try, then one more for each retry | | `http_response_status` | Request attempt | HTTP status your endpoint returned, empty when the request never got an answer | | `created_at`, `picked_at`, `failed_at`, `succeeded_at` | Request attempt | When the attempt was queued, sent, and how it ended | | `delay_until` | Request attempt | When the next retry is due | | `response_id` | Request attempt | Points to the stored response, fetched with `GET /responses/{response_id}` | | `response_error_name` | Response | `E_CONNECTION`, `E_TIMEOUT`, `E_DNS`, `E_HTTP`, `E_INVALID_TARGET` or `E_INVALID_HEADER` | | `http_code`, `headers`, `body` | Response | The status, headers and body your endpoint sent back | | `elapsed_time_ms` | Response | How long your endpoint took to answer | :::tip Try it on your own events The free Hook0 Cloud plan includes delivery monitoring for 100 events a day, with no credit card. [Create an account](https://app.hook0.com/register), or send a first request to [Hook0 Play](https://play.hook0.com) to see what a signed delivery looks like. ::: :::tip General Troubleshooting For API errors, connection issues, and authentication problems, see [Troubleshooting Guide](./troubleshooting.md). This guide focuses specifically on webhook delivery failures. ::: ## Quick diagnosis checklist Before diving deep, run through this quick checklist: - [ ] Is the webhook endpoint accessible? - [ ] Are you returning the correct HTTP status codes? - [ ] Is the endpoint processing requests within timeout limits? - [ ] Have you verified the webhook signature? - [ ] Is your subscription properly configured? - [ ] Is your subscription enabled? (see [Troubleshooting Guide - Events Not Being Delivered](./troubleshooting.md#events-not-being-delivered)) ## Understanding webhook failures Hook0 categorizes delivery failures by root cause: ### HTTP status code categories :::tip 2xx - Success Request processed successfully. No retry needed. ::: :::warning 4xx - Client Errors Retried like any other failure, so a 4xx from a bug in your handler fails again on every retry until you fix it. For 429 (Rate Limited), Hook0 waits at least as long as the `Retry-After` header asks, up to a cap. ::: :::danger 5xx - Server Errors Retried on the [retry schedule](/explanation/webhook-retry-logic). Points to a problem in your webhook endpoint. ::: :::info Network errors - Connection timeouts - DNS resolution failures - Connection refused ::: ### Set up environment variables ```bash # Set your service token (from dashboard) export HOOK0_TOKEN="YOUR_TOKEN_HERE" export HOOK0_API="https://app.hook0.com/api/v1" # Replace by your domain (or http://localhost:8081 locally) # Set your application ID (shown in dashboard URL or application details) export APP_ID="YOUR_APPLICATION_ID_HERE" ``` Save these values: ```bash # Save to .env file for later use cat > .env < ```json { "request_attempt_id": "b3f1c2a4-5d6e-47f8-9a0b-1c2d3e4f5a6b", "event_id": "9f8e7d6c-5b4a-4938-8271-6a5b4c3d2e1f", "event": { "event_id": "9f8e7d6c-5b4a-4938-8271-6a5b4c3d2e1f", "event_type_name": "billing.invoice.paid" }, "subscription": { "subscription_id": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "description": "Billing service webhook" }, "created_at": "2024-01-15T10:30:00Z", "picked_at": "2024-01-15T10:30:01Z", "failed_at": "2024-01-15T10:30:06Z", "succeeded_at": null, "delay_until": "2024-01-15T10:34:00Z", "response_id": "7c6b5a49-3827-4160-9e5d-4c3b2a1f0e9d", "retry_count": 3, "http_response_status": 500, "status": { "type": "waiting", "since": "2024-01-15T10:30:06Z", "until": "2024-01-15T10:34:00Z" } } ``` `http_response_status` and `retry_count` tell you what happened and how many times Hook0 has tried. The response body, headers, and error name are **not** on the attempt: it links to them through `response_id`. Follow that id to `GET /responses/{response_id}` (Step 2 below) to read what your endpoint actually returned. ## Step 2: Using the API for analysis ### Get request attempts via API ```bash # Get all request attempts for an application curl "$HOOK0_API/request_attempts/?application_id=$APP_ID" \ -H "Authorization: Bearer $HOOK0_TOKEN" # Filter by event curl "$HOOK0_API/request_attempts/?application_id=$APP_ID&event_id={EVENT_ID}" \ -H "Authorization: Bearer $HOOK0_TOKEN" # Filter by subscription curl "$HOOK0_API/request_attempts/?application_id=$APP_ID&subscription_id={SUBSCRIPTION_ID}" \ -H "Authorization: Bearer $HOOK0_TOKEN" ``` ### Get response details ```bash # Get the response body and headers for a failed attempt curl "$HOOK0_API/responses/{RESPONSE_ID}?application_id=$APP_ID" \ -H "Authorization: Bearer $HOOK0_TOKEN" ``` ## Step 3: Common failure scenarios ### Scenario 1: Connection timeouts **Symptoms:** - Error message: "Connection timeout" - No HTTP status code - High duration_ms values **Diagnosis:** ```bash # Test endpoint connectivity curl -v --max-time 30 https://your-webhook-endpoint.com/webhook # Check DNS resolution nslookup your-webhook-endpoint.com # Test from different networks curl -v https://your-webhook-endpoint.com/webhook ``` **Solutions:** - Optimize webhook processing to respond faster - Increase server resources - Check network connectivity - Verify DNS configuration ### Scenario 2: SSL/TLS certificate issues **Symptoms:** - Error message: "SSL certificate verification failed" - Connection errors for HTTPS endpoints **Diagnosis:** ```bash # Check SSL certificate openssl s_client -connect your-domain.com:443 -servername your-domain.com # Verify certificate chain curl -v https://your-webhook-endpoint.com/webhook ``` **Solutions:** ```bash # Renew expired certificates certbot renew # Fix certificate chain issues # Ensure intermediate certificates are included # Test with SSL Labs # https://www.ssllabs.com/ssltest/ ``` ### Scenario 3: Webhook signature verification failures **Symptoms:** - 401 Unauthorized responses - Error messages about invalid signatures - Webhook endpoint rejecting Hook0 requests **Diagnosis:** Add logging to compare expected vs received signature: ```javascript // Capture raw body for signature verification app.post('/webhook', express.json({ verify: (req, res, buf) => { req.rawBody = buf; } }), (req, res) => { const signature = req.headers['x-hook0-signature']; // Use raw body for signature verification, not JSON.stringify(req.body) const rawBodyString = req.rawBody.toString('utf8'); console.log('Received signature:', signature); console.log('Body length:', rawBodyString.length); console.log('Body preview:', rawBodyString.slice(0, 100)); // Your verification logic here const isValid = verifySignature(rawBodyString, signature, secret); console.log('Signature valid:', isValid); if (!isValid) { console.error('Signature verification failed'); return res.status(401).json({ error: 'Invalid signature' }); } res.json({ status: 'processed' }); }); ``` **Common mistakes:** ```javascript // ❌ Wrong - Using parsed body instead of raw const computed = crypto.createHmac('sha256', secret) .update(JSON.stringify(req.body)) // Wrong if body already parsed .digest('hex'); // ✅ Correct - Use the raw request body, not the re-serialized parsed body const rawBodyString = req.rawBody.toString('utf8'); const computed = crypto.createHmac('sha256', secret) .update(rawBodyString) .digest('hex'); ``` **Solutions:** 1. Use the raw request body for signature verification, never `JSON.stringify(req.body)` 2. Ensure consistent character encoding (UTF-8) 3. Verify you're using the correct subscription secret (fetch from API) 4. Check HMAC algorithm (SHA256) 5. Match Hook0's signature format exactly See [Implementing Webhook Authentication](../tutorials/webhook-authentication.md) for complete implementation guide. ### Scenario 4: Rate limiting **Symptoms:** - 429 Too Many Requests responses - Intermittent failures during high traffic **Diagnosis:** ```bash # Monitor request patterns - filter 429 responses curl "$HOOK0_API/request_attempts/?application_id=$APP_ID&subscription_id={SUBSCRIPTION_ID}" \ -H "Authorization: Bearer $HOOK0_TOKEN" | \ jq '.[] | select(.failed_at != null)' ``` **Solutions:** ```javascript // Implement rate limiting on your endpoint const rateLimit = require('express-rate-limit'); const webhookLimiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes max: 1000, // limit each IP to 1000 requests per windowMs message: 'Too many webhook requests', standardHeaders: true, legacyHeaders: false, }); app.use('/webhooks', webhookLimiter); ``` ### Scenario 5: Internal server errors (5xx) **Symptoms:** - 500, 502, 503, 504 responses - Generic error messages **Diagnosis:** ```javascript // Add comprehensive logging to your webhook handler app.post('/webhook', (req, res) => { const startTime = Date.now(); try { console.log('Webhook received:', { timestamp: new Date().toISOString(), headers: req.headers, body: req.body }); // Your webhook processing logic processWebhook(req.body); const duration = Date.now() - startTime; console.log('Webhook processed successfully:', { duration }); res.json({ status: 'processed' }); } catch (error) { const duration = Date.now() - startTime; console.error('Webhook processing failed:', { error: error.message, stack: error.stack, duration }); res.status(500).json({ error: 'Internal server error' }); } }); ``` **Solutions:** - Add proper error handling and logging - Monitor application performance and resources - Implement health checks - Use application monitoring tools (APM) :::tip Quick debugging with Hook0 Play [Hook0 Play](https://play.hook0.com) lets you inspect raw request headers, body, and method in real-time. Use it to verify what your endpoint actually receives before diving into code. ::: ## Step 4: Setting up webhook debugging ### Create a debug webhook endpoint ```javascript // debug-webhook.js const express = require('express'); const crypto = require('crypto'); const fs = require('fs'); const app = express(); // Middleware to log all requests app.use((req, res, next) => { const timestamp = new Date().toISOString(); const logEntry = { timestamp, method: req.method, url: req.url, headers: req.headers, query: req.query, ip: req.ip }; console.log('Request received:', JSON.stringify(logEntry, null, 2)); next(); }); // Capture raw body for signature verification, then parse JSON // This is critical: JSON.stringify(req.body) may differ from the original payload app.use('/webhook', express.json({ verify: (req, res, buf) => { req.rawBody = buf; } })); // Note: Express.js normalizes all header names to lowercase app.post('/webhook', (req, res) => { const timestamp = new Date().toISOString(); const signature = req.headers['x-hook0-signature']; // Use raw body for signature verification, not JSON.stringify(req.body) const rawBodyString = req.rawBody.toString('utf8'); const debugInfo = { timestamp, signature, bodyLength: rawBodyString.length, bodyPreview: rawBodyString.slice(0, 200), headers: req.headers }; console.log('Webhook debug info:', JSON.stringify(debugInfo, null, 2)); // Save to file for analysis fs.appendFileSync('webhook-debug.log', JSON.stringify({ ...debugInfo, fullBody: rawBodyString }) + '\n'); // Always respond successfully for debugging res.json({ status: 'debug_received', timestamp, bodyLength: rawBodyString.length }); }); app.listen(3000, () => { console.log('Debug webhook server running on port 3000'); }); ``` ### Use ngrok for local testing ```bash # Install ngrok npm install -g ngrok # Start your debug server node debug-webhook.js # In another terminal, expose it ngrok http 3000 # Use the ngrok URL in your Hook0 subscription # https://abc123.ngrok.io/webhook ``` ## Step 5: Automated failure detection ### Monitor failure rates with a script ```javascript // monitor-failures.js const fetch = require('node-fetch'); const HOOK0_TOKEN = '{YOUR_TOKEN}'; const APP_ID = '{APP_ID}'; const SUBSCRIPTION_ID = '{SUBSCRIPTION_ID}'; async function getFailureRate(subscriptionId, hours = 24) { // Get request attempts, optionally filtered by subscription const url = subscriptionId ? `http://localhost:8081/api/v1/request_attempts/?application_id=${APP_ID}&subscription_id=${subscriptionId}` : `http://localhost:8081/api/v1/request_attempts/?application_id=${APP_ID}`; const response = await fetch(url, { headers: { 'Authorization': `Bearer ${HOOK0_TOKEN}` } }); const attempts = await response.json(); const total = attempts.length; // failed_at is set (non-null) only when a delivery attempt has failed const failed = attempts.filter(a => a.failed_at !== null).length; const failureRate = total > 0 ? (failed / total) * 100 : 0; return { total, failed, failureRate, attempts: attempts.slice(0, 5) }; } async function monitorSubscriptions() { try { const stats = await getFailureRate(SUBSCRIPTION_ID); console.log(`Failure Rate: ${stats.failureRate.toFixed(2)}%`); console.log(`Total Attempts: ${stats.total}`); console.log(`Failed Attempts: ${stats.failed}`); if (stats.failureRate > 10) { console.warn('⚠️ High failure rate detected!'); // Show recent failures (failed_at is set when delivery failed) const recentFailures = stats.attempts.filter(a => a.failed_at !== null); console.log('Recent failures:', recentFailures); } } catch (error) { console.error('Monitoring error:', error.message); } } // Run monitoring monitorSubscriptions(); // Schedule regular monitoring setInterval(monitorSubscriptions, 5 * 60 * 1000); // Every 5 minutes ``` ### Set up alerts ```javascript // alert-system.js const nodemailer = require('nodemailer'); const transporter = nodemailer.createTransport({ host: 'smtp.your-domain.com', port: 587, auth: { user: 'alerts@your-domain.com', pass: 'your-password' } }); async function sendAlert(subject, message) { await transporter.sendMail({ from: 'alerts@your-domain.com', to: 'admin@your-domain.com', subject: `Hook0 Alert: ${subject}`, text: message, html: `
${message}
` }); } async function checkAndAlert() { const stats = await getFailureRate(SUBSCRIPTION_ID); if (stats.failureRate > 20) { const failedAttempts = stats.attempts.filter(a => a.failed_at !== null); await sendAlert('High Webhook Failure Rate', ` Failure Rate: ${stats.failureRate.toFixed(2)}% Total Attempts: ${stats.total} Failed Attempts: ${stats.failed} Recent failures: ${JSON.stringify(failedAttempts, null, 2)} `); } } ``` ## Step 6: Recovery strategies ### Manual retry of failed events ```bash # Get events for your application curl "$HOOK0_API/events/?application_id=$APP_ID" \ -H "Authorization: Bearer $HOOK0_TOKEN" # Replay a specific event curl -X POST "$HOOK0_API/events/{EVENT_ID}/replay" \ -H "Authorization: Bearer $HOOK0_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "application_id": "'"$APP_ID"'" }' ``` ### Bulk retry script ```javascript // retry-failed.js const HOOK0_TOKEN = '{YOUR_TOKEN}'; const APP_ID = '{APP_ID}'; async function retryFailedEvents(maxAge = 24) { // Get failed request attempts const response = await fetch( `http://localhost:8081/api/v1/request_attempts/?application_id=${APP_ID}`, { headers: { 'Authorization': `Bearer ${HOOK0_TOKEN}` } } ); const attempts = await response.json(); // Filter failed attempts (failed_at is set when delivery failed) const failedAttempts = attempts.filter(a => a.failed_at !== null); // Get unique event IDs const uniqueEvents = [...new Set(failedAttempts.map(a => a.event_id))]; console.log(`Found ${uniqueEvents.length} failed events to retry`); for (const eventId of uniqueEvents) { try { const retryResponse = await fetch( `http://localhost:8081/api/v1/events/${eventId}/replay`, { method: 'POST', headers: { 'Authorization': `Bearer ${HOOK0_TOKEN}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ application_id: APP_ID }) } ); if (retryResponse.ok) { console.log(`✅ Replayed event: ${eventId}`); } else { const error = await retryResponse.text(); console.log(`❌ Failed to replay event: ${eventId} - ${error}`); } // Rate limit retries await new Promise(resolve => setTimeout(resolve, 100)); } catch (error) { console.error(`Error replaying event ${eventId}:`, error.message); } } } retryFailedEvents(); ``` ## Step 7: Prevention strategies ### Circuit breaker pattern ```javascript // circuit-breaker.js class CircuitBreaker { constructor(threshold = 5, timeout = 60000) { this.threshold = threshold; this.timeout = timeout; this.failureCount = 0; this.lastFailure = null; this.state = 'CLOSED'; // CLOSED, OPEN, HALF_OPEN } async call(fn) { if (this.state === 'OPEN') { if (Date.now() - this.lastFailure < this.timeout) { throw new Error('Circuit breaker is OPEN'); } this.state = 'HALF_OPEN'; } try { const result = await fn(); this.onSuccess(); return result; } catch (error) { this.onFailure(); throw error; } } onSuccess() { this.failureCount = 0; this.state = 'CLOSED'; } onFailure() { this.failureCount++; this.lastFailure = Date.now(); if (this.failureCount >= this.threshold) { this.state = 'OPEN'; } } } // Use in webhook handler const circuitBreaker = new CircuitBreaker(3, 30000); app.post('/webhook', async (req, res) => { try { await circuitBreaker.call(async () => { await processWebhook(req.body); }); res.json({ status: 'processed' }); } catch (error) { console.error('Circuit breaker prevented processing:', error.message); res.status(503).json({ error: 'Service temporarily unavailable' }); } }); ``` ### Graceful degradation ```javascript // graceful-degradation.js app.post('/webhook', async (req, res) => { try { // Try primary processing await processPrimary(req.body); res.json({ status: 'processed' }); } catch (primaryError) { console.warn('Primary processing failed, trying fallback:', primaryError.message); try { // Try fallback processing await processFallback(req.body); res.json({ status: 'processed_fallback' }); } catch (fallbackError) { console.error('Both primary and fallback failed:', fallbackError.message); // Store for later processing await storeForRetry(req.body); res.status(202).json({ status: 'queued_for_retry' }); } } }); ``` ## Best practices ### Endpoint design - ✅ Return appropriate HTTP status codes - ✅ Respond within 30 seconds - ✅ Implement idempotency - ✅ Use structured error responses - ✅ Add comprehensive logging ### Error handling - ✅ Distinguish between temporary and permanent failures - ✅ Rely on Hook0's automatic retries with predefined delays - ✅ Use circuit breakers for external dependencies - ✅ Monitor and alert on high failure rates - ✅ Provide detailed error messages ### Testing - ✅ Test webhook endpoints thoroughly - ✅ Simulate failure scenarios - ✅ Verify signature validation - ✅ Test with various payload sizes - ✅ Monitor performance under load ## Troubleshooting checklist When webhook deliveries fail, work through this systematic checklist: ### Infrastructure - [ ] Endpoint is accessible from internet - [ ] SSL certificate is valid and not expired - [ ] DNS resolution works correctly - [ ] Firewall allows incoming connections - [ ] Load balancer is configured correctly ### Application - [ ] Webhook handler is properly implemented - [ ] Signature verification is working - [ ] Response times are under 30 seconds - [ ] Error handling returns appropriate status codes - [ ] Logging captures enough detail for debugging ### Hook0 Configuration - [ ] Subscription is enabled and active (see [Events Not Being Delivered](./troubleshooting.md#events-not-being-delivered)) - [ ] Event types match what you're sending - [ ] Target URL is correct - [ ] Custom headers are properly configured - [ ] Retry configuration is appropriate ## Related resources - [Troubleshooting Guide](./troubleshooting.md) - API errors, authentication, and configuration issues - [Implementing Webhook Authentication](../tutorials/webhook-authentication.md) - Signature verification guide - [Monitor Webhook Performance](./monitor-webhook-performance.md) - Performance monitoring - [Error Codes Reference](../reference/error-codes.md) - Error code list # Securing webhook endpoints A webhook endpoint is secure when it rejects every request it cannot prove came from the sender. With Hook0 that means five checks, in this order: verify the HMAC-SHA256 signature in the `X-Hook0-Signature` header against the raw body, reject timestamps outside a short window (300 seconds is a sensible default), validate the payload against a schema, skip any `event_id` you have already processed, and rate-limit the route. An IP allowlist is not one of them: Hook0 does not guarantee fixed delivery IPs, so an allowlist ends up dropping real webhooks. This guide implements each check, using [subscription signing secrets](/concepts/subscriptions#subscription-secrets) and [subscription](/concepts/subscriptions) configuration, then adds logging and attack detection. ## IP allowlist or HMAC signature? | | IP allowlist | HMAC signature | |-|--------------|----------------| | Proves the request came from Hook0 | No, only where it came from | Yes, only the holder of the subscription secret can sign | | Detects a modified payload | No | Yes, the signature covers the body | | Blocks a replayed request | No | Yes, combined with the timestamp check | | Survives sender infrastructure changes | No, breaks when delivery IPs change | Yes, independent of the network | | Secret rotation | Not applicable | Accept the old and new secret during the overlap | Keep IP filtering for blocking abusive sources (Step 4), not as proof of origin. :::tip Test your verifier against real signatures Send a request to [Hook0 Play](https://play.hook0.com) and copy its `X-Hook0-Signature` header, or [create a free Hook0 Cloud account](https://app.hook0.com/register) (100 events a day, no credit card) to sign deliveries with your own subscription secret. ::: ## Quick Start (5 minutes) 1. **Verify HMAC signature** - Check `X-Hook0-Signature` header 2. **Validate payload** - Use Joi/Zod to validate JSON structure 3. **Implement idempotency** - Track processed `event_id` to avoid duplicates 4. **Log requests** - Record all webhook attempts for debugging For detailed implementation, see sections below. ## Security threat model ### Common webhook security threats :::danger Spoofing attacks Attackers sending fake webhook requests. **Mitigation**: Signature verification. IP allowlisting does not prove origin, see [IP allowlist or HMAC signature?](#ip-allowlist-or-hmac-signature) ::: :::warning Replay attacks Reusing legitimate webhook requests. **Mitigation**: Timestamp validation, nonce tracking ::: :::info Man-in-the-Middle Intercepting and modifying webhook data. **Mitigation**: HTTPS, certificate pinning ::: :::danger Denial of service Overwhelming webhook endpoints with requests. **Mitigation**: Rate limiting, request validation ::: :::warning Data injection Malicious payloads causing application vulnerabilities. **Mitigation**: Input validation, sanitization ::: ## Step 1: Signature verification Hook0 uses HMAC-SHA256 signatures with the v1 format. For complete signature verification implementation in JavaScript, Python, and Go, see [Implementing Webhook Authentication](../tutorials/webhook-authentication.md). Key security considerations for signature verification: - Use timing-safe comparison to prevent timing attacks - Support multiple secrets for rotation - Validate timestamp to prevent replay attacks - Use raw request body (not parsed JSON) While implementing, send a test webhook to a [Hook0 Play](https://play.hook0.com) URL and copy the `X-Hook0-Signature` header value to verify your signature computation. ## Step 2: Input validation ### Schema-based validation ```javascript // webhook-validator.js const Joi = require('joi'); // Define webhook payload schemas const eventSchemas = { 'user.account.created': Joi.object({ event_id: Joi.string().uuid().required(), event_type: Joi.string().valid('user.account.created').required(), timestamp: Joi.string().isoDate().required(), payload: Joi.object({ user_id: Joi.string().required(), email: Joi.string().email().required(), name: Joi.string().min(1).max(255).required(), plan: Joi.string().optional() }).required(), labels: Joi.object().optional() }), 'order.completed': Joi.object({ event_id: Joi.string().uuid().required(), event_type: Joi.string().valid('order.completed').required(), timestamp: Joi.string().isoDate().required(), payload: Joi.object({ order_id: Joi.string().required(), customer_id: Joi.string().required(), total_amount: Joi.number().positive().required(), currency: Joi.string().length(3).uppercase().required() }).required(), labels: Joi.object().optional() }) }; function validateWebhookPayload(payload) { const schema = eventSchemas[payload.event_type]; if (!schema) { return { valid: false, error: `Unknown event type: ${payload.event_type}` }; } const { error, value } = schema.validate(payload, { abortEarly: false, stripUnknown: true }); if (error) { return { valid: false, error: 'Validation failed', details: error.details.map(d => ({ field: d.path.join('.'), message: d.message })) }; } return { valid: true, data: value }; } module.exports = { validateWebhookPayload }; ``` ### Sanitization and security filters ```javascript // security-filters.js const DOMPurify = require('isomorphic-dompurify'); const validator = require('validator'); class SecurityFilters { static sanitizeString(input, options = {}) { if (typeof input !== 'string') return input; let sanitized = input; // HTML sanitization if (options.allowHtml) { sanitized = DOMPurify.sanitize(sanitized); } else { sanitized = validator.escape(sanitized); } // Length limits if (options.maxLength) { sanitized = sanitized.substring(0, options.maxLength); } // Remove control characters sanitized = sanitized.replace(/[\x00-\x1F\x7F]/g, ''); return sanitized; } static validateEmail(email) { return validator.isEmail(email) && email.length <= 254; } static validateURL(url) { return validator.isURL(url, { protocols: ['https'], require_protocol: true, require_valid_protocol: true }); } static sanitizePayload(payload, rules = {}) { const sanitized = {}; for (const [key, value] of Object.entries(payload)) { const rule = rules[key] || {}; if (typeof value === 'string') { sanitized[key] = this.sanitizeString(value, rule); } else if (typeof value === 'object' && value !== null) { sanitized[key] = this.sanitizePayload(value, rule.nested || {}); } else { sanitized[key] = value; } } return sanitized; } } module.exports = SecurityFilters; ``` ## Step 3: Request deduplication ### Idempotency with in-memory storage ```javascript // idempotency-manager.js class IdempotencyManager { constructor(options = {}) { this.store = new Map(); this.defaultTTL = options.ttl || 24 * 60 * 60 * 1000; // 24 hours in ms this.maxSize = options.maxSize || 10000; // Cleanup old entries periodically this.cleanupInterval = setInterval(() => this.cleanup(), 60 * 60 * 1000); // Every hour } generateKey(eventId, subscriptionId) { return `${subscriptionId}:${eventId}`; } async checkAndSetProcessed(eventId, subscriptionId, ttl = this.defaultTTL) { const key = this.generateKey(eventId, subscriptionId); const existing = this.store.get(key); if (existing) { // Key already exists, this is a duplicate return { isFirst: false, key, previousProcessing: existing.data }; } // Key doesn't exist, set it this.store.set(key, { data: { processedAt: new Date().toISOString(), eventId, subscriptionId }, expiresAt: Date.now() + ttl }); // Enforce max size if (this.store.size > this.maxSize) { this.cleanup(); } return { isFirst: true, key }; } async markCompleted(eventId, subscriptionId, result) { const key = this.generateKey(eventId, subscriptionId); const entry = this.store.get(key); if (entry) { entry.data = { ...entry.data, completedAt: new Date().toISOString(), result: result, status: 'completed' }; } } async markFailed(eventId, subscriptionId, error) { const key = this.generateKey(eventId, subscriptionId); const entry = this.store.get(key); if (entry) { entry.data = { ...entry.data, failedAt: new Date().toISOString(), error: { message: error.message, stack: error.stack }, status: 'failed' }; } } cleanup() { const now = Date.now(); let removed = 0; for (const [key, entry] of this.store.entries()) { if (entry.expiresAt < now) { this.store.delete(key); removed++; } } console.log(`Cleaned up ${removed} expired entries. Current size: ${this.store.size}`); } destroy() { clearInterval(this.cleanupInterval); this.store.clear(); } } // Usage in webhook handler const idempotency = new IdempotencyManager({ ttl: 24 * 60 * 60 * 1000, // 24 hours maxSize: 10000 }); app.post('/webhook', async (req, res) => { const { event_id } = req.webhook; const subscriptionId = req.headers['hook0-subscription-id']; try { const idempotencyCheck = await idempotency.checkAndSetProcessed( event_id, subscriptionId ); if (!idempotencyCheck.isFirst) { console.log(`Duplicate webhook ignored: ${event_id}`); return res.json({ status: 'duplicate', previousProcessing: idempotencyCheck.previousProcessing }); } // Process the webhook const result = await processWebhook(req.webhook); await idempotency.markCompleted(event_id, subscriptionId, result); res.json({ status: 'processed', result }); } catch (error) { await idempotency.markFailed(event_id, subscriptionId, error); console.error('Webhook processing failed:', error); res.status(500).json({ error: 'Processing failed' }); } }); module.exports = IdempotencyManager; ``` :::tip Production note For production with multiple instances, use PostgreSQL for idempotency storage instead of in-memory. Query the database to check if an event has been processed before. ::: ## Step 4: IP allowlisting and geolocation :::caution Do not allowlist Hook0 delivery IPs Hook0 does not guarantee a fixed set of delivery IPs. Use the filter below to block abusive sources or to restrict traffic you control, and keep `requireAllowlist` off for Hook0 webhooks: the HMAC signature from Step 1 is what proves origin. ::: ### IP filtering ```javascript // ip-security.js const geoip = require('geoip-lite'); const ipRangeCheck = require('ip-range-check'); class IPSecurity { constructor(config = {}) { this.allowedIPs = config.allowedIPs || []; this.allowedRanges = config.allowedRanges || []; this.allowedCountries = config.allowedCountries || []; this.blockedCountries = config.blockedCountries || []; this.requireAllowlist = config.requireAllowlist || false; } isIPAllowed(clientIP) { // Remove IPv6 wrapper if present const ip = clientIP.replace(/^::ffff:/, ''); // Check explicit IP allowlist if (this.allowedIPs.includes(ip)) { return { allowed: true, reason: 'explicit_allow' }; } // Check IP ranges for (const range of this.allowedRanges) { if (ipRangeCheck(ip, range)) { return { allowed: true, reason: 'range_allow' }; } } // Check geolocation const geo = geoip.lookup(ip); if (geo) { // Check blocked countries first if (this.blockedCountries.includes(geo.country)) { return { allowed: false, reason: 'country_blocked', country: geo.country }; } // Check allowed countries if (this.allowedCountries.length > 0) { if (this.allowedCountries.includes(geo.country)) { return { allowed: true, reason: 'country_allow', country: geo.country }; } else { return { allowed: false, reason: 'country_not_allowed', country: geo.country }; } } } // If allowlist is required and no explicit allow, block if (this.requireAllowlist) { return { allowed: false, reason: 'not_in_allowlist' }; } // Default allow if no specific rules matched return { allowed: true, reason: 'default_allow' }; } middleware() { return (req, res, next) => { const clientIP = req.ip || req.connection.remoteAddress || req.headers['x-forwarded-for']?.split(',')[0]?.trim(); const ipCheck = this.isIPAllowed(clientIP); // Log all IP checks for monitoring console.log('IP Security Check:', { ip: clientIP, allowed: ipCheck.allowed, reason: ipCheck.reason, country: ipCheck.country, timestamp: new Date().toISOString() }); if (!ipCheck.allowed) { return res.status(403).json({ error: 'Access denied', reason: ipCheck.reason, country: ipCheck.country }); } req.clientIP = clientIP; req.ipSecurity = ipCheck; next(); }; } } module.exports = IPSecurity; ``` ## Step 5: Request logging and monitoring ### Request logging ```javascript // webhook-logger.js const winston = require('winston'); const crypto = require('crypto'); class WebhookLogger { constructor(options = {}) { this.logger = winston.createLogger({ level: options.level || 'info', format: winston.format.combine( winston.format.timestamp(), winston.format.errors({ stack: true }), winston.format.json() ), transports: [ new winston.transports.Console(), new winston.transports.File({ filename: options.logFile || 'webhooks.log' }) ] }); } hashSensitiveData(data) { return crypto.createHash('sha256').update(JSON.stringify(data)).digest('hex').substring(0, 16); } logWebhookRequest(req, res, responseTime) { const requestId = crypto.randomUUID(); const payloadHash = this.hashSensitiveData(req.webhook); const logData = { requestId, timestamp: new Date().toISOString(), method: req.method, path: req.path, statusCode: res.statusCode, responseTime, clientIP: req.clientIP, userAgent: req.headers['user-agent'], contentLength: req.headers['content-length'], payloadHash, eventType: req.webhook?.event_type, eventId: req.webhook?.event_id, signatureValid: req.webhookAuth?.signature?.valid, timestampValid: req.webhookAuth?.timestamp?.valid, ipSecurityReason: req.ipSecurity?.reason, ipSecurityCountry: req.ipSecurity?.country }; if (res.statusCode >= 400) { this.logger.error('Webhook request failed', logData); } else { this.logger.info('Webhook request processed', logData); } return requestId; } logSecurityEvent(type, details) { this.logger.warn('Security event detected', { type, details, timestamp: new Date().toISOString() }); } middleware() { return (req, res, next) => { const startTime = Date.now(); // Override res.end to capture response time const originalEnd = res.end; res.end = (...args) => { const responseTime = Date.now() - startTime; this.logWebhookRequest(req, res, responseTime); originalEnd.apply(res, args); }; next(); }; } } module.exports = WebhookLogger; ``` ## Step 6: Attack detection ### Anomaly detection ```javascript // anomaly-detector.js class AnomalyDetector { constructor(options = {}) { this.suspiciousPatterns = options.suspiciousPatterns || []; this.rateLimits = options.rateLimits || { requestsPerMinute: 60, failuresPerMinute: 10 }; this.monitoring = { requests: new Map(), failures: new Map(), patterns: new Map() }; } detectSuspiciousPayload(payload) { const payloadStr = JSON.stringify(payload).toLowerCase(); const suspiciousPatterns = [ /script\s*>/i, /