Migrate from Hook0
Move outbound webhooks from Hook0 to the Webhook Relay Outbound Gateway: applications, labels, subscriptions and event types mapped to consumers, endpoints and event types, the signature change for receivers, a dual-publish cutover with rollback and a TypeScript import script.
Moving from Hook0 turns label routing into explicit consumers. The label you use to route events to a customer (for example tenant_id) becomes the consumer ID, each subscription becomes an endpoint, event types keep their dotted names, and sendEvent becomes relay.outbound.messages.publish. Receivers move from X-Hook0-Signature to Standard Webhooks. Hook0's subscription secrets are UUIDs, which don't fit the importable secret format, so each endpoint gets a new whsec_ secret and each customer updates their receiver once.
Read the migration overview first for the plan limits and the general cutover plan. For what the Outbound Gateway is and how it compares with Hook0 and other providers, see the Outbound Gateway overview and the webhooks-as-a-service comparison.
Concept map
| Hook0 | Outbound Gateway | Notes |
|---|---|---|
| Organization | Webhook Relay account | |
| Application | Webhook Relay account (or one per environment) | Migrate one application at a time. |
Customer label (for example labels.tenant_id) | Consumer | The label value becomes the consumer ID (up to 128 letters, numbers, dots, colons, underscores or hyphens). |
| Subscription | Endpoint | Public HTTPS URL, always POST. Hook0's UUID secret doesn't fit the importable format, so the endpoint gets a new whsec_ secret. |
Subscription event_types | Endpoint event_types | Up to 100 per endpoint, or ["*"]. |
Subscription target.headers | Endpoint headers | Up to 32. webhook-* headers are reserved. |
Subscription is_enabled: false | Paused endpoint | |
Event type (service.resource.verb) | Event type | Same dotted name. |
Event (POST /api/v1/event/) | Message | JSON payload up to 256 KiB. |
event_id | event_id and the Idempotency-Key header | |
| Extra labels used for filtering | Function | Labels other than the customer label have no equivalent. A Function can drop messages based on the payload. |
payload_content_type other than JSON | No equivalent | Wrap the payload in a JSON object. |
API map
| Task | Hook0 | Outbound Gateway |
|---|---|---|
| Add an endpoint | POST /api/v1/subscriptions/ | PUT /v1/outbound/consumers/{id}, then POST /v1/outbound/consumers/{consumer}/endpoints |
| List endpoints | GET /api/v1/subscriptions/?application_id=… | GET /v1/outbound/consumers/{consumer}/endpoints |
| Add an event type | POST /api/v1/event_types/ | POST /v1/outbound/event-types |
| List event types | GET /api/v1/event_types/?application_id=… | GET /v1/outbound/event-types |
| Publish | POST /api/v1/event/ | POST /v1/outbound/messages |
| Replay an event | POST /api/v1/events/{event_id}/replay | POST /v1/outbound/endpoints/{id}/retry with message_id |
| Disable / enable | PUT /api/v1/subscriptions/{subscription_id} with is_enabled | POST /v1/outbound/endpoints/{id}/pause / .../resume |
| Rotate a secret | Not in the Hook0 API reference | POST /v1/outbound/endpoints/{id}/secret/rotate |
Hook0 routes are from the Hook0 OpenAPI spec. The hosted API base URL is https://app.hook0.com/api/v1.
Publish
Before, with hook0-client:
import { Event, Hook0Client } from "hook0-client";
const hook0 = new Hook0Client("https://app.hook0.com/api/v1", process.env.HOOK0_APPLICATION_ID!, process.env.HOOK0_TOKEN!);
await hook0.sendEvent(
new Event(
"billing.invoice.paid",
JSON.stringify({ invoice_id: "inv_123", amount: 4900 }),
"application/json",
{ tenant_id: "customer_42" },
),
);
After, with @webhookrelay/sdk:
import { WebhookRelay } from "@webhookrelay/sdk";
const relay = new WebhookRelay(); // reads RELAY_KEY and RELAY_SECRET
await relay.outbound.messages.publish(
{ consumer: "customer_42", eventType: "billing.invoice.paid", payload: { invoice_id: "inv_123", amount: 4900 } },
{ idempotencyKey: "invoice-paid:inv_123" },
);
Go, before (a direct API call) and after (github.com/webhookrelay/webhookrelay-go):
// Before
body, _ := json.Marshal(map[string]any{
"application_id": os.Getenv("HOOK0_APPLICATION_ID"),
"event_type": "billing.invoice.paid",
"labels": map[string]string{"tenant_id": "customer_42"},
"occurred_at": time.Now().UTC().Format(time.RFC3339),
"payload": `{"invoice_id":"inv_123","amount":4900}`,
"payload_content_type": "application/json",
})
req, _ := http.NewRequest(http.MethodPost, "https://app.hook0.com/api/v1/event/", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+os.Getenv("HOOK0_TOKEN"))
req.Header.Set("Content-Type", "application/json")
_, err := http.DefaultClient.Do(req)
// After
_, err = api.PublishOutboundMessage(&webhookrelay.OutboundMessagePublishOptions{
Consumer: "customer_42",
EventType: "billing.invoice.paid",
Payload: map[string]interface{}{"invoice_id": "inv_123", "amount": 4900},
IdempotencyKey: "invoice-paid:inv_123",
})
curl:
# Before
curl -X POST https://app.hook0.com/api/v1/event/ \
-H "Authorization: Bearer $HOOK0_TOKEN" -H 'Content-Type: application/json' \
-d "{\"application_id\":\"$HOOK0_APPLICATION_ID\",\"event_type\":\"billing.invoice.paid\",\"labels\":{\"tenant_id\":\"customer_42\"},\"occurred_at\":\"2026-10-11T09:00:00Z\",\"payload\":\"{\\\"invoice_id\\\":\\\"inv_123\\\"}\",\"payload_content_type\":\"application/json\"}"
# After
curl --user "$RELAY_KEY:$RELAY_SECRET" -X POST https://my.webhookrelay.com/v1/outbound/messages \
-H 'Content-Type: application/json' -H 'Idempotency-Key: invoice-paid:inv_123' \
-d '{"consumer":"customer_42","event_type":"billing.invoice.paid","payload":{"invoice_id":"inv_123","amount":4900}}'
Add a customer and an endpoint
await relay.outbound.consumers.upsert("customer_42", { name: "Acme" });
const endpoint = await relay.outbound.endpoints.create("customer_42", {
url: "https://acme.example/webhooks",
eventTypes: ["billing.invoice.paid"],
autoDisable: true,
});
// endpoint.secret is the customer's new whsec_ secret. Store it like a password.
Go: api.UpsertOutboundConsumer(...), api.CreateOutboundEndpoint(...), api.PauseOutboundEndpoint(id), api.RetryOutboundDelivery(endpointID, messageID). MCP: upsert_outbound_consumer, create_outbound_endpoint, publish_outbound_message, pause_outbound_endpoint, retry_outbound_delivery. For a handful of customers, the dashboard's Add endpoint dialog does the same without code.
Signature changes for your customers
| Hook0 | Outbound Gateway | |
|---|---|---|
| Signature header | X-Hook0-Signature: t=<ts>,h=<header names>,v1=<hex> | webhook-signature: v1,<base64> |
| Signed content | {t}.{header names}.{header values}.{body} (or {t}.{body} without h) | {webhook-id}.{webhook-timestamp}.{body} |
| Algorithm | HMAC-SHA256, hex | HMAC-SHA256, base64 |
| Secret | Per subscription (a UUID) | A new whsec_… per endpoint (key = base64 part after whsec_). UUIDs don't fit the importable format. |
| ID header | webhook-id | |
| Event type header | webhook-event-type |
Hook0's scheme is described in Verifying Signatures. Every receiver needs Standard Webhooks verification: send your customers the receiving guide or point them at any Standard Webhooks library. During the migration window the receiver accepts either provider:
import { verifyWebhookSignature } from "hook0-client";
import { verifyWebhook } from "./verify-webhook.js"; // Standard Webhooks check from the receiving guide
export function verify(rawBody, headers) {
if (headers["webhook-signature"]) {
return verifyWebhook(rawBody, headers, process.env.WEBHOOK_SECRET_RELAY);
}
if (headers["x-hook0-signature"]) {
try {
// The receiver's existing Hook0 check; it throws on an invalid signature.
return verifyWebhookSignature(headers["x-hook0-signature"], rawBody, new Headers(headers), process.env.WEBHOOK_SECRET_HOOK0, 300);
} catch {
return false;
}
}
return false;
}
Deduplicate on webhook-id for Webhook Relay deliveries. Because each provider assigns its own IDs, include your own event ID in the payload if receivers must deduplicate across providers.
Cutover plan
- Import. Run the import script with
DRY_RUN=true, review the report, then withDRY_RUN=false. It creates event types, one consumer per customer label value and an endpoint per subscription, then pauses every new endpoint. Each endpoint gets a newwhsec_secret. Rollback: nothing is live. Leave the endpoints paused or delete them. - Shadow publish. Next to every
sendEvent(orPOST /api/v1/event/), publish to Webhook Relay with the customer label as the consumer, the same event type, the payload as JSON and the Hook0event_idas the idempotency key, behind a feature flag. Hook0 still delivers. Webhook Relay records each message asrejectedon the paused endpoints. Rollback: turn the flag off. - Receiver changes (every Hook0 customer). Both the signature scheme and the secret change, so this stage applies to every customer. Show each customer their new secret in your product (
POST /v1/outbound/endpoints/{id}/secret/reveal), send the receiving guide, and have their receiver accept either provider as shown above. Rollback: nothing to undo: receivers still verify Hook0. - Switch, one customer at a time (needs approval). Resume the customer's Webhook Relay endpoints and set
is_enabled: falseon their Hook0 subscriptions. Don't delete anything, and don't callreplay-missingfor the shadow period. Start with an internal test customer. Rollback: pause the Webhook Relay endpoints and re-enable the Hook0 subscriptions. - End dual publish. When every customer is switched and Hook0 has no deliveries left in retry, remove the Hook0 calls and SDK. Rollback: restore the calls; the Hook0 application is untouched.
- Decommission. A human closes the Hook0 application or deployment after exporting any history they need.
Verify
- Create a free Webhook Bin and add it as an endpoint of a test consumer:
BIN=$(curl -s -X POST https://bin.webhookrelay.com/v1/bins | jq -r .id) curl --user "$RELAY_KEY:$RELAY_SECRET" -X PUT https://my.webhookrelay.com/v1/outbound/consumers/migration-test \ -H 'Content-Type: application/json' -d '{"name":"Migration test"}' curl --user "$RELAY_KEY:$RELAY_SECRET" -X POST https://my.webhookrelay.com/v1/outbound/consumers/migration-test/endpoints \ -H 'Content-Type: application/json' \ -d "{\"url\":\"https://bin.webhookrelay.com/v1/webhooks/$BIN\",\"event_types\":[\"*\"]}" - Publish a test message to
migration-testthrough your new code path and read the capture withcurl -s https://bin.webhookrelay.com/v1/bins/$BIN | jq '.requests[0]'. - Paste the headers, body and that endpoint's secret into the Standard Webhooks signature verifier, then run the same request through your customers' sample receiver.
- During shadow publishing, compare per customer and event type: Hook0's events (
GET /api/v1/events/?application_id=…) againstGET /v1/outbound/messages?consumer=<id>, and therejecteddeliveries on each paused endpoint (GET /v1/outbound/endpoints/{id}/deliveries?status=rejected). - After switching a customer, watch
GET /v1/outbound/endpoints/{id}/deliveries?status=failedandGET /v1/outbound/health.
Import script
This script reads Hook0 (read-only) and creates the same configuration through @webhookrelay/sdk. Set HOOK0_CONSUMER_LABEL to the label you route customers by. Subscriptions without that label are reported and skipped. The script checks your plan limits before writing anything, doesn't read Hook0 secrets (they are UUIDs, which can't be imported, so each endpoint gets a generated secret), never prints Webhook Relay secrets, pauses every endpoint it creates and stops at the first error with its progress saved. Run it with npx tsx migrate-hook0.mts.
// migrate-hook0.mts
// Env: HOOK0_API_URL (default https://app.hook0.com/api/v1), HOOK0_TOKEN, HOOK0_APPLICATION_ID,
// HOOK0_CONSUMER_LABEL (e.g. tenant_id), RELAY_KEY, RELAY_SECRET ("Outbound: manage"), DRY_RUN=false to write.
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { WebhookRelay, WebhookRelayAPIError } from "@webhookrelay/sdk";
const HOOK0_API = process.env.HOOK0_API_URL ?? "https://app.hook0.com/api/v1";
const APP = process.env.HOOK0_APPLICATION_ID!;
const LABEL = process.env.HOOK0_CONSUMER_LABEL!;
const DRY_RUN = process.env.DRY_RUN !== "false";
const STATE_FILE = "migration-state.json"; // source subscription ID -> Webhook Relay endpoint ID. Never secrets.
const ID = /^[a-zA-Z0-9][a-zA-Z0-9_.:-]{0,127}$/;
const RESERVED = /^(webhook-|host$|content-length$|transfer-encoding$|connection$)/i;
const relay = new WebhookRelay();
type PlanEndpoint = {
sourceId: string; url: string; eventTypes: string[]; description?: string;
headers?: Record<string, string>; rate?: number; disabledAtSource?: boolean;
};
type Plan = {
eventTypes: { name: string; description?: string; deprecated?: boolean }[];
consumers: { id: string; name?: string; endpoints: PlanEndpoint[] }[];
warnings: string[];
};
// ---- 1. Export from Hook0 (read-only) ----
async function hook0<T>(path: string): Promise<T> {
const res = await fetch(`${HOOK0_API}${path}?application_id=${APP}`, {
headers: { Authorization: `Bearer ${process.env.HOOK0_TOKEN}` },
});
if (!res.ok) throw new Error(`Hook0 GET ${path}: ${res.status} ${await res.text()}`);
return res.json() as Promise<T>;
}
async function exportPlan(): Promise<Plan> {
const plan: Plan = { eventTypes: [], consumers: [], warnings: [] };
plan.eventTypes = (await hook0<any[]>("/event_types/")).map((et) => ({ name: et.event_type_name }));
const consumers = new Map<string, Plan["consumers"][number]>();
for (const sub of await hook0<any[]>("/subscriptions/")) {
// sub.secret is deliberately ignored: Hook0 secrets are UUIDs, which don't fit the
// Standard Webhooks secret format, so Webhook Relay generates a new whsec_ secret.
const labels: Record<string, string> = sub.labels ?? (sub.label_key ? { [sub.label_key]: sub.label_value } : {});
const consumerId = labels[LABEL];
if (!consumerId) {
plan.warnings.push(`subscription ${sub.subscription_id} (${sub.target?.url}) has no "${LABEL}" label; skipped`);
continue;
}
if (Object.keys(labels).length > 1) plan.warnings.push(`${consumerId} ${sub.target.url}: extra labels ${JSON.stringify(labels)} are not carried over`);
if (sub.target.type !== "http") { plan.warnings.push(`${consumerId}: target type ${sub.target.type} skipped`); continue; }
if (sub.target.method && sub.target.method.toUpperCase() !== "POST")
plan.warnings.push(`${consumerId} ${sub.target.url}: uses ${sub.target.method}; deliveries will be POST`);
const headers = Object.fromEntries(Object.entries(sub.target.headers ?? {}).filter(([k]) => !RESERVED.test(k))) as Record<string, string>;
const consumer = consumers.get(consumerId) ?? { id: consumerId, endpoints: [] as PlanEndpoint[] };
consumer.endpoints.push({
sourceId: sub.subscription_id, url: sub.target.url,
eventTypes: sub.event_types?.length ? sub.event_types : ["*"],
description: sub.description || undefined, headers, disabledAtSource: !sub.is_enabled,
});
consumers.set(consumerId, consumer);
}
plan.consumers = [...consumers.values()];
return plan;
}
// ---- 2. Check access and limits before writing anything ----
async function checkPlan(plan: Plan) {
const auth = Buffer.from(`${process.env.RELAY_KEY}:${process.env.RELAY_SECRET}`).toString("base64");
const res = await fetch("https://my.webhookrelay.com/v1/outbound/access", { headers: { Authorization: `Basic ${auth}` } });
const access = await res.json();
if (!access.enabled) throw new Error("The Outbound Gateway needs a Basic, Business or Pro plan: upgrade at https://my.webhookrelay.com/account.");
const limits = access.limits as { consumers: number; endpoints_per_consumer: number; event_types: number };
const haveConsumers = new Set((await relay.outbound.consumers.list()).map((c) => c.id));
const haveTypes = new Set((await relay.outbound.eventTypes.list()).map((e) => e.name));
const consumers = new Set([...haveConsumers, ...plan.consumers.map((c) => c.id)]).size;
const types = new Set([...haveTypes, ...plan.eventTypes.map((e) => e.name)]).size;
const problems: string[] = [];
if (consumers > limits.consumers) problems.push(`${consumers} consumers exceed the limit of ${limits.consumers}`);
if (types > limits.event_types) problems.push(`${types} event types exceed the limit of ${limits.event_types}`);
for (const et of plan.eventTypes) if (!ID.test(et.name)) problems.push(`event type "${et.name}" is not a valid name`);
for (const c of plan.consumers) {
if (!ID.test(c.id)) problems.push(`consumer "${c.id}" is not a valid ID: map it to your own customer ID`);
if (c.endpoints.length > limits.endpoints_per_consumer)
problems.push(`${c.id} has ${c.endpoints.length} endpoints, the limit is ${limits.endpoints_per_consumer}`);
for (const e of c.endpoints) {
if (!e.url.startsWith("https://")) problems.push(`${c.id} ${e.url}: endpoints must be public HTTPS`);
if (e.eventTypes.length > 100) problems.push(`${c.id} ${e.url}: more than 100 event types`);
}
}
for (const w of plan.warnings) console.warn(`warning: ${w}`);
if (problems.length) {
console.error(`Not importing:\n- ${problems.join("\n- ")}`);
process.exit(1);
}
}
// ---- 3. Import into Webhook Relay ----
const state: Record<string, string> = existsSync(STATE_FILE) ? JSON.parse(readFileSync(STATE_FILE, "utf8")) : {};
async function step<T>(what: string, fn: () => Promise<T>): Promise<T> {
for (let attempt = 1; ; attempt++) {
try {
return await fn();
} catch (err) {
if (err instanceof WebhookRelayAPIError && err.status === 429 && attempt < 5) {
await new Promise((r) => setTimeout(r, attempt * 2000));
continue;
}
console.error(`Stopped at ${what}: ${err instanceof Error ? err.message : String(err)}`);
console.error(`Progress is saved in ${STATE_FILE}. Fix the cause and run the script again.`);
process.exit(1);
}
}
}
async function importPlan(plan: Plan) {
for (const et of plan.eventTypes) {
await step(`event type ${et.name}`, () =>
relay.outbound.eventTypes.upsert(et.name, { description: et.description, deprecated: et.deprecated }));
}
for (const c of plan.consumers) {
await step(`consumer ${c.id}`, () => relay.outbound.consumers.upsert(c.id, { name: c.name }));
for (const e of c.endpoints) {
if (state[e.sourceId]) continue; // imported on an earlier run
const { id, created } = await step(`endpoint ${c.id} ${e.url}`, async () => {
try {
const ep = await relay.outbound.endpoints.create(c.id, {
url: e.url, eventTypes: e.eventTypes, description: e.description, headers: e.headers, rate: e.rate,
});
return { id: ep.id, created: true }; // ep.secret is intentionally not logged or stored
} catch (err) {
if (err instanceof WebhookRelayAPIError && err.status === 409) {
const existing = (await relay.outbound.endpoints.list(c.id)).find((x) => x.url === e.url);
if (existing) return { id: existing.id, created: false }; // already there: leave its state alone
}
throw err;
}
});
if (created) await step(`pause ${id}`, () => relay.outbound.endpoints.pause(id));
state[e.sourceId] = id;
writeFileSync(STATE_FILE, JSON.stringify(state, null, 2));
console.log(`${c.id}: ${e.url} -> ${id} ${created ? "(created, paused)" : "(already existed, unchanged)"}${e.disabledAtSource ? " [disabled at Hook0]" : ""}`);
}
}
}
const plan = await exportPlan();
await checkPlan(plan);
const endpoints = plan.consumers.reduce((n, c) => n + c.endpoints.length, 0);
console.log(`Plan: ${plan.eventTypes.length} event types, ${plan.consumers.length} consumers, ${endpoints} endpoints.`);
if (DRY_RUN) console.log("Dry run: nothing was written. Run again with DRY_RUN=false to import.");
else await importPlan(plan);
With more than a few hundred subscriptions, run the script in batches and review the output between runs. There is no bulk import endpoint.
Let your agent do it
Read https://webhookrelay.com/outbound-migration/hook0.md and follow it step by step in this repository.
- Inventory every use of the Hook0 SDK and API (sendEvent and POST /api/v1/event/ calls, subscription and event type management, secret display, receiver docs), and work out which label identifies the customer on our events. Ask me if it isn't clear.
- Check GET https://my.webhookrelay.com/v1/outbound/access: confirm enabled is true and that our customers, subscriptions and event types fit within the returned limits. Stop and tell me if they don't.
- Import event types, consumers and endpoints with the playbook's script, dry run first. Every new endpoint must be paused. Report the subscriptions it skipped.
- Add a Webhook Relay publish call next to every Hook0 event call, behind a feature flag, with the customer label as the consumer and a stable idempotency key.
- Run the playbook's verification checks with a Webhook Bin and report the results.
Never print, log or commit signing secrets. Never delete or modify Hook0 subscriptions, event types or applications. Stop and ask me for approval before resuming any Webhook Relay endpoint or disabling any Hook0 subscription.
Next steps
- Migration overview: limits, what doesn't carry over and the general cutover plan.
- Receive and verify outbound webhooks: send this to your customers with their new secret.
- Monitor, retry and recover deliveries: delivery history, endpoint health, pause and resume.
Frequently asked questions
How do Hook0 labels map to Webhook Relay consumers?
Hook0 delivers an event to the subscriptions whose labels match the event's labels, and most producers use one label, such as a tenant or customer ID, to route events to a customer. That label's value becomes the consumer ID in the Outbound Gateway, and each subscription carrying it becomes an endpoint of that consumer. You then publish with consumer set to the same value.
What do my customers change when I move from Hook0 to Webhook Relay?
Their signature check. Hook0 sends X-Hook0-Signature with a timestamp, an optional list of signed headers and a hex HMAC-SHA256. Webhook Relay sends the Standard Webhooks headers webhook-id, webhook-timestamp and webhook-signature, signed with a new whsec_ secret per endpoint. During the migration window their receiver should accept either one.
Can I import my Hook0 subscription secrets into Webhook Relay?
No. Webhook Relay imports existing secrets only in the Standard Webhooks format: 32 to 100 standard base64 characters, with or without the whsec_ prefix. Hook0 subscription secrets are UUIDs, and their hyphens are not base64, so they don't fit the format. Every migrated Hook0 subscription gets a new whsec_ secret, and since the signature scheme changes too, each customer updates their receiver once.
Can I send non-JSON payloads with the Outbound Gateway?
No. Outbound Gateway payloads are JSON values up to 256 KiB. Hook0 events that use text/plain or base64 binary payloads need to be wrapped in a JSON object, for example as a string field, and receivers need to read that field.
Do Hook0 subscriptions that use GET or PUT carry over?
Outbound Gateway deliveries are always HTTPS POST requests. Subscriptions that use another method need the receiver to accept POST before you switch them.
