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 or self-hosted instance running
- API token
- Application ID from Hook0
- Basic understanding of REST APIs and webhooks
Set up environment variables
# 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:
# Save to .env file for later use
cat > .env <<EOF
HOOK0_TOKEN=$HOOK0_TOKEN
HOOK0_API=$HOOK0_API
APP_ID=$APP_ID
EOF
Architecture overview
Your SaaS sends events to Hook0, which handles delivery, retries, signature verification, and monitoring.
Understanding the API flow
The integration has four steps:
- Create event types - define what events your SaaS emits
- Create subscriptions - let customers receive webhooks
- Send events - push events to Hook0 when actions occur
- Monitor delivery - track webhook success and failures
Step 1: Define your event types
Hook0 uses a three-part structure for 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
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:
{
"service_name": "user",
"resource_type_name": "account",
"verb_name": "created",
"event_type_name": "user.account.created"
}
Create an "order completed" event type
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:
{
"service_name":"orders",
"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:
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\": \"[email protected]\", \"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:
{
"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
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:
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:
{
"application_id": "{APP_ID}",
"event_id": "{EVENT_ID}",
"received_at": "2025-12-12T09:27:27.045191Z"
}
Step 3: Create customer subscriptions
Subscriptions define where and how webhooks get delivered. Each subscription filters events by 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
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:
{
"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:
curl -X GET "$HOOK0_API/events/?application_id=$APP_ID" \
-H "Authorization: Bearer $HOOK0_TOKEN"
Response:
[
{
"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) and their status:
curl -X GET "$HOOK0_API/request_attempts/?application_id=$APP_ID" \
-H "Authorization: Bearer $HOOK0_TOKEN"
Response:
[
{
"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:
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:
# 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\": \"[email protected]\", \"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\": \"[email protected]\", \"plan\": \"premium\"}",
"labels": {
"tenant_id": "customer_123",
"batch_id": "batch-001",
"batch_size": "100"
}
}'
Environment-based filtering
Use labels to separate environments:
# 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:
{
"application_id": "{APP_ID}",
"event_id": "{EVENT_ID}",
"received_at": "2025-12-12T09:41:39.757315Z"
}
# 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:
{
"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)
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:
{
"service_name": "billing",
"resource_type_name": "invoice",
"verb_name": "paid",
"event_type_name": "billing.invoice.paid"
}
Step 2: Create Subscription (customer setup)
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:
{
"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)
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:
{
"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-Signatureheader for verification- The event payload
- Automatic retries on failure
See Implementing Webhook Authentication 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
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
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
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-tenancyenvironment: separate prod/staging/devregion: geographic filtering when needed
3. Error handling
- Use unique
event_idvalues 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
# 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
# Verify token is included
curl -H "Authorization: Bearer $HOOK0_TOKEN" # ✅ Correct
curl -H "Authorization: $HOOK0_TOKEN" # ❌ Wrong - missing Bearer
Event type not found
# 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:
- Create event types using the three-part structure
- Send events with labels for multi-tenancy
- Create subscriptions with label-based filtering
- Monitor deliveries and handle failures
- 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.