# Replace a homegrown webhook sender with Webhook Relay — agent playbook

Machine-readable playbook for coding agents (Claude Code, Cursor, Codex, ...).
For codebases that send webhooks to customers with their own outbox table, cron
job, SQS consumer, Sidekiq/Celery/BullMQ job or retry worker. Follow the steps in
order. The human-readable guide is https://webhookrelay.com/docs/outbound/migrate/diy
(Markdown: https://webhookrelay.com/docs/outbound/migrate/diy.md).

- Webhook Relay API base URL: `https://my.webhookrelay.com`, HTTP Basic `RELAY_KEY:RELAY_SECRET`
- SDKs: TypeScript `@webhookrelay/sdk`, Go `github.com/webhookrelay/webhookrelay-go`.
  No Python or Ruby SDK: call the REST API.
- Mapping: customer → consumer, endpoint row → endpoint, event name → event type,
  outbox row / job → message (outbox row ID = idempotency key).
- Webhook Relay owns delivery: signing (Standard Webhooks), retries (up to 8
  attempts in 48 hours), per-endpoint rate limits, health, 35 days of history.
- **Import existing signing secrets where the format allows.** Endpoint create
  accepts an optional `secret`: 32-100 standard base64 characters (`A-Z a-z 0-9 + /`,
  padded or not, decoding to a key of at least 24 bytes), `whsec_` prefix optional. Hex secrets of 32+
  chars usually fit; URL-safe base64 (`-`, `_`), UUIDs and short keys don't, and
  those endpoints get a new `whsec_` secret. The API never echoes the secret in errors.
- **What an imported secret keeps:** the same secret *value* in the customer's
  config. Standard Webhooks base64-decodes it to get the HMAC key, while many
  homegrown senders use the raw string, so receivers still switch to Standard
  Webhooks verification unless the sender already used it. Customers accept your
  current signature OR Webhook Relay's during the window.
- SDK status: the TypeScript and Go SDKs add a `secret` option to endpoint create
  in their next release. Until then, create imported endpoints with the REST call
  (the import script does). MCP `create_outbound_endpoint` accepts `secret` today.

**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** (import); a separate **Outbound: publish only** token for the publishing service |
| `DATABASE_URL` | Read access to the tables that hold customers, endpoints and endpoint secrets (for the import) |

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

Find the whole delivery path. Useful searches:

```bash
rg -n --hidden -g '!node_modules' -g '!vendor' -i \
  -e 'webhook_(endpoints?|subscriptions?|deliver(y|ies)|attempts?|events?|outbox)' \
  -e 'outbox' -e 'send_?webhook|deliver_?webhook|WebhookWorker|WebhookJob|webhook_task' \
  -e 'createHmac|hmac\.new|OpenSSL::HMAC|hmac\.New' \
  -e 'x-(webhook-)?signature|x-hub-signature' \
  -e 'sidekiq|celery|bullmq|sqs.*(send|receive)_?message|SendMessage'
```

Record, with file and line:

| Piece | Look for | Becomes |
| --- | --- | --- |
| Where events are created | `INSERT INTO webhook_outbox`, `queue.send(...)`, `perform_async`, `.delay(...)` | Unchanged (keep the outbox) |
| Delivery worker | HTTP `POST` to customer URLs | `relay.outbound.messages.publish(...)` once per event |
| Retry/backoff/dead-letter logic | `scheduleRetry`, `retry_in`, `max_retries`, DLQ | Removed after cutover (Webhook Relay retries) |
| Signing | HMAC over the body, header name | Removed after cutover (Standard Webhooks) |
| Endpoint storage + UI | `webhook_endpoints` table, settings page | `relay.outbound.endpoints.create/update/delete` |
| Delivery log / resend button | `webhook_deliveries` table | `GET /v1/outbound/endpoints/{id}/deliveries`, `POST .../retry` |
| Customer docs | signature verification instructions | link https://webhookrelay.com/docs/outbound/receiving |

---

## 2. Export (read-only)

Read customers, endpoints, their signing secrets and event names from the
database. The secret column is read **only inside the import script's process**,
held in memory and passed to the create call; never print it, log it, write it to
a file or paste it into your output. Example (adjust to the schema):

```sql
SELECT e.id, e.customer_id, c.name AS customer_name, e.url, e.event_types, e.description, e.enabled, e.secret
FROM webhook_endpoints e JOIN customers c ON c.id = e.customer_id
WHERE e.deleted_at IS NULL;

SELECT DISTINCT event_type FROM webhook_outbox;
```

Mapping:

- consumer ID = customer ID as a string (must match `^[a-zA-Z0-9][a-zA-Z0-9_.:-]{0,127}$`)
- endpoint `event_types` = the row's event filter, or `["*"]` for all
- static per-endpoint headers → `headers` (max 32; not `webhook-*`, `host`,
  `content-length`, `transfer-encoding`, `connection`)
- endpoint `secret` = the row's secret if it passes the format check
  (`^(whsec_)?[A-Za-z0-9+/]{32,100}={0,2}$`-style, base64-decoding to at
  least 24 bytes); otherwise omit `secret` and record the endpoint row ID (never the value)
  as "new secret" for the report
- if secrets are stored encrypted or hashed, ask the human: hashed secrets can't
  be recovered, so those endpoints get a new secret
- disabled rows → 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 endpoints than
`limits.endpoints_per_consumer`; any endpoint > 100 event types; any URL not
public `https://` (private/loopback hosts are rejected); any ID fails the pattern;
any payload can exceed 256 KiB or isn't JSON.

---

## 4. Import into Webhook Relay

Use the script under "Import script" in
https://webhookrelay.com/docs/outbound/migrate/diy.md, saved as `migrate-diy.mts`,
with its SQL adjusted to the schema:

```bash
npm install @webhookrelay/sdk pg
DRY_RUN=true  npx tsx migrate-diy.mts
DRY_RUN=false npx tsx migrate-diy.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}` `{"name":"..."}` | `relay.outbound.consumers.upsert(id, { name })` | `upsert_outbound_consumer` |
| Endpoint | `POST /v1/outbound/consumers/{consumer}/endpoints` `{"url":"...","event_types":[...],"secret":"<existing, optional>"}` | `relay.outbound.endpoints.create(consumer, { url, eventTypes })` (no `secret` until the next SDK release: use REST for imported secrets) | `create_outbound_endpoint` (accepts `secret`) |
| Pause | `POST /v1/outbound/endpoints/{id}/pause` | `relay.outbound.endpoints.pause(id)` | `pause_outbound_endpoint` |

Rules: pass the existing secret only in the create request body; pause every
endpoint right after creating it; never print/log/store any secret (old or from
the create response); a `400` on a create that carried `secret` → stop and report
the endpoint row ID only; `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.

Store the Webhook Relay endpoint ID next to each endpoint row (a new nullable
column, added with a migration the human approves).

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

---

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

Keep the outbox and the existing delivery. In the worker, also publish each
event to Webhook Relay when the flag is on. Use the outbox row (or job) ID as the
idempotency key so worker retries never publish twice.

TypeScript:

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

if (flags.relayOutbound) {
  await relay.outbound.messages.publish(
    { consumer: String(row.customer_id), eventType: row.event_type, eventId: String(row.id), payload: row.payload },
    { idempotencyKey: `outbox:${row.id}` },
  );
}
await legacyDeliver(row); // unchanged until the customer is switched
```

Go:

```go
if flags.RelayOutbound {
	_, err := api.PublishOutboundMessage(&webhookrelay.OutboundMessagePublishOptions{
		Consumer: customerID, EventType: row.EventType, EventID: rowID, Payload: row.Payload,
		IdempotencyKey: "outbox:" + rowID,
	})
	if err != nil {
		return err // retry the row later with the same key
	}
}
```

Python (Celery) / Ruby (Sidekiq) / anything else — REST:

```
POST https://my.webhookrelay.com/v1/outbound/messages
Authorization: Basic base64(RELAY_KEY:RELAY_SECRET)
Content-Type: application/json
Idempotency-Key: outbox:<row id>

{"consumer":"<customer id>","event_type":"<event>","event_id":"<row id>","payload":{...}}
```

`202` = accepted (also for an idempotent repeat). `429` or `5xx` → retry later with
the same key. `409` = the key was used for a different message (a bug: report it).

Also: when customers add/change/remove endpoints in the product UI, write to
Webhook Relay too while the flag is on (new endpoints stay paused until that
customer is switched; pass the same secret you generate for your own table so both
senders share it). For customers whose secret didn't import, 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)

Skip this for customers whose secret was imported **and** whose receiver already
verifies Standard Webhooks (only when the existing sender used Standard Webhooks).
Everyone else adds Standard Webhooks verification and keeps the current check
(with an imported secret, both read the same secret value):

```js
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);
  return verifyLegacy(rawBody, headers, process.env.WEBHOOK_SECRET_OLD); // the existing scheme
}
```

If the existing sender already uses Standard Webhooks, receivers with an imported
secret change nothing; receivers whose secret didn't import only add the new secret.

---

## 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. Insert a test outbox row for `migration-test` and let the worker publish it; 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. Re-run the worker for the same row: the publish must return the same message `id`
   (idempotency works).
5. In shadow mode, compare per customer and event type over the same window: your
   delivery table 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: the delivery path found, import counts, how many secrets were imported
vs newly generated (endpoint row IDs only for the latter), warnings, code changes,
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 (skip only
   for imported secrets with an existing Standard Webhooks sender).
2. Resume their Webhook Relay endpoints: `POST /v1/outbound/endpoints/{id}/resume`.
3. Turn on a per-customer flag that makes the legacy worker skip its own delivery
   for that customer. Don't delete endpoint rows or outbox data.
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, the legacy worker has no retries left, and the
human approves: remove the legacy HTTP delivery, retry and signing code. Keep the
outbox if the worker publishes from it.

## 10. Rollback

| Stage | Rollback |
| --- | --- |
| Import | Nothing is live. Leave endpoints paused (or delete with approval). |
| Dual delivery | Turn the flag off. |
| Cutover | Pause the customer's Webhook Relay endpoints; clear their per-customer flag so the legacy worker delivers again. |
| After removing legacy code | Revert the commit; endpoint rows and outbox are still there. |

---

## Never do

- Never delete endpoint rows, outbox data, delivery history or the legacy worker
  before the human approves.
- Never print, log, write to files, paste into chat or commit signing secrets or
  tokens. Read the secret column only inside the import process and pass it
  straight to the create request.
- Never resume a Webhook Relay endpoint or stop legacy delivery for a customer
  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 senders deliver live to the same customer at the same time.
- Never publish from browser or mobile code; tokens stay server-side.
- Never publish real customer data to a Webhook Bin.
- Never claim features that don't exist: consumer portal, non-HTTP
  destinations, Python/Ruby SDKs, an SDK `secret` option before it is released.
