DocumentationMigrate from Hook0

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

Hook0Outbound GatewayNotes
OrganizationWebhook Relay account
ApplicationWebhook Relay account (or one per environment)Migrate one application at a time.
Customer label (for example labels.tenant_id)ConsumerThe label value becomes the consumer ID (up to 128 letters, numbers, dots, colons, underscores or hyphens).
SubscriptionEndpointPublic HTTPS URL, always POST. Hook0's UUID secret doesn't fit the importable format, so the endpoint gets a new whsec_ secret.
Subscription event_typesEndpoint event_typesUp to 100 per endpoint, or ["*"].
Subscription target.headersEndpoint headersUp to 32. webhook-* headers are reserved.
Subscription is_enabled: falsePaused endpoint
Event type (service.resource.verb)Event typeSame dotted name.
Event (POST /api/v1/event/)MessageJSON payload up to 256 KiB.
event_idevent_id and the Idempotency-Key header
Extra labels used for filteringFunctionLabels other than the customer label have no equivalent. A Function can drop messages based on the payload.
payload_content_type other than JSONNo equivalentWrap the payload in a JSON object.

API map

TaskHook0Outbound Gateway
Add an endpointPOST /api/v1/subscriptions/PUT /v1/outbound/consumers/{id}, then POST /v1/outbound/consumers/{consumer}/endpoints
List endpointsGET /api/v1/subscriptions/?application_id=…GET /v1/outbound/consumers/{consumer}/endpoints
Add an event typePOST /api/v1/event_types/POST /v1/outbound/event-types
List event typesGET /api/v1/event_types/?application_id=…GET /v1/outbound/event-types
PublishPOST /api/v1/event/POST /v1/outbound/messages
Replay an eventPOST /api/v1/events/{event_id}/replayPOST /v1/outbound/endpoints/{id}/retry with message_id
Disable / enablePUT /api/v1/subscriptions/{subscription_id} with is_enabledPOST /v1/outbound/endpoints/{id}/pause / .../resume
Rotate a secretNot in the Hook0 API referencePOST /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

Hook0Outbound Gateway
Signature headerX-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}
AlgorithmHMAC-SHA256, hexHMAC-SHA256, base64
SecretPer subscription (a UUID)A new whsec_… per endpoint (key = base64 part after whsec_). UUIDs don't fit the importable format.
ID headerwebhook-id
Event type headerwebhook-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

  1. Import. Run the import script with DRY_RUN=true, review the report, then with DRY_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 new whsec_ secret. Rollback: nothing is live. Leave the endpoints paused or delete them.
  2. Shadow publish. Next to every sendEvent (or POST /api/v1/event/), publish to Webhook Relay with the customer label as the consumer, the same event type, the payload as JSON and the Hook0 event_id as the idempotency key, behind a feature flag. Hook0 still delivers. Webhook Relay records each message as rejected on the paused endpoints. Rollback: turn the flag off.
  3. 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.
  4. Switch, one customer at a time (needs approval). Resume the customer's Webhook Relay endpoints and set is_enabled: false on their Hook0 subscriptions. Don't delete anything, and don't call replay-missing for the shadow period. Start with an internal test customer. Rollback: pause the Webhook Relay endpoints and re-enable the Hook0 subscriptions.
  5. 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.
  6. Decommission. A human closes the Hook0 application or deployment after exporting any history they need.

Verify

  1. 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\":[\"*\"]}"
    
  2. Publish a test message to migration-test through your new code path and read the capture with curl -s https://bin.webhookrelay.com/v1/bins/$BIN | jq '.requests[0]'.
  3. 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.
  4. During shadow publishing, compare per customer and event type: Hook0's events (GET /api/v1/events/?application_id=…) against GET /v1/outbound/messages?consumer=<id>, and the rejected deliveries on each paused endpoint (GET /v1/outbound/endpoints/{id}/deliveries?status=rejected).
  5. After switching a customer, watch GET /v1/outbound/endpoints/{id}/deliveries?status=failed and GET /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

Migrate our outbound webhooks from Hook0 to Webhook Relay
Paste into Claude Code, Cursor, Codex or any agent with shell and web access.

Read https://webhookrelay.com/outbound-migration/hook0.md and follow it step by step in this repository.

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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

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.

Did this page help you?