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

Machine-readable playbook for coding agents (Claude Code, Cursor, Codex, ...).
Follow the steps in order. Every step lists the exact calls and a check. The
human-readable guide is https://webhookrelay.com/docs/outbound/migrate/svix
(Markdown: https://webhookrelay.com/docs/outbound/migrate/svix.md).

- Webhook Relay API base URL: `https://my.webhookrelay.com`
- Webhook Relay auth: HTTP Basic with `RELAY_KEY:RELAY_SECRET` (an access token key and secret)
- Svix API base URL: your region, e.g. `https://api.us.svix.com` or `https://api.eu.svix.com`
- Svix auth: `Authorization: Bearer $SVIX_TOKEN`
- Webhook Relay signs deliveries with Standard Webhooks (`webhook-id`,
  `webhook-timestamp`, `webhook-signature`), the same scheme Svix uses under
  `svix-*` header names. Svix's verification libraries accept both names.
- **Import each endpoint's Svix secret.** Endpoint create accepts an optional
  `secret` in Svix's own format (32-100 standard base64 characters, `whsec_`
  optional). Read it from Svix and pass it through, so customers keep verifying
  with their current key. Receivers using a Svix or Standard Webhooks library
  need **no change**. Only endpoints on asymmetric `whsk_` keys (refused) get a
  new secret.
- Cutover: import secrets → dual publish → switch.

**STOP AND ASK THE HUMAN** before step 9 (switching traffic). Steps 1–8 never
change what customers receive.

---

## 0. Preconditions

Required environment variables (ask the human if any is missing; never ask
them to paste secrets into the chat if they can set env vars instead):

| Variable | What |
| --- | --- |
| `RELAY_KEY`, `RELAY_SECRET` | Webhook Relay access token restricted to **Outbound: manage** (for the import) |
| `SVIX_TOKEN` | Svix API key for the environment being migrated (read access is enough, including endpoint secrets) |
| `SVIX_API_URL` | Svix API base URL for that environment's region |

Check that the account has the Outbound Gateway (Basic plan or above) and read the plan limits:

```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, Business or Pro at https://my.webhookrelay.com/account.
  Every other `/v1/outbound` route returns `403` until then.
- Keep `limits` for step 3. Plan limits: Basic 100 consumers / 10 endpoints each /
  300 event types; Business 500 / 25 / 500; Pro 1,000 / 100 / 1,000 (the platform
  ceiling). 100 event types per endpoint.

MCP alternative: if a Webhook Relay MCP server is connected
(`https://my.webhookrelay.com/v1/mcp`), `get_outbound_access` returns the same.

---

## 1. Inventory the codebase

Find every Svix touchpoint. Search for these packages and imports:

| Language | Package / import |
| --- | --- |
| Node / TypeScript | `svix` (npm) — `from "svix"`, `require("svix")` |
| Go | `github.com/svix/svix-webhooks/go`, `github.com/svix/svix-webhooks/v2/go` |
| Python | `svix` (PyPI) — `from svix`, `import svix` |
| Ruby | `svix` gem — `Svix::` |
| Java / Kotlin | `com.svix` |
| PHP | `svix/svix` (Composer) |
| C# | `Svix` (NuGet) |
| Rust | `svix` crate |
| Any | direct calls to `api.svix.com`, `api.us.svix.com`, `api.eu.svix.com`, `/api/v1/app/` |

```bash
rg -n --hidden -g '!node_modules' -g '!vendor' \
  -e 'from ["'"'"']svix' -e 'require\(["'"'"']svix' -e 'svix-webhooks' -e 'import svix' -e 'Svix::' \
  -e 'com\.svix' -e 'svix/svix' -e 'api(\.us|\.eu)?\.svix\.com' -e 'svix-(id|timestamp|signature)'
```

Classify each hit:

| Kind | Typical call | Becomes |
| --- | --- | --- |
| Publish | `svix.message.create(appId, { eventType, payload })`, `Message.Create(ctx, appId, ...)` | `relay.outbound.messages.publish(...)` / `api.PublishOutboundMessage(...)` |
| Customer create | `svix.application.create / getOrCreate` | `relay.outbound.consumers.upsert(id, { name })` |
| Endpoint CRUD | `svix.endpoint.create / update / delete` | `relay.outbound.endpoints.create / update / delete` |
| Secret display | `svix.endpoint.getSecret` | `relay.outbound.endpoints.revealSecret(id)` (returns the imported secret) |
| Portal | `svix.authentication.appPortalAccess` | **No equivalent** — report to the human |
| Event type catalog | `svix.eventType.create` | `relay.outbound.eventTypes.upsert(name, ...)` |
| Receiver docs for customers | mentions of `svix-signature` | link https://webhookrelay.com/docs/outbound/receiving |

Write the inventory (file, line, kind) into your report. Do not change code yet.

---

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

All calls are `GET` with `Authorization: Bearer $SVIX_TOKEN`. Lists are paginated:
`?limit=100&iterator=<iterator>` until the response has `"done": true`.

| Data | Call | Fields used |
| --- | --- | --- |
| Event types | `GET /api/v1/event-type` | `name`, `description`, `deprecated`, `archived` (skip archived) |
| Applications | `GET /api/v1/app` | `id`, `uid`, `name` |
| Endpoints | `GET /api/v1/app/{app_id}/endpoint` | `id`, `url`, `eventTypes` (empty = all), `description`, `throttleRate`, `disabled`, `channels` |
| Endpoint headers | `GET /api/v1/app/{app_id}/endpoint/{endpoint_id}/headers` | `headers`; `sensitive` lists redacted names |
| Endpoint secret | `GET /api/v1/app/{app_id}/endpoint/{endpoint_id}/secret` | `key` (`whsec_…`). Keep it in memory only |

Read each endpoint's secret inside the import process and pass it straight to
the endpoint create call. Never print it, log it, write it to the plan, state
file or report, or paste it into the chat. Svix SDK equivalent:
`svix.endpoint.getSecret(appId, endpointId)` → `{ key }`.

Mapping:

- consumer ID = `app.uid ?? app.id` (must match `^[a-zA-Z0-9][a-zA-Z0-9_.:-]{0,127}$`;
  if not, ask the human for a mapping to their customer IDs)
- endpoint `event_types` = `eventTypes` or `["*"]` when empty
- endpoint `rate` = `min(throttleRate, 1000)` when set
- endpoint `headers` = Svix headers minus names starting with `webhook-` and
  minus `host`, `content-length`, `transfer-encoding`, `connection`. Redacted
  (`sensitive`) headers must be re-entered by the human. Report them.
- `channels` have no equivalent. Report them.
- `disabled: true` → still import (it will be paused anyway); flag it in the report.
- endpoint `secret` = `key` if it matches `^(whsec_)?[A-Za-z0-9+/=]{32,100}$`
  (Svix's symmetric format). A `whsk_` / `whpk_` key (asymmetric signing) can't
  be imported: omit `secret` and report the endpoint ID (never the key) as
  "new secret: receiver must accept both during the window".

---

## 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
```

Fail the step (do not import, report to the human) if any of these is true:

- existing consumers ∪ new consumers > `limits.consumers`
- existing event types ∪ new event types > `limits.event_types`
- any consumer has more endpoints than `limits.endpoints_per_consumer`
- any endpoint subscribes to more than 100 event types
- any endpoint URL is not `https://`, or any ID/name fails the pattern above
- any payload your code publishes can exceed 256 KiB (Svix allows up to 1 MiB)

Never silently drop customers or endpoints to fit a limit.

---

## 4. Import into Webhook Relay

Use the import script from the guide (section "Import script" in
https://webhookrelay.com/docs/outbound/migrate/svix.md). Save it as
`migrate-svix.mts`, then:

```bash
npm install @webhookrelay/sdk
DRY_RUN=true  npx tsx migrate-svix.mts   # export + limit check only
DRY_RUN=false npx tsx migrate-svix.mts   # writes; safe to re-run after a failure
```

Do not commit `migration-state.json` without checking it contains only IDs.

Equivalent calls if you do it by hand (REST / TypeScript SDK / MCP tool):

| Step | REST | TypeScript `@webhookrelay/sdk` | MCP |
| --- | --- | --- | --- |
| Event type | `PUT /v1/outbound/event-types/{name}` `{"description":"..."}` | `relay.outbound.eventTypes.upsert(name, { description })` | `save_outbound_event_type` |
| Consumer | `PUT /v1/outbound/consumers/{id}` `{"name":"..."}` | `relay.outbound.consumers.upsert(id, { name })` | `upsert_outbound_consumer` |
| Endpoint (with imported secret) | `POST /v1/outbound/consumers/{consumer}/endpoints` `{"url":"...","event_types":[...],"headers":{...},"rate":0,"secret":"whsec_..."}` | Not yet: the SDKs add a `secret` option in their next release. Use the REST call | `create_outbound_endpoint` (accepts `secret`) |
| Pause it | `POST /v1/outbound/endpoints/{id}/pause` | `relay.outbound.endpoints.pause(id)` | `pause_outbound_endpoint` |

Rules:

- **Pause every endpoint right after creating it.**
- Pass the Svix `key` as `secret`. The create response echoes `secret`: read
  only `id` from it. Do not print, log, store in files or commit any secret.
- `400` on a create that carried `secret` → the secret was refused (the error
  never echoes it). Stop and report the endpoint ID; don't retry without
  `secret` unless the human approves giving that customer a new secret.
- `409` on endpoint create = that URL already exists for the consumer. Look it up
  with `GET /v1/outbound/consumers/{consumer}/endpoints`, record its ID, and do
  **not** change its state (it may already be live).
- `429` → wait and retry the same call. `403` → limit reached or token lacks
  permission: stop and report. Any other error → stop and report; the state file
  lets you resume.
- There is no bulk import endpoint. For hundreds of endpoints, import in batches.

Check: for every Svix endpoint there is a Webhook Relay endpoint in state `paused`,
and every endpoint except the reported asymmetric ones was created with its Svix secret:

```bash
curl -s --user "$RELAY_KEY:$RELAY_SECRET" "https://my.webhookrelay.com/v1/outbound/endpoints?state=paused&limit=100"
```

---

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

Add the Webhook Relay call next to the Svix call. Keep the Svix call. Use the same
idempotency key in both. Gate the new call with a feature flag (default off).

TypeScript, before:

```ts
await svix.message.create(appId, { eventType: "invoice.paid", payload }, { idempotencyKey: key });
```

TypeScript, after (dual publish):

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

await svix.message.create(appId, { eventType: "invoice.paid", payload }, { idempotencyKey: key });
if (flags.relayOutbound) {
  await relay.outbound.messages.publish(
    { consumer: appUid, eventType: "invoice.paid", payload },
    { idempotencyKey: key },
  );
}
```

Go, after:

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

REST (any language): `POST https://my.webhookrelay.com/v1/outbound/messages`, Basic
auth, header `Idempotency-Key: <key>`, body
`{"consumer":"<id>","event_type":"<type>","payload":{...}}` → `202` with a receipt.

Also:

- Endpoint management code (when a customer adds/removes an endpoint): write to
  both providers while the flag is on; new Webhook Relay endpoints created during
  the window must also be paused until that customer is switched.
- A failure of the Webhook Relay call must never break the Svix call during the window.
- The customer-facing webhook settings page can keep showing the same secret;
  after the switch, read it with `revealSecret(id)` instead of
  `svix.endpoint.getSecret`. Update customer docs that mention `svix-*` headers
  to the `webhook-*` names (https://webhookrelay.com/docs/outbound/receiving).

---

## 6. Customer receiver change (usually none)

- Receivers that verify with a Svix or Standard Webhooks library: **no change**.
  The secret is the same (imported) and the libraries read `webhook-*` headers
  (https://docs.svix.com/receiving/verifying-payloads/how-manual).
- Receivers that read `svix-*` headers by name must read `webhook-*` as well,
  before that customer is switched.
- Only endpoints that got a new secret (asymmetric `whsk_` at Svix) need a
  receiver that accepts either secret for the window. With a Svix library:

```js
import { Webhook } from "svix";
const verifiers = [process.env.WEBHOOK_SECRET_RELAY, process.env.WEBHOOK_SECRET_SVIX].filter(Boolean).map((s) => new Webhook(s));
export function verify(rawBody, headers) {
  for (const wh of verifiers) { try { return wh.verify(rawBody, headers); } catch {} }
  throw new Error("invalid signature");
}
```

---

## 7. Verify

1. Create a Webhook Bin and a test consumer endpoint with a test-only secret in
   Svix's format, to prove the import path (test data only — bins are public):

   ```bash
   BIN=$(curl -s -X POST https://bin.webhookrelay.com/v1/bins | jq -r .id)
   export TEST_SECRET="whsec_$(openssl rand -base64 24)"   # test-only; never print it
   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"}'
   jq -n --arg url "https://bin.webhookrelay.com/v1/webhooks/$BIN" --arg secret "$TEST_SECRET" \
     '{url: $url, event_types: ["*"], secret: $secret}' |
   curl -s --user "$RELAY_KEY:$RELAY_SECRET" -X POST https://my.webhookrelay.com/v1/outbound/consumers/migration-test/endpoints \
     -H 'Content-Type: application/json' --data @- | jq '{id, state}'
   ```

2. Publish through the new code path with `consumer=migration-test`, then:

   ```bash
   curl -s "https://bin.webhookrelay.com/v1/bins/$BIN" | jq '.requests[0].header | keys'
   ```

   Check: `Webhook-Id`, `Webhook-Timestamp`, `Webhook-Signature` are present.
3. Verify the signature locally with `TEST_SECRET` and a Svix library (`new Webhook(TEST_SECRET).verify(body, headers)`),
   exactly as customers do. It must pass with no receiver change. Or ask the human
   to paste the request into https://webhookrelay.com/verify-standard-webhooks-signature.
4. With the flag on in shadow mode, compare per consumer and event type over the same window:
   - Svix: `GET /api/v1/app/{app_id}/msg`
   - Webhook Relay: `GET /v1/outbound/messages?consumer=<id>&limit=100`
   - Each paused endpoint: `GET /v1/outbound/endpoints/{id}/deliveries?status=rejected`
   Counts must match. Report mismatches with examples.

---

## 8. Report and STOP

Report to the human: inventory, import results (counts, imported vs new secrets
by endpoint ID, warnings: redacted headers, channels, disabled endpoints, invalid
IDs), customers whose code reads `svix-*` headers by name (if known), code changes,
verification results, and the per-customer switch plan. **Wait for explicit approval** before step 9.

---

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

For an approved customer (start with an internal test customer):

1. Only for customers that read `svix-*` headers by name or got a new secret:
   confirm their receiver change is live (ask the human). Library users need nothing.
2. Resume each of the customer's Webhook Relay endpoints:
   `POST /v1/outbound/endpoints/{id}/resume` (`relay.outbound.endpoints.resume(id)`, MCP `resume_outbound_endpoint`).
3. Disable the matching Svix endpoint: `PATCH /api/v1/app/{app_id}/endpoint/{endpoint_id}` with `{"disabled": true}`.
4. Do **not** call `replay-missing` for the shadow period (Svix delivered those).
5. Watch `GET /v1/outbound/endpoints/{id}/deliveries?status=failed` and `GET /v1/outbound/health`.

When all customers are switched and Svix has no deliveries left in retry, and the
human approves, remove the Svix publish call and the `svix` dependency.

## 10. Rollback

| Stage | Rollback |
| --- | --- |
| Import (4) | Nothing is live. Leave endpoints paused (or delete them with approval). |
| Dual publish (5) | Turn the feature flag off. |
| Cutover (9) | Pause the customer's Webhook Relay endpoints (`POST /v1/outbound/endpoints/{id}/pause`) and re-enable the Svix endpoint (`PATCH ... {"disabled": false}`). |
| After removing Svix code | Revert the commit; Svix applications and endpoints are untouched. |

---

## Never do

- Never delete or modify Svix applications, endpoints or event types (only the
  approved `disabled: true` toggle in step 9).
- Never print, log, write to files, paste into chat or commit any signing secret
  (Svix `key`, `whsec_…`) or API token. Secrets go from the Svix response straight
  into the Webhook Relay create call.
- Never resume a Webhook Relay endpoint or disable a Svix endpoint without the
  human's explicit approval for that customer.
- Never exceed plan limits silently: check `GET /v1/outbound/access` first and
  stop if the migration doesn't fit.
- 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 claim features that don't exist: consumer portal, non-HTTP
  destinations, asymmetric (`whsk_`) signing.
