DocumentationMigrate from Hookdeck Outpost

Migrate from Hookdeck Outpost

Move outbound webhooks from Hookdeck Outpost to the Webhook Relay Outbound Gateway: tenants, destinations and topics mapped to consumers, endpoints and event types, destination secrets imported, signature changes, a dual-publish cutover with rollback and a TypeScript import script.

Moving from Hookdeck Outpost maps one-to-one for webhook destinations. A tenant becomes a consumer, a webhook destination becomes an endpoint of that consumer, a topic becomes an event type, and outpost.publish becomes relay.outbound.messages.publish. Each destination's signing secret is imported when it fits the Standard Webhooks format, which Outpost's generated secrets do. If your instance runs in Standard Webhooks mode, customers with an imported secret change nothing; in Outpost's default mode, receivers switch their verification code to Standard Webhooks but keep their secret.

Read the migration overview first for the plan limits, secret import rules and the general cutover plan. For how the Outbound Gateway compares with Outpost on price and features, see the Outbound Gateway overview and the webhooks-as-a-service comparison.

Concept map

Hookdeck OutpostOutbound GatewayNotes
Outpost instance (managed or self-hosted)Webhook Relay account
TenantConsumerUse the tenant ID as the consumer ID (up to 128 letters, numbers, dots, colons, underscores or hyphens). Tenant metadata.name can become the consumer name.
Destination, type webhookEndpointPublic HTTPS URL. credentials.secret is imported when it fits the format, otherwise the endpoint gets a new whsec_ secret.
Destination, other types (SQS, Pub/Sub, Kafka, S3, …)Not supportedKeep on Outpost or deliver another way.
TopicEvent typeSame name.
Destination topics: "*"Endpoint event_types: ["*"]Wildcard patterns such as user.* are expanded to topic names.
Destination filterFunctionA Function on the endpoint can drop messages.
config.custom_headersEndpoint headersUp to 32. webhook-* headers are reserved.
Event metadata (sent as headers)No per-message headersPut per-event values in the payload.
Event idevent_id and the Idempotency-Key header
Publish with destination_idNo equivalentMessages go to every subscribed endpoint of the consumer.
Disabled destinationPaused endpoint
Tenant portalNot availableManage endpoints from your own product with the API.

API map

TaskHookdeck OutpostOutbound Gateway
Create a customerPUT /tenants/{tenant_id}PUT /v1/outbound/consumers/{id}
List customersGET /tenantsGET /v1/outbound/consumers
Add an endpointPOST /tenants/{tenant_id}/destinationsPOST /v1/outbound/consumers/{consumer}/endpoints
List endpointsGET /tenants/{tenant_id}/destinationsGET /v1/outbound/consumers/{consumer}/endpoints
List topics / event typesGET /topicsGET /v1/outbound/event-types
PublishPOST /publishPOST /v1/outbound/messages
Retry one deliveryPOST /retry with event_id and destination_idPOST /v1/outbound/endpoints/{id}/retry with message_id
Disable / enablePUT /tenants/{tenant_id}/destinations/{destination_id}/disable / .../enablePOST /v1/outbound/endpoints/{id}/pause / .../resume
Read a secretDestination credentials.secret (readable for webhook destinations)POST /v1/outbound/endpoints/{id}/secret/reveal
Import a secretn/asecret on POST /v1/outbound/consumers/{consumer}/endpoints
Rotate a secretSee Outpost's secret rotation docsPOST /v1/outbound/endpoints/{id}/secret/rotate

Outpost routes are from the Outpost OpenAPI spec. The managed API base URL is https://api.outpost.hookdeck.com/2025-07-01; a self-hosted instance serves the same paths under /api/v1.

Publish

Before, with @hookdeck/outpost-sdk:

import { Outpost } from "@hookdeck/outpost-sdk";

const outpost = new Outpost({ apiKey: process.env.OUTPOST_API_KEY });

await outpost.publish({
  id: "evt_9001",
  tenantId: "customer_42",
  topic: "invoice.paid",
  data: { invoice_id: "inv_123", amount: 4900 },
});

After, with @webhookrelay/sdk:

import { WebhookRelay } from "@webhookrelay/sdk";

const relay = new WebhookRelay(); // reads RELAY_KEY and RELAY_SECRET

await relay.outbound.messages.publish(
  { consumer: "customer_42", eventType: "invoice.paid", eventId: "evt_9001", payload: { invoice_id: "inv_123", amount: 4900 } },
  { idempotencyKey: "evt_9001" },
);

Go, before (github.com/hookdeck/outpost/sdks/outpost-go) and after (github.com/webhookrelay/webhookrelay-go):

// Before
_, err := s.Publish(ctx, components.PublishRequest{
    ID:       outpostgo.Pointer("evt_9001"),
    TenantID: outpostgo.Pointer("customer_42"),
    Topic:    outpostgo.Pointer("invoice.paid"),
    Data:     map[string]any{"invoice_id": "inv_123", "amount": 4900},
})

// After
_, err := api.PublishOutboundMessage(&webhookrelay.OutboundMessagePublishOptions{
    Consumer:       "customer_42",
    EventType:      "invoice.paid",
    EventID:        "evt_9001",
    Payload:        map[string]interface{}{"invoice_id": "inv_123", "amount": 4900},
    IdempotencyKey: "evt_9001",
})

curl:

# Before
curl -X POST https://api.outpost.hookdeck.com/2025-07-01/publish \
  -H "Authorization: Bearer $OUTPOST_API_KEY" -H 'Content-Type: application/json' \
  -d '{"id":"evt_9001","tenant_id":"customer_42","topic":"invoice.paid","data":{"invoice_id":"inv_123","amount":4900}}'

# After
curl --user "$RELAY_KEY:$RELAY_SECRET" -X POST https://my.webhookrelay.com/v1/outbound/messages \
  -H 'Content-Type: application/json' -H 'Idempotency-Key: evt_9001' \
  -d '{"consumer":"customer_42","event_type":"invoice.paid","event_id":"evt_9001","payload":{"invoice_id":"inv_123","amount":4900}}'

Add a customer and an endpoint

New customers you onboard after the migration get a generated secret:

// Before
await outpost.tenants.upsert("customer_42");
// POST /tenants/customer_42/destinations {"type":"webhook","topics":["invoice.paid"],"config":{"url":"https://acme.example/webhooks"}}

// After
await relay.outbound.consumers.upsert("customer_42", { name: "Acme" });
const endpoint = await relay.outbound.endpoints.create("customer_42", {
  url: "https://acme.example/webhooks",
  eventTypes: ["invoice.paid"],
  autoDisable: true,
});
// endpoint.secret is the customer's new whsec_ secret. Store it like a password.

To migrate an existing destination, pass its current secret in secret. The SDKs add a secret option in their next release; until then, use the REST call:

# OUTPOST_SECRET holds the destination's credentials.secret. Never echo it.
jq -n --arg url "https://acme.example/webhooks" --arg secret "$OUTPOST_SECRET" \
  '{url: $url, event_types: ["invoice.paid"], secret: $secret}' |
curl -s --user "$RELAY_KEY:$RELAY_SECRET" -X POST \
  https://my.webhookrelay.com/v1/outbound/consumers/customer_42/endpoints \
  -H 'Content-Type: application/json' --data @- | jq '{id, state}'

For a few customers, the dashboard's Add endpoint dialog has an optional Existing signing secret field that does the same without code.

Go: api.UpsertOutboundConsumer(...), api.CreateOutboundEndpoint(...), api.PauseOutboundEndpoint(id), api.RetryOutboundDelivery(endpointID, messageID). MCP: upsert_outbound_consumer, create_outbound_endpoint (accepts secret), publish_outbound_message, pause_outbound_endpoint, retry_outbound_delivery.

Signature changes for your customers

Outpost has two webhook modes, set per instance with DESTINATIONS_WEBHOOK_MODE (Outpost webhook destination docs). Check which one yours uses.

Outpost default modeOutpost Standard Webhooks modeOutbound Gateway
ID headerx-outpost-event-idwebhook-idwebhook-id
Timestamp headerx-outpost-timestamp (RFC 3339)webhook-timestampwebhook-timestamp (Unix seconds)
Signature headerx-outpost-signature: v0=<hex>webhook-signature: v1,<base64>webhook-signature: v1,<base64>
Signed contentThe raw body (unless the operator changed the templates){id}.{timestamp}.{body}{id}.{timestamp}.{body}
KeyThe whole secret stringBase64 part of whsec_…Base64-decoded secret (after whsec_), imported when it fits the format, otherwise a new whsec_…
Event typex-outpost-topicwebhook-event-type
  • Standard Webhooks mode, secret imported: nothing changes for the receiver. Same headers, same scheme, same secret.
  • Standard Webhooks mode, secret not imported: the receiver switches to the new whsec_ secret, trying the new secret and then the old one during the window.
  • Default mode: receivers add Standard Webhooks verification (receiving guide). If the secret was imported, they keep the same secret value in their configuration: a Standard Webhooks library strips whsec_ and base64-decodes the rest, and Webhook Relay signs with that same decoded key. (Outpost's default mode used the whole string as the key, so the old check and the new check use the same value differently; that's expected.) If your operator customized DESTINATIONS_WEBHOOK_SIGNATURE_CONTENT_TEMPLATE or ..._HEADER_TEMPLATE, the old check is whatever those templates produce.

During the migration window, a default-mode receiver accepts either provider by checking which headers are present. verifyWebhook is the Standard Webhooks function from the receiving guide. With an imported secret, both checks read the same WEBHOOK_SECRET:

import crypto from "node:crypto";
import { verifyWebhook } from "./verify-webhook.js"; // from the receiving guide

export function verify(rawBody, headers) {
  if (headers["webhook-signature"]) {
    // Standard Webhooks: the key is the base64-decoded part after "whsec_".
    // If the secret wasn't imported, pass the new Webhook Relay secret here instead.
    return verifyWebhook(rawBody, headers, process.env.WEBHOOK_SECRET);
  }
  const header = headers["x-outpost-signature"]; // "v0=<hex>[,<hex>]" with Outpost's default templates
  if (!header?.startsWith("v0=")) return false;
  // Outpost default mode: the key is the whole secret string.
  const expected = crypto.createHmac("sha256", process.env.WEBHOOK_SECRET).update(rawBody).digest();
  return header.slice(3).split(",").some((sig) => {
    const received = Buffer.from(sig, "hex");
    return received.length === expected.length && crypto.timingSafeEqual(received, expected);
  });
}

Deduplicate on webhook-id for Webhook Relay deliveries and on x-outpost-event-id for Outpost ones. If you publish to Webhook Relay with event_id set to the Outpost event ID and also include it in the payload, receivers can deduplicate across providers.

Cutover plan

  1. Import. Run the import script with DRY_RUN=true, review the report, then with DRY_RUN=false. It creates event types from your topics, one consumer per tenant and an endpoint per webhook destination with the destination's existing secret where it fits the format, then pauses every new endpoint. The report lists the endpoints that got a new secret. Rollback: nothing is live. Leave the endpoints paused or delete them.
  2. Shadow publish. Next to every outpost.publish, call relay.outbound.messages.publish with the tenant ID as the consumer, the topic as the event type and the Outpost event id as both event_id and the idempotency key, behind a feature flag. Outpost still delivers. Webhook Relay records each message as rejected on the paused endpoints. Rollback: turn the flag off.
  3. Receiver changes, only where needed. In Standard Webhooks mode, skip this for customers whose secret was imported. In default mode, every receiver adds Standard Webhooks verification and accepts either provider as shown above. Customers whose secret couldn't be imported also need their new secret: show it in your product (read it with POST /v1/outbound/endpoints/{id}/secret/reveal). Send the receiving guide to everyone whose receiver changes. Rollback: nothing to undo.
  4. Switch, one customer at a time (needs approval). Resume the customer's Webhook Relay endpoints and disable their Outpost destinations (PUT .../disable). Don't delete anything, and don't call replay-missing for the shadow period. Start with an internal test tenant. Rollback: pause the Webhook Relay endpoints and PUT .../enable the Outpost destinations.
  5. End dual publish. When every customer is switched and Outpost has no deliveries left in retry, remove the Outpost publish call and SDK. Rollback: restore the call; tenants and destinations are untouched.
  6. Decommission. A human shuts down the Outpost instance or plan after exporting any history they need.

Verify

  1. Create a free Webhook Bin and add it as an endpoint of a test consumer:
    BIN=$(curl -s -X POST https://bin.webhookrelay.com/v1/bins | jq -r .id)
    curl --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 --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\":[\"*\"]}"
    
  2. Publish a test message to migration-test through your new code path and read the capture with curl -s https://bin.webhookrelay.com/v1/bins/$BIN | jq '.requests[0]'.
  3. Paste the headers, body and that endpoint's secret into the Standard Webhooks signature verifier, then run the same request through your customers' sample receiver. To test secret import, create a test Outpost destination, then create the bin endpoint with its secret (as in Add a customer and an endpoint) and verify with the Outpost secret.
  4. During shadow publishing, compare per tenant and topic: Outpost's events (GET /events?tenant_id=…) against GET /v1/outbound/messages?consumer=<id>, and the rejected deliveries on each paused endpoint (GET /v1/outbound/endpoints/{id}/deliveries?status=rejected).
  5. After switching a customer, watch GET /v1/outbound/endpoints/{id}/deliveries?status=failed and GET /v1/outbound/health.

Import script

This script reads Outpost (read-only), including each webhook destination's credentials.secret, and creates the same configuration in Webhook Relay. It checks your plan limits before writing anything, skips non-webhook destinations, imports each secret that fits the format and reports the endpoints that get a new one, keeps secrets in memory only (never printed, logged or written to the state file), pauses every endpoint it creates and stops at the first error with its progress saved. Run it with npx tsx migrate-outpost.mts.

The SDKs add a secret option to endpoint create in their next release; until then, the script creates endpoints with the REST call and uses @webhookrelay/sdk for everything else.

GET /tenants needs RediSearch on a self-hosted instance and returns 501 without it. In that case, set TENANT_IDS to a comma-separated list of tenant IDs from your own database.

// migrate-outpost.mts
// Env: OUTPOST_API_KEY, OUTPOST_API_URL (default managed; self-hosted: https://<host>/api/v1), TENANT_IDS (optional),
//      RELAY_KEY, RELAY_SECRET (token with "Outbound: manage"), DRY_RUN=false to write.
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { WebhookRelay, WebhookRelayAPIError } from "@webhookrelay/sdk";

const OUTPOST_API = process.env.OUTPOST_API_URL ?? "https://api.outpost.hookdeck.com/2025-07-01";
const DRY_RUN = process.env.DRY_RUN !== "false";
const STATE_FILE = "migration-state.json"; // source destination ID -> Webhook Relay endpoint ID. Never secrets.
const ID = /^[a-zA-Z0-9][a-zA-Z0-9_.:-]{0,127}$/;
const RESERVED = /^(webhook-|host$|content-length$|transfer-encoding$|connection$)/i;
const RELAY_AUTH = `Basic ${Buffer.from(`${process.env.RELAY_KEY}:${process.env.RELAY_SECRET}`).toString("base64")}`;

const relay = new WebhookRelay();

type PlanEndpoint = {
  sourceId: string; url: string; eventTypes: string[]; description?: string;
  headers?: Record<string, string>; rate?: number; disabledAtSource?: boolean;
  secret?: string; // in memory only: never printed, logged or written to the state file
};

// Webhook Relay imports 32-100 standard base64 characters (padded or not) with an optional whsec_ prefix.
// Anything else (URL-safe base64, other characters, wrong length) gets a new generated secret.
function importableSecret(secret: unknown): string | undefined {
  if (typeof secret !== "string") return undefined;
  const encoded = secret.replace(/^whsec_/, "");
  const bare = encoded.replace(/={1,2}$/, "");
  if (encoded.length > 100 || !/^[A-Za-z0-9+/]{32,100}$/.test(bare) || bare.length % 4 === 1) return undefined;
  if (bare !== encoded && encoded.length % 4 !== 0) return undefined; // bad padding
  return secret;
}
type Plan = {
  eventTypes: { name: string; description?: string; deprecated?: boolean }[];
  consumers: { id: string; name?: string; endpoints: PlanEndpoint[] }[];
  warnings: string[];
};

// ---- 1. Export from Outpost (read-only) ----
async function outpost<T>(path: string, query: Record<string, string> = {}): Promise<{ status: number; body: T }> {
  const qs = new URLSearchParams(query).toString();
  const res = await fetch(`${OUTPOST_API}${path}${qs ? `?${qs}` : ""}`, {
    headers: { Authorization: `Bearer ${process.env.OUTPOST_API_KEY}` },
  });
  if (res.status === 501) return { status: 501, body: undefined as T };
  if (!res.ok) throw new Error(`Outpost GET ${path}: ${res.status} ${await res.text()}`);
  return { status: res.status, body: (await res.json()) as T };
}

async function tenantIds(): Promise<{ id: string; name?: string }[]> {
  if (process.env.TENANT_IDS) return process.env.TENANT_IDS.split(",").map((id) => ({ id: id.trim() }));
  const out: { id: string; name?: string }[] = [];
  let next: string | undefined;
  for (;;) {
    const { status, body } = await outpost<{ models: any[]; pagination?: { next?: string | null } }>(
      "/tenants", { limit: "100", ...(next ? { next } : {}) });
    if (status === 501) throw new Error("GET /tenants is not available on this instance: set TENANT_IDS");
    for (const t of body.models) out.push({ id: t.id, name: t.metadata?.name });
    if (!body.pagination?.next) return out;
    next = body.pagination.next;
  }
}

async function exportPlan(): Promise<Plan> {
  const plan: Plan = { eventTypes: [], consumers: [], warnings: [] };
  const topics = (await outpost<string[]>("/topics")).body;
  plan.eventTypes = topics.map((name) => ({ name }));
  const expand = (t: string | string[]): string[] => {
    const list = Array.isArray(t) ? t : [t];
    if (list.includes("*")) return ["*"];
    return [...new Set(list.flatMap((p) => {
      if (!p.includes("*")) return [p];
      const re = new RegExp(`^${p.split("*").map((s) => s.replace(/[.+?^${}()|[\]\\]/g, "\\$&")).join(".*")}$`);
      return topics.filter((name) => re.test(name));
    }))];
  };
  for (const tenant of await tenantIds()) {
    const consumer = { id: tenant.id, name: tenant.name, endpoints: [] as PlanEndpoint[] };
    const { body: destinations } = await outpost<any[]>(`/tenants/${encodeURIComponent(tenant.id)}/destinations`);
    for (const d of destinations) {
      if (d.type !== "webhook") {
        plan.warnings.push(`${tenant.id}: ${d.type} destination ${d.id} skipped (only HTTPS endpoints are supported)`);
        continue;
      }
      if (d.filter) plan.warnings.push(`${tenant.id} ${d.config.url}: has a filter; recreate it as a Function`);
      const secret = importableSecret(d.credentials?.secret);
      if (!secret) plan.warnings.push(`${tenant.id} destination ${d.id}: secret doesn't fit the import format; the endpoint gets a new one`);
      const custom = d.config.custom_headers ? JSON.parse(d.config.custom_headers) : {};
      const headers = Object.fromEntries(Object.entries(custom).filter(([k]) => !RESERVED.test(k))) as Record<string, string>;
      consumer.endpoints.push({
        sourceId: d.id, url: d.config.url, eventTypes: expand(d.topics), headers,
        disabledAtSource: Boolean(d.disabled_at), secret,
      });
    }
    plan.consumers.push(consumer);
  }
  return plan;
}

// ---- 2. Check access and limits before writing anything ----
async function checkPlan(plan: Plan) {
  const res = await fetch("https://my.webhookrelay.com/v1/outbound/access", { headers: { Authorization: RELAY_AUTH } });
  const access = await res.json();
  if (!access.enabled) throw new Error("The Outbound Gateway isn't available on this account: upgrade to Basic or above.");
  const limits = access.limits as { consumers: number; endpoints_per_consumer: number; event_types: number };

  const haveConsumers = new Set((await relay.outbound.consumers.list()).map((c) => c.id));
  const haveTypes = new Set((await relay.outbound.eventTypes.list()).map((e) => e.name));
  const consumers = new Set([...haveConsumers, ...plan.consumers.map((c) => c.id)]).size;
  const types = new Set([...haveTypes, ...plan.eventTypes.map((e) => e.name)]).size;

  const problems: string[] = [];
  if (consumers > limits.consumers) problems.push(`${consumers} consumers exceed the limit of ${limits.consumers}`);
  if (types > limits.event_types) problems.push(`${types} event types exceed the limit of ${limits.event_types}`);
  for (const et of plan.eventTypes) if (!ID.test(et.name)) problems.push(`event type "${et.name}" is not a valid name`);
  for (const c of plan.consumers) {
    if (!ID.test(c.id)) problems.push(`consumer "${c.id}" is not a valid ID: map it to your own customer ID`);
    if (c.endpoints.length > limits.endpoints_per_consumer)
      problems.push(`${c.id} has ${c.endpoints.length} endpoints, the limit is ${limits.endpoints_per_consumer}`);
    for (const e of c.endpoints) {
      if (!e.url.startsWith("https://")) problems.push(`${c.id} ${e.url}: endpoints must be public HTTPS`);
      if (e.eventTypes.length === 0) problems.push(`${c.id} ${e.url}: no topics matched`);
      if (e.eventTypes.length > 100) problems.push(`${c.id} ${e.url}: more than 100 event types`);
    }
  }
  for (const w of plan.warnings) console.warn(`warning: ${w}`);
  if (problems.length) {
    console.error(`Not importing:\n- ${problems.join("\n- ")}`);
    process.exit(1);
  }
}

// ---- 3. Import into Webhook Relay ----
const state: Record<string, string> = existsSync(STATE_FILE) ? JSON.parse(readFileSync(STATE_FILE, "utf8")) : {};

class RelayError extends Error {
  constructor(readonly status: number, message: string) { super(message); }
}

// REST create, because the SDKs expose `secret` only from their next release. Webhook Relay's errors never echo the secret.
async function createEndpoint(consumer: string, e: PlanEndpoint): Promise<{ id: string }> {
  const res = await fetch(`https://my.webhookrelay.com/v1/outbound/consumers/${encodeURIComponent(consumer)}/endpoints`, {
    method: "POST",
    headers: { Authorization: RELAY_AUTH, "Content-Type": "application/json" },
    body: JSON.stringify({
      url: e.url, event_types: e.eventTypes, description: e.description, headers: e.headers, rate: e.rate,
      ...(e.secret ? { secret: e.secret } : {}),
    }),
  });
  if (!res.ok) throw new RelayError(res.status, `create endpoint: ${res.status} ${await res.text()}`);
  const { id } = (await res.json()) as { id: string }; // the response also has the secret: don't log or store it
  return { id };
}

async function step<T>(what: string, fn: () => Promise<T>): Promise<T> {
  for (let attempt = 1; ; attempt++) {
    try {
      return await fn();
    } catch (err) {
      const status = err instanceof WebhookRelayAPIError || err instanceof RelayError ? err.status : 0;
      if (status === 429 && attempt < 5) {
        await new Promise((r) => setTimeout(r, attempt * 2000));
        continue;
      }
      console.error(`Stopped at ${what}: ${err instanceof Error ? err.message : String(err)}`);
      console.error(`Progress is saved in ${STATE_FILE}. Fix the cause and run the script again.`);
      process.exit(1);
    }
  }
}

async function importPlan(plan: Plan) {
  for (const et of plan.eventTypes) {
    await step(`event type ${et.name}`, () =>
      relay.outbound.eventTypes.upsert(et.name, { description: et.description, deprecated: et.deprecated }));
  }
  for (const c of plan.consumers) {
    await step(`consumer ${c.id}`, () => relay.outbound.consumers.upsert(c.id, { name: c.name }));
    for (const e of c.endpoints) {
      if (state[e.sourceId]) continue; // imported on an earlier run
      const { id, created } = await step(`endpoint ${c.id} ${e.url}`, async () => {
        try {
          return { ...(await createEndpoint(c.id, e)), created: true };
        } catch (err) {
          if (err instanceof RelayError && err.status === 409) {
            const existing = (await relay.outbound.endpoints.list(c.id)).find((x) => x.url === e.url);
            if (existing) return { id: existing.id, created: false }; // already there: leave its state alone
          }
          throw err;
        }
      });
      if (created) await step(`pause ${id}`, () => relay.outbound.endpoints.pause(id));
      state[e.sourceId] = id;
      writeFileSync(STATE_FILE, JSON.stringify(state, null, 2));
      const secretNote = created ? (e.secret ? ", secret imported" : ", NEW secret") : "";
      console.log(`${c.id}: ${e.url} -> ${id} ${created ? "(created, paused" : "(already existed, unchanged"}${secretNote})${e.disabledAtSource ? " [disabled at Outpost]" : ""}`);
    }
  }
}

const plan = await exportPlan();
await checkPlan(plan);
const endpoints = plan.consumers.flatMap((c) => c.endpoints);
const imported = endpoints.filter((e) => e.secret).length;
console.log(`Plan: ${plan.eventTypes.length} event types, ${plan.consumers.length} consumers, ${endpoints.length} endpoints (${imported} with an imported secret, ${endpoints.length - imported} with a new one).`);
if (DRY_RUN) console.log("Dry run: nothing was written. Run again with DRY_RUN=false to import.");
else await importPlan(plan);

With more than a few hundred endpoints, run the script in batches (for example, set TENANT_IDS to one batch at a time) and review the output between runs. There is no bulk import endpoint.

Let your agent do it

Migrate our outbound webhooks from Hookdeck Outpost to Webhook Relay
Paste into Claude Code, Cursor, Codex or any agent with shell and web access.

Read https://webhookrelay.com/outbound-migration/hookdeck-outpost.md and follow it step by step in this repository.

  1. Inventory every use of the Outpost SDK and API (publishing, tenant and destination management, secret display, receiver docs), and find out whether our Outpost instance uses the default or the Standard Webhooks signature mode.
  2. Check GET https://my.webhookrelay.com/v1/outbound/access: confirm enabled is true and that our tenants, webhook destinations and topics fit within the returned limits. Stop and tell me if they don't.
  3. Import event types, consumers and endpoints with the playbook's script, dry run first, passing each destination's existing signing secret where it fits the format. Every new endpoint must be paused. Report any non-webhook destinations and filters it skipped, and which endpoints got a new secret (by ID only).
  4. Add a Webhook Relay publish call next to every Outpost publish call, behind a feature flag, using the Outpost event id as event_id and as the idempotency key.
  5. Run the playbook's verification checks with a Webhook Bin and report the results.

Never print, log or commit signing secrets. Never delete or modify Outpost tenants or destinations. Stop and ask me for approval before resuming any Webhook Relay endpoint or disabling any Outpost destination.

Next steps

Frequently asked questions

What do my customers change when I move from Hookdeck Outpost to Webhook Relay?

It depends on Outpost's webhook mode. In Standard Webhooks mode, Outpost already sends webhook-id, webhook-timestamp and webhook-signature, so a customer whose destination secret is imported changes nothing. In Outpost's default mode, receivers verify a hex HMAC of the body in x-outpost-signature, so they need to add Standard Webhooks verification, but they can keep the same secret value when it is imported.

Can I keep my Outpost destination secrets?

Usually, yes. The import script reads each webhook destination's credentials.secret from the Outpost API and passes it to Webhook Relay when the endpoint is created. Webhook Relay accepts 32 to 100 standard base64 characters with an optional whsec_ prefix. Outpost's default generated secrets, whsec_ followed by 64 hex characters, fit that format. A secret that doesn't fit, for example one from a custom secret template that uses other characters, is replaced with a new whsec_ secret and the script reports that endpoint.

Can I migrate Outpost destinations that aren't webhooks?

No. Outbound Gateway endpoints are public HTTPS URLs. Destinations such as SQS, Pub/Sub, Kafka or S3 are not supported, so keep those on Outpost or deliver them another way. The import script reports them and skips them.

How do Outpost topic wildcards map to Webhook Relay event types?

An endpoint subscribes to a list of event type names or to every event type with an asterisk. A destination subscribed to all topics becomes an endpoint subscribed to all event types. A pattern such as user.* has no direct equivalent, so the import script expands it into the matching topic names from your Outpost topic list.

Does Webhook Relay support publishing an event to one specific destination?

No. A published message goes to every endpoint of the consumer that subscribes to its event type. If you publish to a single Outpost destination with destination_id, use a dedicated event type for that endpoint, or attach a Function that drops messages the endpoint shouldn't receive.

Did this page help you?