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

- Webhook Relay API base URL: `https://my.webhookrelay.com`, HTTP Basic `RELAY_KEY:RELAY_SECRET`
- Outpost API base URL: managed `https://api.outpost.hookdeck.com/2025-07-01`;
  self-hosted `https://<host>/api/v1`. Auth: `Authorization: Bearer $OUTPOST_API_KEY` (admin API key)
- Mapping: tenant → consumer, webhook destination → endpoint, topic → event type.
- Webhook Relay signs with Standard Webhooks (`webhook-id`, `webhook-timestamp`,
  `webhook-signature`). Outpost's default mode signs differently
  (`x-outpost-signature: v0=<hex HMAC-SHA256 of the body>`); Outpost's Standard
  Webhooks mode (`DESTINATIONS_WEBHOOK_MODE=standard`) uses the same scheme as Webhook Relay.
- **Signing secrets are imported.** Read each webhook destination's
  `credentials.secret` from Outpost 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. Outpost's default generated secrets
  (`whsec_` + 64 hex characters) fit. A secret that doesn't fit is left out and
  that endpoint gets a new `whsec_` secret: report it by endpoint ID.
- Receiver impact: Standard Webhooks mode + imported secret → **no receiver
  change**. Default mode → receivers add Standard Webhooks verification but keep
  the same secret value when it was imported. Any endpoint with a new secret →
  that customer needs the new secret.
- Only HTTPS endpoints exist in Webhook Relay. Non-webhook Outpost destinations
  (SQS, Pub/Sub, Kafka, S3, RabbitMQ, ...) cannot be migrated: report them.

**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** |
| `OUTPOST_API_KEY` | Outpost admin API key |
| `OUTPOST_API_URL` | Outpost API base URL (see above) |
| `TENANT_IDS` | Optional: comma-separated tenant IDs, needed when `GET /tenants` returns `501` (self-hosted without RediSearch) |

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

Find out which webhook mode Outpost uses (ask the human, or check the managed
config with `GET /config`, or the self-hosted env `DESTINATIONS_WEBHOOK_MODE`). Also
note any custom `DESTINATIONS_WEBHOOK_SIGNATURE_*_TEMPLATE` or header prefix.

---

## 1. Inventory the codebase

| Language | Package / import |
| --- | --- |
| Node / TypeScript | `@hookdeck/outpost-sdk` — `import { Outpost } from "@hookdeck/outpost-sdk"` |
| Go | `github.com/hookdeck/outpost/sdks/outpost-go` |
| Python | `outpost_sdk` (PyPI) — `from outpost_sdk import Outpost` |
| Any | direct calls to `api.outpost.hookdeck.com`, `/publish`, `/tenants/` |

```bash
rg -n --hidden -g '!node_modules' -g '!vendor' \
  -e '@hookdeck/outpost-sdk' -e 'outpost/sdks/outpost-go' -e 'outpost_sdk' \
  -e 'api\.outpost\.hookdeck\.com' -e '/publish' -e 'x-outpost-' -e 'DESTINATIONS_WEBHOOK_'
```

| Kind | Typical call | Becomes |
| --- | --- | --- |
| Publish | `outpost.publish({ tenantId, topic, id, data, metadata })` / `s.Publish(ctx, components.PublishRequest{...})` / `POST /publish` | `relay.outbound.messages.publish({ consumer, eventType, eventId, payload }, { idempotencyKey })` |
| Targeted publish | `destination_id` / `destinationId` set | **No equivalent** — report; use a dedicated event type or a Function |
| Tenant create | `outpost.tenants.upsert(id)` / `PUT /tenants/{id}` | `relay.outbound.consumers.upsert(id, { name })` |
| Destination CRUD | `POST/PATCH/DELETE /tenants/{id}/destinations...` | `relay.outbound.endpoints.create / update / delete` |
| Portal | `GET /tenants/{id}/portal`, tenant JWT | **No equivalent** — report |
| Metadata headers | `metadata: {...}` on publish | No per-message headers: move values into the payload |
| Receiver docs | `x-outpost-signature` | link https://webhookrelay.com/docs/outbound/receiving |

---

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

| Data | Call | Fields used |
| --- | --- | --- |
| Topics | `GET /topics` | array of names |
| Tenants | `GET /tenants?limit=100&next=<cursor>` → `{ models, pagination: { next } }` | `id`, `metadata.name` |
| Destinations | `GET /tenants/{tenant_id}/destinations` → array | `id`, `type`, `topics`, `config.url`, `config.custom_headers` (JSON string), `filter`, `disabled_at`, `credentials.secret` |

If `GET /tenants` returns `501`, use `TENANT_IDS`.

Outpost obfuscates destination credentials except the webhook destination
secret, which the admin API key can read. Read `credentials.secret` into memory
only, to pass it to Webhook Relay in step 4. Ignore `previous_secret` (Webhook
Relay imports one secret per endpoint). Never print, log, write to a file or
commit a secret value. In reports, say "imported" or "new secret" per endpoint ID.

Mapping:

- consumer ID = tenant `id` (must match `^[a-zA-Z0-9][a-zA-Z0-9_.:-]{0,127}$`)
- only `type: "webhook"` destinations → endpoints; report the rest
- `topics: "*"` (or containing `"*"`) → `event_types: ["*"]`; wildcard patterns
  such as `user.*` → expand against `GET /topics`; zero matches → report
- `config.custom_headers` → parse JSON → `headers`, minus `webhook-*`, `host`,
  `content-length`, `transfer-encoding`, `connection`
- `filter` → no equivalent; report (a Webhook Relay Function can drop messages)
- `disabled_at` set → import (paused anyway) and flag
- `credentials.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 destination 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 tenant has more webhook destinations
than `limits.endpoints_per_consumer`; any endpoint has > 100 event types; any URL
is not `https://`; any ID fails the pattern; any payload can exceed 256 KiB.
Never drop tenants or destinations silently.

---

## 4. Import into Webhook Relay

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

```bash
npm install @webhookrelay/sdk
DRY_RUN=true  npx tsx migrate-outpost.mts
DRY_RUN=false npx tsx migrate-outpost.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":[...],"headers":{...},"secret":"<imported>"}` | 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 for that consumer → look it up, record it,
don't change its state (its secret may differ from Outpost's: report it); `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 outpost.publish({ id: eventId, tenantId, topic: "invoice.paid", data });
```

After:

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

await outpost.publish({ id: eventId, tenantId, topic: "invoice.paid", data });
if (flags.relayOutbound) {
  await relay.outbound.messages.publish(
    { consumer: tenantId, eventType: "invoice.paid", eventId, payload: data },
    { idempotencyKey: eventId },
  );
}
```

Go, after:

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

REST: `POST https://my.webhookrelay.com/v1/outbound/messages`, Basic auth,
`Idempotency-Key: <event id>`, body `{"consumer":"<tenant>","event_type":"<topic>","event_id":"<id>","payload":{...}}` → `202`.

If the Outpost `id` is generated by Outpost (not set by your code), generate a
stable key from your own event and use it for both calls.

Also update destination management code to write to both providers while the flag
is on (new Webhook Relay endpoints stay paused until that tenant is switched; pass
the secret Outpost generated as `secret` so both providers sign with the same key).
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)

- Outpost Standard Webhooks mode, secret imported: **nothing to communicate**.
- Outpost Standard Webhooks mode, new secret: receivers switch to the new secret;
  during the window they try the new secret, then the old one.
- Outpost default mode: receivers add Standard Webhooks verification and keep the
  old check for requests that carry `x-outpost-signature`. With an imported
  secret both checks use the same configured value (Standard Webhooks
  base64-decodes the part after `whsec_`; Outpost's default mode uses the whole
  string). With a new secret, pass the new one to `verifyWebhook`:

```js
import crypto from "node:crypto";
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);
  const header = headers["x-outpost-signature"];
  if (!header?.startsWith("v0=")) return false;
  const expected = crypto.createHmac("sha256", process.env.WEBHOOK_SECRET).update(rawBody).digest();
  return header.slice(3).split(",").some((sig) => {
    const got = Buffer.from(sig, "hex");
    return got.length === expected.length && crypto.timingSafeEqual(got, expected);
  });
}
```

(Default-mode details: https://hookdeck.com/docs/outpost/destinations/webhook. If the
operator customized signature templates, the old check follows those templates.)

---

## 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.
   To prove secret import, create a second bin endpoint with a test secret in
   Outpost's format (`secret` field) and verify its delivery with that test secret.
4. In shadow mode, compare per tenant and topic over the same window: Outpost
   `GET /events?tenant_id=<id>` vs Webhook Relay `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, webhook mode, import counts (imported secrets vs new secrets,
by endpoint ID, never values), skipped destinations, filters, targeted
publishes, verification results, which tenants need a receiver change, per-tenant
switch plan. **Wait for approval.**

---

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

1. Only if the tenant's receiver changes (default mode, or a new secret): confirm
   it accepts Webhook Relay's signature. Standard Webhooks mode with an imported
   secret needs no confirmation.
2. Resume their Webhook Relay endpoints: `POST /v1/outbound/endpoints/{id}/resume` (MCP `resume_outbound_endpoint`).
3. Disable their Outpost destinations: `PUT /tenants/{tenant_id}/destinations/{destination_id}/disable`.
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 tenant is switched, Outpost has nothing left in retry, and the human
approves: remove the Outpost publish call 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; `PUT .../destinations/{destination_id}/enable` at Outpost. |
| After removing Outpost code | Revert the commit; tenants and destinations are untouched. |

---

## Never do

- Never delete or modify Outpost tenants, destinations or config (only the approved
  disable toggle in step 9).
- Never print, log, write to files or commit signing secrets or API keys. Read
  Outpost destination secrets only to pass them to Webhook Relay in memory.
- Never resume a Webhook Relay endpoint or disable an Outpost destination without
  explicit approval for that tenant.
- 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 tenant 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.
