Migrate from Convoy
Move outbound webhooks from Convoy to the hosted Webhook Relay Outbound Gateway: projects, endpoints, owner IDs and subscriptions mapped to consumers and endpoints, the signature change for receivers, secret import where the format allows, a dual-publish cutover with rollback and a TypeScript import script.
Moving from Convoy replaces a self-hosted or Convoy Cloud gateway with a hosted one. Endpoints that share an owner ID become one consumer, each endpoint's subscriptions become its event types, and events.createFanOutEvent becomes relay.outbound.messages.publish. Receivers move from X-Convoy-Signature to Standard Webhooks. An endpoint's current secret is imported when it fits the Standard Webhooks format; Convoy's auto-generated secrets don't, so most endpoints get a new whsec_ secret.
Read the migration overview first for the plan limits, secret import rules and the general cutover plan. For how the hosted Outbound Gateway compares with running Convoy, see the Outbound Gateway overview and the webhooks-as-a-service comparison.
Concept map
| Convoy | Outbound Gateway | Notes |
|---|---|---|
| Project (outgoing) | Webhook Relay account | Migrate one project at a time. |
Endpoint owner_id | Consumer | Endpoints without an owner ID become one consumer each (for example, the endpoint's name or uid). Consumer IDs are up to 128 letters, numbers, dots, colons, underscores or hyphens. |
| Endpoint | Endpoint | Public HTTPS URL. The current secret is imported when it fits the format, otherwise a new whsec_ secret. |
Subscription filter_config.event_types | Endpoint event_types | * maps to ["*"]. Up to 100 event types per endpoint. |
| Subscription body and header filters | Function | A Function on the endpoint can drop messages. |
| Subscription function (JS transform) | Function | Outbound Functions can reshape the JSON body or drop the message. |
| Event type | Event type | Same name. |
Event to one endpoint (endpoint_id) | Publish for the endpoint's consumer | Goes to every subscribed endpoint of that consumer. |
Fan-out event (owner_id) | Publish (consumer) | |
| Broadcast event | One publish per consumer | |
idempotency_key | Idempotency-Key header | |
Event custom_headers | No per-message headers | Put per-event values in the payload, or static values in endpoint headers. |
Endpoint rate_limit | Endpoint rate | Deliveries per second, 50 by default, up to 1,000. |
| Paused endpoint | Paused endpoint | |
| Sources (incoming webhooks) | Buckets, inputs and outputs | A separate Webhook Relay feature, not covered here. |
| Portal links | Not available | Manage endpoints from your own product with the API. |
API map
| Task | Convoy | Outbound Gateway |
|---|---|---|
| Add an endpoint | POST /v1/projects/{projectID}/endpoints | PUT /v1/outbound/consumers/{id}, then POST /v1/outbound/consumers/{consumer}/endpoints |
| Subscribe to event types | POST /v1/projects/{projectID}/subscriptions | event_types on the endpoint (PUT /v1/outbound/endpoints/{id} to change) |
| Add an event type | POST /v1/projects/{projectID}/event-types | POST /v1/outbound/event-types |
| Publish to one owner | POST /v1/projects/{projectID}/events/fanout | POST /v1/outbound/messages |
| Publish to one endpoint | POST /v1/projects/{projectID}/events | POST /v1/outbound/messages (for the endpoint's consumer) |
| Replay an event | PUT /v1/projects/{projectID}/events/{eventID}/replay | POST /v1/outbound/endpoints/{id}/retry with message_id |
| Read a secret | Endpoint secrets[] (the entry without expires_at is current) | POST /v1/outbound/endpoints/{id}/secret/reveal |
| Import a secret | n/a | secret on POST /v1/outbound/consumers/{consumer}/endpoints |
| Roll a secret | PUT /v1/projects/{projectID}/endpoints/{endpointID}/expire_secret | POST /v1/outbound/endpoints/{id}/secret/rotate |
| Pause / activate | PUT .../endpoints/{endpointID}/pause / POST .../activate | POST /v1/outbound/endpoints/{id}/pause / .../resume |
Convoy routes are from the Convoy OpenAPI spec. Convoy Cloud's API base URLs are https://us.getconvoy.cloud/api and https://eu.getconvoy.cloud/api; a self-hosted instance serves the same paths under its own host.
Publish
Before, with convoy.js:
const { Convoy } = require("convoy.js");
const convoy = new Convoy({ uri: process.env.CONVOY_URL, api_key: process.env.CONVOY_API_KEY, project_id: process.env.CONVOY_PROJECT_ID });
await convoy.events.createFanOutEvent({
owner_id: "customer_42",
event_type: "invoice.paid",
idempotency_key: "invoice-paid:inv_123",
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", payload: { invoice_id: "inv_123", amount: 4900 } },
{ idempotencyKey: "invoice-paid:inv_123" },
);
Go, before (github.com/frain-dev/convoy-go/v2) and after (github.com/webhookrelay/webhookrelay-go):
// Before
_, err := c.Events.FanoutEvent(ctx, &convoy.CreateFanoutEventRequest{
EventType: "invoice.paid",
OwnerID: "customer_42",
IdempotencyKey: "invoice-paid:inv_123",
Data: []byte(`{"invoice_id":"inv_123","amount":4900}`),
})
// After
_, err := api.PublishOutboundMessage(&webhookrelay.OutboundMessagePublishOptions{
Consumer: "customer_42",
EventType: "invoice.paid",
Payload: map[string]interface{}{"invoice_id": "inv_123", "amount": 4900},
IdempotencyKey: "invoice-paid:inv_123",
})
curl:
# Before
curl -X POST "$CONVOY_URL/v1/projects/$CONVOY_PROJECT_ID/events/fanout" \
-H "Authorization: Bearer $CONVOY_API_KEY" -H 'Content-Type: application/json' \
-d '{"owner_id":"customer_42","event_type":"invoice.paid","idempotency_key":"invoice-paid:inv_123","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: invoice-paid:inv_123' \
-d '{"consumer":"customer_42","event_type":"invoice.paid","payload":{"invoice_id":"inv_123","amount":4900}}'
Add a customer and an endpoint
In Convoy, an endpoint and its subscription are two calls. In the Outbound Gateway, the endpoint carries its event types:
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 keep an existing Convoy secret that fits the format, pass it in secret. The SDKs add a secret option in their next release; until then, use the REST call:
# CONVOY_SECRET holds the endpoint's current secret. Never echo it.
jq -n --arg url "https://acme.example/webhooks" --arg secret "$CONVOY_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}'
A secret that doesn't fit is refused with 400 (the error never echoes it); create that endpoint without secret. For a few customers, the dashboard's Add endpoint dialog has an optional Existing signing secret field.
Go: api.UpsertOutboundConsumer(...), api.CreateOutboundEndpoint(...), api.RotateOutboundEndpointSecret(id), api.RetryOutboundDelivery(endpointID, messageID). MCP: upsert_outbound_consumer, create_outbound_endpoint (accepts secret), publish_outbound_message, rotate_outbound_endpoint_secret, retry_outbound_delivery.
Signature changes for your customers
| Convoy | Outbound Gateway | |
|---|---|---|
| Signature header | X-Convoy-Signature | webhook-signature: v1,<base64> |
| Simple format | One HMAC of the payload | n/a |
| Advanced format | t=<timestamp>,v1=<sig>[,v0=<sig>] over {timestamp},{payload} | v1,<sig> over {webhook-id}.{webhook-timestamp}.{body} |
| Hash and encoding | Set in the project's signature settings | HMAC-SHA256, base64 |
| Secret | Per endpoint, used as the raw HMAC key | Imported when it fits the format, otherwise a new whsec_… (key = base64-decoded part after whsec_) |
| ID header | X-Convoy-Idempotency-Key (default) | webhook-id |
Convoy's signature formats are described in its signatures documentation. Check your project's settings for the hash, the encoding and whether endpoints use advanced signatures.
Every receiver needs Standard Webhooks verification: send your customers the receiving guide or point them at any Standard Webhooks library.
When a secret was imported, the customer keeps the same secret value in their configuration, but the two checks use it differently: Convoy uses the string itself as the HMAC key, while a Standard Webhooks library strips an optional whsec_ prefix and base64-decodes the rest. Webhook Relay signs with that same decoded key, so the value works as long as the receiver verifies Webhook Relay deliveries with a Standard Webhooks library (or the receiving guide's code). Only standard base64 qualifies: hex strings are valid base64 characters and import, while URL-safe base64 (-, _) and UUIDs don't.
During the migration window the receiver accepts either provider:
import { verifyWebhook } from "./verify-webhook.js"; // Standard Webhooks check from the receiving guide
import { verifyConvoy } from "./verify-convoy.js"; // your receiver's existing Convoy check
export function verify(rawBody, headers) {
if (headers["webhook-signature"]) {
// The imported Convoy secret, or the new whsec_ secret if it couldn't be imported.
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;
}
Deduplicate on webhook-id for Webhook Relay deliveries. Because each provider assigns its own IDs, include your own event ID in the payload if receivers must deduplicate across providers.
Cutover plan
- Import. Run the import script with
DRY_RUN=true, review the report, then withDRY_RUN=false. It creates event types, one consumer per owner ID and each endpoint with its subscribed event types and, where it fits the format, its current secret, 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. - Shadow publish. Next to every Convoy event call, publish to Webhook Relay with the owner ID as the consumer and the same idempotency key, behind a feature flag. Replace broadcast calls with one publish per consumer. Convoy still delivers. Webhook Relay records each message as
rejectedon the paused endpoints. Rollback: turn the flag off. - Receiver changes. The signature scheme changes, so every customer's receiver adds Standard Webhooks verification and accepts either provider as shown above. Customers whose secret was imported keep their secret value; the others also need their new secret: show it in your product (
POST /v1/outbound/endpoints/{id}/secret/reveal). Send everyone the receiving guide. Rollback: nothing to undo. - Switch, one customer at a time (needs approval). Resume the customer's Webhook Relay endpoints and pause their Convoy endpoints. Don't delete anything, and don't call
replay-missingfor the shadow period. Start with an internal test owner. Rollback: pause the Webhook Relay endpoints and activate the Convoy endpoints. - End dual publish. When every customer is switched and Convoy has no deliveries left in retry, remove the Convoy calls and SDK. Rollback: restore the calls; the Convoy project is untouched.
- Decommission. A human shuts down the Convoy deployment or plan after exporting any history they need.
Verify
- 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\":[\"*\"]}" - Publish a test message to
migration-testthrough your new code path and read the capture withcurl -s https://bin.webhookrelay.com/v1/bins/$BIN | jq '.requests[0]'. - 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.
- During shadow publishing, compare per owner and event type: Convoy's events (
GET /v1/projects/{projectID}/events) againstGET /v1/outbound/messages?consumer=<id>, and therejecteddeliveries on each paused endpoint (GET /v1/outbound/endpoints/{id}/deliveries?status=rejected). - After switching a customer, watch
GET /v1/outbound/endpoints/{id}/deliveries?status=failedandGET /v1/outbound/health.
Import script
This script reads Convoy (read-only), including each endpoint's current secret, and creates the same configuration in Webhook Relay. It checks your plan limits before writing anything, 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-convoy.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.
// migrate-convoy.mts
// Env: CONVOY_URL (e.g. https://us.getconvoy.cloud/api), CONVOY_API_KEY, CONVOY_PROJECT_ID,
// 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 CONVOY = `${process.env.CONVOY_URL}/v1/projects/${process.env.CONVOY_PROJECT_ID}`;
const DRY_RUN = process.env.DRY_RUN !== "false";
const STATE_FILE = "migration-state.json"; // source endpoint ID -> Webhook Relay endpoint ID. Never secrets.
const ID = /^[a-zA-Z0-9][a-zA-Z0-9_.:-]{0,127}$/;
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 (Convoy's 25-character generated secrets, URL-safe base64, UUIDs) 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;
}
// The current Convoy secret is the live entry without an expiry (rolled secrets get expires_at).
function currentSecret(secrets: any[] | undefined): string | undefined {
const live = (secrets ?? []).filter((s) => !s.deleted_at);
return (live.find((s) => !s.expires_at) ?? live.at(-1))?.value;
}
type Plan = {
eventTypes: { name: string; description?: string; deprecated?: boolean }[];
consumers: { id: string; name?: string; endpoints: PlanEndpoint[] }[];
warnings: string[];
};
// ---- 1. Export from Convoy (read-only) ----
async function convoy<T>(path: string, query: Record<string, string> = {}): Promise<T> {
const qs = new URLSearchParams(query).toString();
const res = await fetch(`${CONVOY}${path}${qs ? `?${qs}` : ""}`, {
headers: { Authorization: `Bearer ${process.env.CONVOY_API_KEY}` },
});
if (!res.ok) throw new Error(`Convoy GET ${path}: ${res.status} ${await res.text()}`);
return ((await res.json()) as { data: T }).data;
}
async function* convoyList<T>(path: string, query: Record<string, string> = {}): AsyncGenerator<T> {
let cursor: string | undefined;
for (;;) {
const page = await convoy<{ content: T[]; pagination?: { has_next_page: boolean; next_page_cursor: string } }>(
path, { ...query, perPage: "100", direction: "next", ...(cursor ? { next_page_cursor: cursor } : {}) });
yield* page.content ?? [];
if (!page.pagination?.has_next_page) return;
cursor = page.pagination.next_page_cursor;
}
}
async function exportPlan(): Promise<Plan> {
const plan: Plan = { eventTypes: [], consumers: [], warnings: [] };
const types = new Map<string, { name: string; description?: string; deprecated?: boolean }>();
for (const et of await convoy<any[]>("/event-types")) {
types.set(et.name, { name: et.name, description: et.description || undefined, deprecated: Boolean(et.deprecated_at) });
}
const consumers = new Map<string, Plan["consumers"][number]>();
for await (const ep of convoyList<any>("/endpoints")) {
const consumerId = ep.owner_id || ep.name || ep.uid;
const secret = importableSecret(currentSecret(ep.secrets));
if (!secret) plan.warnings.push(`${consumerId} endpoint ${ep.uid}: secret doesn't fit the import format; the endpoint gets a new one`);
const eventTypes = new Set<string>();
for await (const sub of convoyList<any>("/subscriptions", { endpointId: ep.uid })) {
for (const t of sub.filter_config?.event_types ?? ["*"]) eventTypes.add(t);
if (sub.filter_config?.filter && JSON.stringify(sub.filter_config.filter) !== "{}")
plan.warnings.push(`${consumerId} ${ep.url}: subscription filters; recreate them as a Function`);
if (sub.function) plan.warnings.push(`${consumerId} ${ep.url}: subscription function; recreate it as a Function`);
}
if (ep.authentication) plan.warnings.push(`${consumerId} ${ep.url}: endpoint authentication; re-enter it as a custom header by hand`);
const list = eventTypes.has("*") || eventTypes.size === 0 ? ["*"] : [...eventTypes];
for (const t of list) if (t !== "*" && !types.has(t)) types.set(t, { name: t });
const consumer = consumers.get(consumerId) ?? { id: consumerId, name: ep.name, endpoints: [] as PlanEndpoint[] };
consumer.endpoints.push({
sourceId: ep.uid, url: ep.url, eventTypes: list, description: ep.description || undefined,
disabledAtSource: ep.status !== "active", secret,
});
consumers.set(consumerId, consumer);
}
plan.eventTypes = [...types.values()];
plan.consumers = [...consumers.values()];
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 > 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 ? " [not active at Convoy]" : ""}`);
}
}
}
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);
Older Convoy versions may not have the event types API. In that case, replace the /event-types call with an empty list: the script then builds the catalog from the subscriptions. With more than a few hundred endpoints, run the script in batches and review the output between runs. There is no bulk import endpoint.
Let your agent do it
Read https://webhookrelay.com/outbound-migration/convoy.md and follow it step by step in this repository.
- Inventory every use of the Convoy SDK and API (event, fan-out and broadcast calls, endpoint and subscription management, secret display, receiver docs), and note the signature settings our Convoy project uses.
- Check GET https://my.webhookrelay.com/v1/outbound/access: confirm enabled is true and that our owners, endpoints and event types fit within the returned limits. Stop and tell me if they don't.
- Import event types, consumers and endpoints with the playbook's script, dry run first, passing each endpoint's current secret where it fits the format. Every new endpoint must be paused. Report any filters, functions or endpoint authentication it couldn't carry over, and which endpoints got a new secret (by ID only).
- Add a Webhook Relay publish call next to every Convoy event call, behind a feature flag, with the same idempotency key. Replace broadcasts with one publish per consumer.
- 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 Convoy endpoints, subscriptions or projects. Stop and ask me for approval before resuming any Webhook Relay endpoint or pausing any Convoy endpoint.
Next steps
- Migration overview: limits, what doesn't carry over and the general cutover plan.
- Receive and verify outbound webhooks: send this to your customers before they switch.
- Monitor, retry and recover deliveries: delivery history, endpoint health, pause and resume.
Frequently asked questions
What does a Convoy owner ID become in Webhook Relay?
A consumer. Convoy fans an event out to every endpoint with the same owner_id. In the Outbound Gateway, a message published for a consumer goes to every endpoint of that consumer that subscribes to its event type, so the owner ID maps directly to the consumer ID. Endpoints without an owner ID each become their own consumer.
What do my customers change when I move from Convoy to Webhook Relay?
Their signature check. Convoy signs with X-Convoy-Signature using the hash and encoding set in your project, in a simple or an advanced format. Webhook Relay signs with the Standard Webhooks scheme: webhook-id, webhook-timestamp and webhook-signature, HMAC-SHA256 with a whsec_ secret per endpoint. During the migration window their receiver should accept either one.
Can I keep my Convoy endpoint secrets?
Only secrets in the Standard Webhooks format: 32 to 100 standard base64 characters, with or without a whsec_ prefix. Convoy's auto-generated endpoint secrets are 25 characters drawn from letters, digits, hyphens and underscores, which is too short, so those endpoints get a new whsec_ secret. Secrets you set yourself when creating Convoy endpoints are imported if they fit. Either way the signature scheme changes, so every receiver updates its verification code; with an imported secret it keeps the same secret value.
Is there an equivalent of Convoy broadcast events?
There is no broadcast call. Publish one message per consumer instead, each with its own idempotency key. Every endpoint of that consumer subscribed to the event type receives it.
Can Webhook Relay also replace Convoy for incoming webhooks?
Yes, as a separate feature. Convoy sources receive webhooks from providers. In Webhook Relay, buckets with inputs and outputs receive and forward incoming webhooks, and the Outbound Gateway sends webhooks to your customers. This guide covers the outbound side.
