# Migrate outbound webhooks from Hook0 to Webhook Relay — agent playbook

Machine-readable playbook for coding agents (Claude Code, Cursor, Codex, ...).
Follow the steps in order. The human-readable guide is
https://webhookrelay.com/docs/outbound/migrate/hook0
(Markdown: https://webhookrelay.com/docs/outbound/migrate/hook0.md).

- Webhook Relay API base URL: `https://my.webhookrelay.com`, HTTP Basic `RELAY_KEY:RELAY_SECRET`
- Hook0 API base URL: `https://app.hook0.com/api/v1` (or your self-hosted host).
  Auth: `Authorization: Bearer $HOOK0_TOKEN`. Most lists take `?application_id=<uuid>`.
- Mapping: the label that identifies a customer (e.g. `labels.tenant_id`) → consumer,
  subscription → endpoint, event type (`service.resource.verb`) → event type,
  event → message.
- Signature change: `X-Hook0-Signature: t=<ts>,h=<header names>,v1=<hex HMAC-SHA256>`
  → Standard Webhooks (`webhook-id`, `webhook-timestamp`, `webhook-signature`). Every receiver changes.
- **Secrets: generate new ones.** Webhook Relay can import an existing secret on
  endpoint create (optional `secret`: 32-100 standard base64 chars, `whsec_`
  optional), but Hook0 subscription secrets are UUIDs, whose hyphens are not
  base64, so they don't fit the format. Create every endpoint without `secret`;
  each gets a new `whsec_` secret. Customers accept either provider's signature
  during the window (the scheme changes anyway).
- Webhook Relay payloads are JSON (≤ 256 KiB), always delivered with `POST`.

**STOP AND ASK THE HUMAN** before step 9 (switching traffic).

---

## 0. Preconditions

| Variable | What |
| --- | --- |
| `RELAY_KEY`, `RELAY_SECRET` | Webhook Relay access token restricted to **Outbound: manage** |
| `HOOK0_TOKEN`, `HOOK0_APPLICATION_ID` | Hook0 service token and application ID |
| `HOOK0_API_URL` | Optional, default `https://app.hook0.com/api/v1` |
| `HOOK0_CONSUMER_LABEL` | The label key that identifies a customer. Find it in step 1; ask the human if unclear. |

```bash
curl -s --user "$RELAY_KEY:$RELAY_SECRET" https://my.webhookrelay.com/v1/outbound/access
# {"enabled":true,"limits":{"consumers":100,"endpoints_per_consumer":10,"event_types":300}}
```

`enabled: false` → stop. The account is on the Free plan: tell the human to
upgrade to Basic or above at https://my.webhookrelay.com/account. Every other
`/v1/outbound` route returns `403` until then. Keep `limits` for step 3. MCP
equivalent: `get_outbound_access`. Plan limits: Basic 100 consumers / 10
endpoints each / 300 event types; Business 500 / 25 / 500; Pro 1,000 / 100 / 1,000.

---

## 1. Inventory the codebase

| Language | Package / import |
| --- | --- |
| Node / TypeScript | `hook0-client` (npm) — `import { Hook0Client, Event } from "hook0-client"` |
| Rust | `hook0-client` crate |
| Python | `hook0-client` (PyPI) |
| Any | direct calls to `app.hook0.com`, `/api/v1/event/` |

```bash
rg -n --hidden -g '!node_modules' -g '!vendor' -g '!target' \
  -e 'hook0-client' -e 'hook0_client' -e 'Hook0Client' -e 'app\.hook0\.com' \
  -e '/api/v1/event/' -e 'sendEvent' -e 'X-Hook0-Signature'
```

| Kind | Typical call | Becomes |
| --- | --- | --- |
| Publish | `hook0.sendEvent(new Event(type, JSON.stringify(payload), "application/json", labels))` / `POST /api/v1/event/` | `relay.outbound.messages.publish({ consumer: labels[LABEL], eventType: type, payload }, { idempotencyKey })` |
| Non-JSON payload | `payload_content_type` `text/plain` or `application/octet-stream+base64` | Wrap in JSON (e.g. `{ "data": "<string>" }`) — report |
| Event type catalog | `POST /api/v1/event_types/` (`service`, `resource_type`, `verb`) | `relay.outbound.eventTypes.upsert("service.resource.verb", {})` |
| Subscription CRUD | `POST/PUT/DELETE /api/v1/subscriptions/` | `relay.outbound.endpoints.create / update / delete` |
| Extra routing labels | labels beyond the customer label | No equivalent — report (Functions can drop messages) |
| Receiver docs | `X-Hook0-Signature` | link https://webhookrelay.com/docs/outbound/receiving |

---

## 2. Export from Hook0 (read-only)

| Data | Call | Fields used |
| --- | --- | --- |
| Event types | `GET /api/v1/event_types/?application_id=<id>` | `event_type_name` |
| Subscriptions | `GET /api/v1/subscriptions/?application_id=<id>` | `subscription_id`, `labels` (or legacy `label_key`/`label_value`), `event_types`, `target.{type,method,url,headers}`, `description`, `is_enabled` |

**Do NOT read or copy** subscription `secret` values. They are UUIDs and can't
be imported, so there is no reason to read them.

Mapping:

- consumer ID = `labels[HOOK0_CONSUMER_LABEL]` (must match `^[a-zA-Z0-9][a-zA-Z0-9_.:-]{0,127}$`).
  Subscriptions without that label → skip and report.
- `event_types` = subscription `event_types`, or `["*"]` if empty
- `target.headers` → `headers`, minus `webhook-*`, `host`, `content-length`,
  `transfer-encoding`, `connection`
- `target.method` other than `POST` → report (deliveries will be `POST`)
- `is_enabled: false` → import (paused anyway) and flag

---

## 3. Check against plan limits (before writing anything)

```bash
curl -s --user "$RELAY_KEY:$RELAY_SECRET" https://my.webhookrelay.com/v1/outbound/consumers
curl -s --user "$RELAY_KEY:$RELAY_SECRET" https://my.webhookrelay.com/v1/outbound/event-types
```

Stop and report if: consumers (existing ∪ new) > `limits.consumers`; event types
(existing ∪ new) > `limits.event_types`; any customer has more subscriptions than
`limits.endpoints_per_consumer`; any endpoint > 100 event types; any URL not
`https://`; any ID fails the pattern; any payload can exceed 256 KiB (Hook0 allows more).

---

## 4. Import into Webhook Relay

Use the script under "Import script" in
https://webhookrelay.com/docs/outbound/migrate/hook0.md, saved as `migrate-hook0.mts`:

```bash
npm install @webhookrelay/sdk
DRY_RUN=true  npx tsx migrate-hook0.mts
DRY_RUN=false npx tsx migrate-hook0.mts   # resumable via migration-state.json
```

By hand:

| Step | REST | TypeScript `@webhookrelay/sdk` | MCP |
| --- | --- | --- | --- |
| Event type | `PUT /v1/outbound/event-types/{name}` | `relay.outbound.eventTypes.upsert(name, {})` | `save_outbound_event_type` |
| Consumer | `PUT /v1/outbound/consumers/{id}` | `relay.outbound.consumers.upsert(id, {})` | `upsert_outbound_consumer` |
| Endpoint | `POST /v1/outbound/consumers/{consumer}/endpoints` `{"url":"...","event_types":[...],"headers":{...}}` | `relay.outbound.endpoints.create(consumer, { url, eventTypes, headers })` | `create_outbound_endpoint` |
| Pause | `POST /v1/outbound/endpoints/{id}/pause` | `relay.outbound.endpoints.pause(id)` | `pause_outbound_endpoint` |

Rules: create endpoints **without** `secret` (Hook0's UUID secrets don't fit the
format); pause every endpoint right after creating it; never print/log/store the
`secret` from the create response; `409` = URL exists → look it up, record it,
don't change its state; `429` → retry; `403` → limit or permission, stop; anything
else → stop and report. No bulk import endpoint: batch large imports.

Check: `GET /v1/outbound/endpoints?state=paused&limit=100` lists every imported endpoint.

---

## 5. Rewrite the code (dual publish behind a flag)

Before:

```ts
await hook0.sendEvent(new Event("billing.invoice.paid", JSON.stringify(payload), "application/json", { tenant_id: tenantId }));
```

After:

```ts
import { WebhookRelay } from "@webhookrelay/sdk";
const relay = new WebhookRelay(); // "Outbound: publish only" token

await hook0.sendEvent(new Event("billing.invoice.paid", JSON.stringify(payload), "application/json", { tenant_id: tenantId }));
if (flags.relayOutbound) {
  await relay.outbound.messages.publish(
    { consumer: tenantId, eventType: "billing.invoice.paid", payload },
    { idempotencyKey: `invoice-paid:${payload.invoice_id}` }, // stable key derived from your event
  );
}
```

Go, after:

```go
if flags.RelayOutbound {
	_, err := api.PublishOutboundMessage(&webhookrelay.OutboundMessagePublishOptions{
		Consumer: tenantID, EventType: "billing.invoice.paid", Payload: payload, IdempotencyKey: key,
	})
	if err != nil {
		log.Printf("webhook relay publish failed: %v", err) // must not break the Hook0 path
	}
}
```

REST: `POST https://my.webhookrelay.com/v1/outbound/messages`, Basic auth,
`Idempotency-Key: <key>`, body `{"consumer":"<label value>","event_type":"<type>","payload":{...}}` → `202`.

Also update subscription management code to write to both providers while the
flag is on (new Webhook Relay endpoints stay paused until that customer is
switched), and show the new secret (`relay.outbound.endpoints.revealSecret(id)`)
with a link to https://webhookrelay.com/docs/outbound/receiving.

---

## 6. Customer receiver change (to communicate)

```js
import { verifyWebhookSignature } from "hook0-client";
import { verifyWebhook } from "./verify-webhook.js"; // https://webhookrelay.com/docs/outbound/receiving#nodejs
export function verify(rawBody, headers) {
  if (headers["webhook-signature"]) return verifyWebhook(rawBody, headers, process.env.WEBHOOK_SECRET_RELAY);
  if (headers["x-hook0-signature"]) {
    try { return verifyWebhookSignature(headers["x-hook0-signature"], rawBody, new Headers(headers), process.env.WEBHOOK_SECRET_HOOK0, 300); }
    catch { return false; }
  }
  return false;
}
```

Hook0 scheme: https://documentation.hook0.com/docs/verifying-webhook-signatures

---

## 7. Verify

1. Webhook Bin + test consumer (test data only):

   ```bash
   BIN=$(curl -s -X POST https://bin.webhookrelay.com/v1/bins | jq -r .id)
   curl -s --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 -s --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\":[\"*\"]}" | jq '{id, state}'
   ```

2. Publish through the new code path with `consumer=migration-test`; check
   `curl -s "https://bin.webhookrelay.com/v1/bins/$BIN" | jq '.requests[0].header | keys'`
   shows `Webhook-Id`, `Webhook-Timestamp`, `Webhook-Signature`.
3. Verify the signature with a Standard Webhooks library (or the human pastes it into
   https://webhookrelay.com/verify-standard-webhooks-signature). Don't print the secret.
4. In shadow mode, compare per customer and event type over the same window: Hook0
   `GET /api/v1/events/?application_id=<id>` vs `GET /v1/outbound/messages?consumer=<id>`,
   and `GET /v1/outbound/endpoints/{id}/deliveries?status=rejected` on each paused endpoint.

---

## 8. Report and STOP

Report: inventory, the customer label used, import counts, skipped subscriptions,
non-POST targets, non-JSON payloads, verification results, per-customer switch
plan. **Wait for approval.**

---

## 9. Cutover (REQUIRES HUMAN APPROVAL, one customer at a time)

1. Confirm the customer's receiver accepts Webhook Relay's signature.
2. Resume their Webhook Relay endpoints: `POST /v1/outbound/endpoints/{id}/resume`.
3. Disable their Hook0 subscriptions: `PUT /api/v1/subscriptions/{subscription_id}` with
   the subscription's current fields and `"is_enabled": false`.
4. Do not call `replay-missing` for the shadow period.
5. Watch `GET /v1/outbound/endpoints/{id}/deliveries?status=failed` and `GET /v1/outbound/health`.

After every customer is switched, Hook0 has nothing left in retry, and the human
approves: remove the Hook0 calls and SDK.

## 10. Rollback

| Stage | Rollback |
| --- | --- |
| Import | Nothing is live. Leave endpoints paused (or delete with approval). |
| Dual publish | Turn the flag off. |
| Cutover | Pause the Webhook Relay endpoints; set `"is_enabled": true` on the Hook0 subscriptions. |
| After removing Hook0 code | Revert the commit; the Hook0 application is untouched. |

---

## Never do

- Never delete or modify Hook0 applications, subscriptions or event types (only the
  approved `is_enabled: false` toggle in step 9).
- Never read, print, log, write to files or commit signing secrets or API tokens.
- Never resume a Webhook Relay endpoint or disable a Hook0 subscription without
  explicit approval for that customer.
- Never exceed plan limits silently: check `GET /v1/outbound/access` first.
- Never call `replay-missing` or `recover` for the shadow period.
- Never let both providers deliver live to the same customer at the same time.
- Never publish real customer data to a Webhook Bin.
- Never pass a Hook0 UUID secret as `secret`: it is refused (`400`) and must not
  appear in logs or files.
- Never claim features that don't exist: consumer portal, non-HTTP destinations.
