Migrate a homegrown webhook sender
Replace a homegrown webhook sender (Postgres outbox, cron job, SQS, Sidekiq or Celery retry worker) with the Webhook Relay Outbound Gateway: what maps to what, publishing from your worker, importing the signing secrets you already issued, the signature change, a dual-publish cutover with rollback and an import script.
A homegrown sender usually has two halves: deciding what happened, and getting it to the customer. The first half stays in your code. The second half (the HTTP call, the retry worker, signing, per-customer pacing, dead endpoint handling and the delivery log) moves to the Outbound Gateway. The smallest change is to keep your queue or outbox and swap the worker's HTTP POST for one publish call. The signing secrets you already issued can be imported when they are in base64 form, so customers keep the secret value they have.
Read the migration overview first for the plan limits and the general cutover plan. For what the Outbound Gateway replaces and how it compares with building it yourself or using another provider, see the Outbound Gateway overview and the webhooks-as-a-service comparison.
Concept map
| Your system | Outbound Gateway | Notes |
|---|---|---|
customers / accounts row | Consumer | Use your customer ID as the consumer ID (up to 128 letters, numbers, dots, colons, underscores or hyphens). |
webhook_endpoints row (URL, secret, event filter) | Endpoint | Public HTTPS URL and event_types (or ["*"]). The existing secret is imported when it fits the format, otherwise the endpoint gets a new whsec_ secret. Endpoints per consumer depend on your plan (10 on Basic, 25 on Business, 100 on Pro). |
Event names in your code (invoice.paid) | Event types | Register them once. |
| Outbox row / queue message / job | Message | Publish with the row or job ID as the idempotency key. |
| Retry worker, backoff, dead-letter queue | Retries | Up to 8 attempts within 48 hours, Retry-After honored. |
| Per-customer throttling | Endpoint rate | 50 deliveries per second by default, up to 1,000. |
| "Disable after N failures" logic | auto_disable, endpoint states | failing after 5 failures in a row; optional disable after 100 failures over at least 12 hours. |
webhook_deliveries / attempts table | Delivery history | Every attempt with request headers and response, 35 days. |
| "Resend" button in your admin | Retry, recover, replay | POST /v1/outbound/endpoints/{id}/retry, .../recover, .../replay-missing. |
| Per-customer payload tweaks | Functions | Reshape the JSON body or drop the message per endpoint. |
Keep your own endpoint management UI. There is no embeddable consumer portal, so your product keeps calling the API when a customer adds or removes an endpoint.
Code map
Keep the outbox, swap the worker's HTTP call
Before: a worker reads the outbox, signs the body itself, POSTs to every endpoint and schedules retries.
// Before: your worker delivers and retries
for (const row of await db.claimOutboxRows(100)) {
for (const ep of await db.endpointsFor(row.customer_id, row.event_type)) {
const body = JSON.stringify(row.payload);
const sig = crypto.createHmac("sha256", ep.secret).update(body).digest("hex");
const res = await fetch(ep.url, { method: "POST", body, headers: { "content-type": "application/json", "x-signature": sig } });
if (!res.ok) await db.scheduleRetry(row.id, ep.id, backoff(row.attempts));
}
await db.markDone(row.id);
}
After: the worker publishes once per outbox row. Webhook Relay finds the subscribed endpoints, signs, delivers and retries.
import { WebhookRelay } from "@webhookrelay/sdk";
const relay = new WebhookRelay(); // reads RELAY_KEY and RELAY_SECRET ("Outbound: publish only" token)
for (const row of await db.claimOutboxRows(100)) {
await relay.outbound.messages.publish(
{ consumer: row.customer_id, eventType: row.event_type, eventId: String(row.id), payload: row.payload },
{ idempotencyKey: `outbox:${row.id}` }, // a crash before markDone re-publishes safely
);
await db.markDone(row.id);
}
Go:
for _, row := range rows {
_, err := api.PublishOutboundMessage(&webhookrelay.OutboundMessagePublishOptions{
Consumer: row.CustomerID,
EventType: row.EventType,
EventID: strconv.FormatInt(row.ID, 10),
Payload: row.Payload,
IdempotencyKey: "outbox:" + strconv.FormatInt(row.ID, 10),
})
if err != nil {
return err // leave the row unclaimed; the next run retries with the same key
}
if err := markDone(ctx, row.ID); err != nil {
return err
}
}
Celery, Sidekiq or any language without an SDK
Call the REST API directly. A 429 means 10,000 messages are already waiting to be prepared; retry later with the same key.
# Celery task (Python): replaces the task that POSTed to customer endpoints
import os, requests
from celery import shared_task
@shared_task(bind=True, max_retries=10, default_retry_delay=30)
def publish_webhook(self, outbox_id, customer_id, event_type, payload):
res = requests.post(
"https://my.webhookrelay.com/v1/outbound/messages",
auth=(os.environ["RELAY_KEY"], os.environ["RELAY_SECRET"]),
headers={"Idempotency-Key": f"outbox:{outbox_id}"},
json={"consumer": customer_id, "event_type": event_type, "event_id": str(outbox_id), "payload": payload},
timeout=10,
)
if res.status_code == 429 or res.status_code >= 500:
raise self.retry()
res.raise_for_status() # 202 Accepted
# Sidekiq job (Ruby)
require "net/http"
require "json"
class PublishWebhookJob
include Sidekiq::Job
def perform(outbox_id, customer_id, event_type, payload)
uri = URI("https://my.webhookrelay.com/v1/outbound/messages")
req = Net::HTTP::Post.new(uri, "Content-Type" => "application/json", "Idempotency-Key" => "outbox:#{outbox_id}")
req.basic_auth(ENV.fetch("RELAY_KEY"), ENV.fetch("RELAY_SECRET"))
req.body = { consumer: customer_id, event_type: event_type, event_id: outbox_id.to_s, payload: payload }.to_json
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(req) }
raise "publish failed: #{res.code}" unless res.code == "202" # Sidekiq retries with the same key
end
end
curl, for a cron job or a shell script:
curl --user "$RELAY_KEY:$RELAY_SECRET" -X POST https://my.webhookrelay.com/v1/outbound/messages \
-H 'Content-Type: application/json' -H "Idempotency-Key: outbox:$OUTBOX_ID" \
-d "{\"consumer\":\"$CUSTOMER_ID\",\"event_type\":\"invoice.paid\",\"payload\":$PAYLOAD_JSON}"
If you used SQS (or another queue) only to feed the delivery worker, you can publish straight from the code that used to send the queue message: the 202 receipt is durable before delivery begins. Keep the queue if other consumers read it.
Endpoint management in your product
When a customer adds, changes or removes an endpoint in your UI, call the API instead of writing your webhook_endpoints table:
await relay.outbound.consumers.upsert(customer.id, { name: customer.name });
const endpoint = await relay.outbound.endpoints.create(customer.id, { url, eventTypes, autoDisable: true });
// Show endpoint.secret to the customer once; read it again later with relay.outbound.endpoints.revealSecret(endpoint.id).
await relay.outbound.endpoints.update(endpoint.id, { url, eventTypes: ["invoice.paid", "invoice.voided"] });
await relay.outbound.endpoints.delete(endpoint.id);
Go: api.UpsertOutboundConsumer, api.CreateOutboundEndpoint, api.UpdateOutboundEndpoint, api.DeleteOutboundEndpoint, api.RevealOutboundEndpointSecret. MCP: upsert_outbound_consumer, create_outbound_endpoint, update_outbound_endpoint, reveal_outbound_endpoint_secret. To bring an existing secret along, pass secret to create_outbound_endpoint or the REST call in the import script, or paste it into the Existing signing secret field of the dashboard's Add endpoint dialog.
Signature changes for your customers
Most homegrown senders sign with something like X-Signature: sha256=<hex HMAC of the body>. The Outbound Gateway uses Standard Webhooks:
| Typical homegrown | Outbound Gateway | |
|---|---|---|
| Signature header | X-Signature (or similar) | webhook-signature: v1,<base64> |
| Signed content | The body, sometimes with a timestamp | {webhook-id}.{webhook-timestamp}.{body} |
| Secret | One you generated | Imported when it fits the format, otherwise a new whsec_… per endpoint |
| ID header | Often none | webhook-id (stable across retries) |
Which secrets import. The Outbound Gateway accepts an existing secret of 32 to 100 standard base64 characters (A-Z, a-z, 0-9, +, /, padded or not), decoding to a key of at least 24 bytes, with or without the whsec_ prefix. Hex secrets of 32 or more characters usually fit, since hex digits are valid base64 characters. URL-safe base64 (-, _), UUIDs with hyphens and short keys don't fit. Those endpoints get a new whsec_ secret.
What an imported secret keeps. Standard Webhooks uses the base64-decoded secret as the HMAC key. Many homegrown senders use the raw secret string as the key instead (like createHmac("sha256", ep.secret) above). An imported secret therefore keeps the same value in your customer's configuration, and it verifies because their receiver switches to a Standard Webhooks library that decodes it the same way Webhook Relay does. It does not make your old HMAC code verify Webhook Relay deliveries.
So:
- You already sign with Standard Webhooks and your secrets import: receivers change nothing.
- You already sign with Standard Webhooks but a secret doesn't import: that customer switches to the new secret.
- Any other scheme: send your customers the receiving guide and have their receiver accept either scheme during the window. With an imported secret, both checks below read the same secret value.
import crypto from "node:crypto";
import { verifyWebhook } from "./verify-webhook.js"; // Standard Webhooks check from the receiving guide
export function verify(rawBody, headers) {
if (headers["webhook-signature"]) {
// With an imported secret, WEBHOOK_SECRET_RELAY is the same value as WEBHOOK_SECRET_OLD.
return verifyWebhook(rawBody, headers, process.env.WEBHOOK_SECRET_RELAY);
}
// Your current scheme, unchanged. This example: X-Signature: <hex HMAC-SHA256 of the body>.
const received = Buffer.from(headers["x-signature"] ?? "", "hex");
const expected = crypto.createHmac("sha256", process.env.WEBHOOK_SECRET_OLD).update(rawBody).digest();
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}
Webhook Relay's message IDs differ from any IDs you sent before. Include your own event ID in the payload if receivers must deduplicate across both senders.
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 customer and an endpoint per row in your endpoints table, passing each row's existing secret when it fits the format, then pauses every new endpoint. The report lists, by endpoint ID only, the endpoints that got a new secret. Rollback: nothing is live. Leave the endpoints paused or delete them. - Shadow publish. Make the worker publish each outbox row to Webhook Relay in addition to its own delivery, behind a feature flag. Your worker still delivers. Webhook Relay records each message as
rejectedon the paused endpoints. Rollback: turn the flag off. - Receiver changes, only where needed. Skip this for customers whose secret was imported if you already sign with Standard Webhooks. Otherwise send the receiving guide and have the receiver accept either scheme. Customers whose secret didn't import also need their new secret: show it in your product (
POST /v1/outbound/endpoints/{id}/secret/reveal). Rollback: nothing to undo: receivers still verify your current scheme. - Switch, one customer at a time (needs approval). Add a per-customer flag. For a switched customer, resume their Webhook Relay endpoints and make your worker skip its own delivery for them. Don't delete endpoint rows, and don't call
replay-missingfor the shadow period. Start with an internal test customer. Rollback: pause the Webhook Relay endpoints and clear the flag so your worker delivers again. - End dual delivery. When every customer is switched and your worker has no retries left, remove the worker's HTTP delivery, retry scheduling and signing code. Keep the outbox if you publish from it. Rollback: restore the worker code from version control; the endpoint rows are still there.
- Decommission. A human archives the old delivery tables 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\":[\"*\"]}" - Insert a test outbox row for
migration-testand let the worker publish it. 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 customer and event type: your own delivery table against
GET /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 your Postgres tables (read-only) and creates the configuration in Webhook Relay. Adjust the two SQL queries to your schema. It checks your plan limits before writing anything, reads each endpoint's existing secret into memory only and passes it on when it fits the format (it never prints, logs or writes a secret), pauses every endpoint it creates and stops at the first error with its progress saved. Run it with npx tsx migrate-diy.mts.
Endpoints are created with the REST call because the SDKs add a secret option to endpoint create in their next release; until then, create imported endpoints with the REST call. Everything else uses @webhookrelay/sdk.
// migrate-diy.mts
// Env: DATABASE_URL, RELAY_KEY, RELAY_SECRET (token with "Outbound: manage"), DRY_RUN=false to write.
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import pg from "pg";
import { WebhookRelay, WebhookRelayAPIError } from "@webhookrelay/sdk";
const RELAY_API = "https://my.webhookrelay.com";
const AUTH = "Basic " + Buffer.from(`${process.env.RELAY_KEY}:${process.env.RELAY_SECRET}`).toString("base64");
const DRY_RUN = process.env.DRY_RUN !== "false";
const STATE_FILE = "migration-state.json"; // your endpoint row ID -> Webhook Relay endpoint ID. Never secrets.
const ID = /^[a-zA-Z0-9][a-zA-Z0-9_.:-]{0,127}$/;
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 a file
};
type Plan = {
eventTypes: { name: string; description?: string; deprecated?: boolean }[];
consumers: { id: string; name?: string; endpoints: PlanEndpoint[] }[];
newSecrets: string[]; // source endpoint IDs whose secret can't be imported
warnings: string[];
};
// A secret imports if it is 32-100 standard base64 characters (a key of at least 24 bytes),
// with or without "whsec_". The API makes the final check and never echoes the secret.
function importable(raw: string | null | undefined): string | undefined {
const secret = raw?.trim();
if (!secret) return undefined;
const b64 = secret.startsWith("whsec_") ? secret.slice("whsec_".length) : secret;
if (b64.length < 32 || b64.length > 100 || !/^[A-Za-z0-9+/]+={0,2}$/.test(b64)) return undefined;
if (b64.replace(/=+$/, "").length % 4 === 1) return undefined;
const bytes = Buffer.from(b64, "base64").length;
return bytes >= 24 ? secret : undefined;
}
// ---- 1. Export from your database (read-only). Adjust to your schema. ----
async function exportPlan(): Promise<Plan> {
const db = new pg.Client({ connectionString: process.env.DATABASE_URL });
await db.connect();
try {
const { rows } = await db.query(`
SELECT e.id::text AS id, e.customer_id::text AS 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
ORDER BY e.customer_id, e.id`);
const { rows: types } = await db.query(`SELECT DISTINCT event_type AS name FROM webhook_outbox`);
const plan: Plan = { eventTypes: types.map((t) => ({ name: t.name })), consumers: [], newSecrets: [], warnings: [] };
const consumers = new Map<string, Plan["consumers"][number]>();
for (const r of rows) {
const consumer = consumers.get(r.customer_id) ?? { id: r.customer_id, name: r.customer_name, endpoints: [] as PlanEndpoint[] };
const eventTypes: string[] = r.event_types?.length ? r.event_types : ["*"];
for (const t of eventTypes) if (t !== "*" && !plan.eventTypes.some((x) => x.name === t)) plan.eventTypes.push({ name: t });
const secret = importable(r.secret);
if (!secret) plan.newSecrets.push(r.id);
consumer.endpoints.push({
sourceId: r.id, url: r.url, eventTypes, description: r.description ?? undefined, disabledAtSource: !r.enabled, secret,
});
consumers.set(r.customer_id, consumer);
}
plan.consumers = [...consumers.values()];
return plan;
} finally {
await db.end();
}
}
// ---- 2. Check access and limits before writing anything ----
async function checkPlan(plan: Plan) {
const res = await fetch(`${RELAY_API}/v1/outbound/access`, { headers: { Authorization: AUTH } });
const access = await res.json();
if (!access.enabled) throw new Error("The Outbound Gateway needs a Basic, Business or Pro plan: upgrade at https://my.webhookrelay.com/account.");
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 another 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 HTTPError extends Error {
constructor(readonly status: number, message: string) { super(message); }
}
const statusOf = (err: unknown) =>
err instanceof WebhookRelayAPIError || err instanceof HTTPError ? err.status : undefined;
// REST create, so the existing secret can be passed (the SDKs add a `secret` option in their next release).
async function createEndpoint(consumer: string, e: PlanEndpoint): Promise<{ id: string }> {
const res = await fetch(`${RELAY_API}/v1/outbound/consumers/${encodeURIComponent(consumer)}/endpoints`, {
method: "POST",
headers: { Authorization: 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 } : {}),
}),
});
// Error bodies never echo the secret; the success body contains it, so read only the ID.
if (!res.ok) throw new HTTPError(res.status, `POST endpoint: ${res.status} ${await res.text()}`);
const { id } = (await res.json()) as { id: string };
return { id };
}
async function step<T>(what: string, fn: () => Promise<T>): Promise<T> {
for (let attempt = 1; ; attempt++) {
try {
return await fn();
} catch (err) {
if (statusOf(err) === 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 (statusOf(err) === 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 = e.secret ? "secret imported" : "new secret";
console.log(`${c.id}: ${e.url} -> ${id} ${created ? `(created, paused, ${secretNote})` : "(already existed, unchanged)"}${e.disabledAtSource ? " [disabled in your DB]" : ""}`);
}
}
}
const plan = await exportPlan();
await checkPlan(plan);
const endpoints = plan.consumers.reduce((n, c) => n + c.endpoints.length, 0);
console.log(`Plan: ${plan.eventTypes.length} event types, ${plan.consumers.length} consumers, ${endpoints} endpoints.`);
console.log(`Secrets: ${endpoints - plan.newSecrets.length} import, ${plan.newSecrets.length} get a new whsec_ secret.`);
if (plan.newSecrets.length) console.log(`New secret for endpoint rows: ${plan.newSecrets.join(", ")}`);
if (DRY_RUN) console.log("Dry run: nothing was written. Run again with DRY_RUN=false to import.");
else await importPlan(plan);
Store the Webhook Relay endpoint ID next to your endpoint row (from migration-state.json) so your product can show the secret and delivery history for it. If the API refuses a secret that passed the local check (400), the script stops without printing it; clear that row's secret in your export query so the endpoint gets a new one, or fix the stored value, and run again. 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/diy.md and follow it step by step in this repository.
- Inventory how we send webhooks to customers today: the tables or queues that hold events and endpoints, the worker or job that POSTs them, the signing code, the retry logic and the receiver docs we give customers.
- Check GET https://my.webhookrelay.com/v1/outbound/access: confirm enabled is true and that our customers, endpoints and event types fit within the returned limits. Stop and tell me if they don't.
- Adapt the playbook's import script to our schema and import event types, consumers and endpoints, dry run first, passing each endpoint's existing signing secret when it fits the format. Every new endpoint must be paused. Report which endpoints get a new secret, by ID only.
- Make the worker also publish each event to Webhook Relay with a stable idempotency key (the outbox row or job ID), behind a feature flag. Keep our own delivery running.
- Run the playbook's verification checks with a Webhook Bin and report the results.
Never print, log or commit signing secrets. Never delete endpoint rows, outbox data or the old worker code. Stop and ask me for approval before resuming any Webhook Relay endpoint or turning off our own delivery for any customer.
Next steps
- Migration overview: limits, what doesn't carry over and the general cutover plan.
- Receive and verify outbound webhooks: send this to customers whose receivers change.
- Monitor, retry and recover deliveries: delivery history, endpoint health, pause and resume.
Frequently asked questions
Should I keep my outbox table when I move to Webhook Relay?
Keeping it is the safest option. Your transaction still writes the event to the outbox, and the worker that used to POST to customer endpoints publishes to Webhook Relay instead, using the outbox row ID as the idempotency key. If the worker crashes after publishing, the retry returns the original receipt and nothing is published twice. Webhook Relay then owns delivery, retries, signing and history.
Is there a Python or Ruby SDK for the Outbound Gateway?
Not yet. The SDKs are TypeScript and Go. From Python, Ruby or any other language, call the REST API directly: POST /v1/outbound/messages with HTTP Basic authentication, a JSON body and an Idempotency-Key header.
What happens to the signing secrets I issued to my customers?
If a secret is 32 to 100 standard base64 characters (decoding to a key of at least 24 bytes, with or without the whsec_ prefix), pass it as the secret field when you create the endpoint and your customer keeps the same secret value. Hex secrets usually fit because hex digits are valid base64 characters. URL-safe base64, UUIDs with hyphens and shorter keys don't fit, and those endpoints get a new whsec_ secret. Deliveries are signed with the Standard Webhooks scheme either way, so unless you already sign with Standard Webhooks, your customers switch their verification code once.
If my secret is imported, do my customers still change their receiver?
Only if your current signature scheme isn't Standard Webhooks. Standard Webhooks base64-decodes the secret to get the HMAC key, while many homegrown senders use the raw secret string as the key. An imported secret therefore keeps the same value in your customer's configuration, but they verify it with a Standard Webhooks library instead of your old HMAC code. If you already sign with Standard Webhooks, receivers change nothing.
Can I keep my own retry schedule?
No. The Outbound Gateway retries on a fixed schedule: after 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours and 10 hours, for up to eight attempts within 48 hours, honoring Retry-After on 429 and 503 responses. You can also retry one delivery, re-send failed deliveries or replay skipped ones for 35 days.
