# Migrate outbound webhooks from Convoy 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/convoy
(Markdown: https://webhookrelay.com/docs/outbound/migrate/convoy.md).

- Webhook Relay API base URL: `https://my.webhookrelay.com`, HTTP Basic `RELAY_KEY:RELAY_SECRET`
- Convoy API: Convoy Cloud `https://us.getconvoy.cloud/api` or `https://eu.getconvoy.cloud/api`,
  or your self-hosted host; routes under `/v1/projects/{projectID}/...`.
  Auth: `Authorization: Bearer $CONVOY_API_KEY`. Responses are wrapped: `{ "data": ... }`.
- Mapping: endpoint `owner_id` → consumer (endpoints without one become one consumer each),
  endpoint → endpoint, subscription `filter_config.event_types` → endpoint `event_types`,
  event type → event type, fan-out event → publish.
- Signature change: Convoy `X-Convoy-Signature` (hash and encoding from project
  settings; simple or advanced `t=...,v1=...` format) → Standard Webhooks
  (`webhook-id`, `webhook-timestamp`, `webhook-signature`). Every receiver changes.
- **Signing secrets are imported where the format allows.** Read each endpoint's
  current secret from Convoy (`secrets[]`, the entry without `expires_at`) and
  pass it as `secret` when you create the Webhook Relay endpoint. Accepted
  format: 32-100 standard base64 characters (padded or not), `whsec_` prefix
  optional. Convoy's auto-generated secrets (25 characters from
  `0-9a-zA-Z_-`) are too short and don't fit, so expect most endpoints to get a
  new `whsec_` secret; secrets the human set themselves may fit. Report each
  endpoint as "imported" or "new secret" by ID.
- Every receiver changes its verification code (scheme change) and accepts
  either provider's signature during the window. With an imported secret it
  keeps the same secret value: a Standard Webhooks library base64-decodes the
  part after `whsec_`, and Webhook Relay signs with that decoded key.
- This playbook covers outgoing projects only. Convoy sources (incoming webhooks)
  map to Webhook Relay buckets, a separate feature.

**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** |
| `CONVOY_URL` | Convoy API base URL (e.g. `https://us.getconvoy.cloud/api`) |
| `CONVOY_API_KEY`, `CONVOY_PROJECT_ID` | Convoy project API key and ID |

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

---

## 1. Inventory the codebase

| Language | Package / import |
| --- | --- |
| Node / TypeScript | `convoy.js` (npm) — `require("convoy.js")`, `from "convoy.js"` |
| Go | `github.com/frain-dev/convoy-go` (`/v2`) |
| Python | `convoy-python` (PyPI) |
| Any | direct calls to `getconvoy.cloud`, `/v1/projects/` |

```bash
rg -n --hidden -g '!node_modules' -g '!vendor' \
  -e 'convoy\.js' -e 'frain-dev/convoy-go' -e 'convoy-python' -e 'getconvoy\.cloud' \
  -e '/v1/projects/' -e 'createFanOutEvent|createBroadcastEvent|FanoutEvent' -e 'X-Convoy-Signature'
```

| Kind | Typical call | Becomes |
| --- | --- | --- |
| Fan-out | `convoy.events.createFanOutEvent({ owner_id, event_type, data, idempotency_key })` / `c.Events.FanoutEvent(...)` / `POST .../events/fanout` | `relay.outbound.messages.publish({ consumer: owner_id, eventType, payload }, { idempotencyKey })` |
| Single endpoint | `convoy.events.create({ endpoint_id, event_type, data })` / `POST .../events` | publish for that endpoint's consumer |
| Broadcast | `convoy.events.createBroadcastEvent(...)` / `POST .../events/broadcast` | one publish per consumer (no broadcast call) |
| Endpoint + subscription CRUD | `POST .../endpoints`, `POST .../subscriptions` | `relay.outbound.endpoints.create(consumer, { url, eventTypes })` |
| Per-event `custom_headers` | | No per-message headers: move values into the payload |
| Portal links | | **No equivalent** — report |
| Receiver docs | `X-Convoy-Signature` | link https://webhookrelay.com/docs/outbound/receiving |

---

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

All `GET` under `$CONVOY_URL/v1/projects/$CONVOY_PROJECT_ID`. Paginated lists:
`?perPage=100&direction=next&next_page_cursor=<cursor>` until
`data.pagination.has_next_page` is false; items are in `data.content`.

| Data | Call | Fields used |
| --- | --- | --- |
| Event types | `GET /event-types` → `data: [...]` | `name`, `description`, `deprecated_at` (may not exist on older versions; then derive from subscriptions) |
| Endpoints | `GET /endpoints` | `uid`, `owner_id`, `name`, `url`, `description`, `status` (`active`/`inactive`/`paused`), `authentication`, `secrets[]` (`value`, `expires_at`, `deleted_at`) |
| Subscriptions per endpoint | `GET /subscriptions?endpointId=<uid>` | `filter_config.event_types`, `filter_config.filter`, `function` |

Read the current secret value (the `secrets[]` entry with no `deleted_at` and no
`expires_at`; else the last live entry) into memory only, to pass it to Webhook
Relay in step 4. Never print, log, write to a file or commit a secret value.

Mapping:

- consumer ID = `owner_id || name || uid` (must match `^[a-zA-Z0-9][a-zA-Z0-9_.:-]{0,127}$`;
  otherwise ask the human for a mapping)
- `event_types` = union of the endpoint's subscriptions' `filter_config.event_types`; `*` or none → `["*"]`
- body/header filters, subscription functions, endpoint `authentication` → no
  automatic equivalent; report (Functions can drop or reshape; auth becomes a
  custom header the human re-enters)
- `status != "active"` → import (paused anyway) and flag
- current secret → endpoint `secret` if, after stripping an optional `whsec_`,
  it matches `^[A-Za-z0-9+/]{32,100}={0,2}$` (max 100 characters including
  padding, valid base64 length); otherwise omit `secret` and report "new
  secret" for that endpoint ID

---

## 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 owner has more endpoints 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.

---

## 4. Import into Webhook Relay

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

```bash
npm install @webhookrelay/sdk
DRY_RUN=true  npx tsx migrate-convoy.mts
DRY_RUN=false npx tsx migrate-convoy.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, { description })` | `save_outbound_event_type` |
| Consumer | `PUT /v1/outbound/consumers/{id}` | `relay.outbound.consumers.upsert(id, { name })` | `upsert_outbound_consumer` |
| Endpoint | `POST /v1/outbound/consumers/{consumer}/endpoints` `{"url":"...","event_types":[...],"secret":"<imported, optional>"}` | `relay.outbound.endpoints.create(consumer, { url, eventTypes })` without a secret; use REST for `secret` (the SDKs add a `secret` option in their next release) | `create_outbound_endpoint` (accepts `secret`) |
| Pause | `POST /v1/outbound/endpoints/{id}/pause` | `relay.outbound.endpoints.pause(id)` | `pause_outbound_endpoint` |

Rules: pause every endpoint right after creating it; build request bodies in
code (e.g. `jq -n --arg secret "$S"` or `JSON.stringify`), never by echoing the
secret into a shell command or log; never print/log/store the `secret` in the
create response; `400` on an endpoint created with `secret` = the format was
refused (the error never echoes it) → create it without `secret` and report
"new secret"; `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 convoy.events.createFanOutEvent({ owner_id: ownerId, event_type: "invoice.paid", idempotency_key: key, data });
```

After:

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

await convoy.events.createFanOutEvent({ owner_id: ownerId, event_type: "invoice.paid", idempotency_key: key, data });
if (flags.relayOutbound) {
  await relay.outbound.messages.publish({ consumer: ownerId, eventType: "invoice.paid", payload: data }, { idempotencyKey: key });
}
```

Go, after:

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

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

Broadcasts: loop over consumers (`GET /v1/outbound/consumers`) and publish one
message each with key `<key>:<consumer>`. Single-endpoint events: publish to the
endpoint's consumer (all its subscribed endpoints receive it — report if that
changes behavior for owners with several endpoints).

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

---

## 6. Customer receiver change (to communicate)

Every receiver adds Standard Webhooks verification and keeps its Convoy check
for requests carrying `X-Convoy-Signature`. `WEBHOOK_SECRET_RELAY` is the
imported Convoy secret (same value as `WEBHOOK_SECRET_CONVOY`) or, if it
couldn't be imported, the new `whsec_` secret:

```js
import { verifyWebhook } from "./verify-webhook.js"; // https://webhookrelay.com/docs/outbound/receiving#nodejs
import { verifyConvoy } from "./verify-convoy.js";   // existing check
export function verify(rawBody, headers) {
  if (headers["webhook-signature"]) return verifyWebhook(rawBody, headers, process.env.WEBHOOK_SECRET_RELAY);
  if (headers["x-convoy-signature"]) return verifyConvoy(rawBody, headers["x-convoy-signature"], process.env.WEBHOOK_SECRET_CONVOY);
  return false;
}
```

Convoy signature formats: https://getconvoy.io/docs/product-manual/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.
   If any secrets import, also create a second bin endpoint with a test secret
   in the same format (`secret` field) and verify its delivery with that test secret.
4. In shadow mode, compare per owner and event type over the same window: Convoy
   `GET /v1/projects/{projectID}/events` 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, signature settings, import counts (imported secrets vs new
secrets, by endpoint ID, never values), filters/functions/auth not carried over,
broadcast and single-endpoint call sites, verification results, per-owner switch
plan. **Wait for approval.**

---

## 9. Cutover (REQUIRES HUMAN APPROVAL, one owner 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. Pause their Convoy endpoints: `PUT /v1/projects/{projectID}/endpoints/{endpointID}/pause`.
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 owner is switched, Convoy has nothing left in retry, and the human
approves: remove the Convoy 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; `POST /v1/projects/{projectID}/endpoints/{endpointID}/activate` at Convoy. |
| After removing Convoy code | Revert the commit; the Convoy project is untouched. |

---

## Never do

- Never delete or modify Convoy projects, endpoints or subscriptions (only the
  approved pause in step 9).
- Never print, log, write to files or commit signing secrets or API keys. Read
  Convoy endpoint secrets only to pass them to Webhook Relay in memory.
- Never resume a Webhook Relay endpoint or pause a Convoy endpoint 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 claim features that don't exist: consumer portal, non-HTTP
  destinations, asymmetric signing keys.
