DocumentationMigrate from Svix

Migrate from Svix

Move outbound webhooks from Svix to the Webhook Relay Outbound Gateway: import your Svix endpoint secrets so customers keep verifying with their current key, publish to both providers, then switch one customer at a time. Concept and API map, rollback, verification and a TypeScript import script.

Moving from Svix is a rename plus a secret import. A Svix application becomes a consumer, endpoints stay endpoints (with their existing whsec_ secrets), event types keep their names, and svix.message.create becomes relay.outbound.messages.publish. Deliveries keep the Standard Webhooks signature, and Svix's libraries verify them, so customers who verify with a library change nothing. The cutover is three moves: import secrets, dual publish, switch.

Read the migration overview first for the plan limits, secret import rules and the general cutover plan. For pricing and features side by side, see the Outbound Gateway overview, the Svix alternative post and the webhooks-as-a-service comparison.

Concept map

SvixOutbound GatewayNotes
EnvironmentWebhook Relay accountMigrate one Svix environment at a time.
Application (uid or app_… ID)ConsumerUse the application's uid (usually your customer ID) as the consumer ID. Consumer IDs are up to 128 letters, numbers, dots, colons, underscores or hyphens.
EndpointEndpointPublic HTTPS URL. Import the Svix endpoint's whsec_ secret with the secret field.
Endpoint eventTypes (empty means all)Endpoint event_types (["*"] means all)Up to 100 event types per endpoint.
Event typeEvent typeSame name, up to 128 characters.
MessageMessageUp to 256 KiB of JSON per message (Svix accepts up to 1 MiB).
eventId / idempotency-keyevent_id / Idempotency-Key
Endpoint throttleRateEndpoint rateDeliveries per second, 50 by default, up to 1,000.
Endpoint headersEndpoint headersUp to 32. webhook-* headers are reserved. Svix returns sensitive header values redacted, so re-enter those by hand.
Disabled endpointPaused endpoint
ChannelsNo equivalentUse event types, or a Function on the endpoint to drop messages.
App PortalNo equivalentManage endpoints from your own product with the API.
TransformationsFunctionsOutbound Functions can reshape the JSON body or drop the message.
Asymmetric signing (whsk_ / whpk_)Not supportedThose endpoints get a new HMAC whsec_ secret.

API map

TaskSvixOutbound Gateway
Create a customerPOST /api/v1/appPUT /v1/outbound/consumers/{id}
Add an endpointPOST /api/v1/app/{app_id}/endpointPOST /v1/outbound/consumers/{consumer}/endpoints
Import an existing secretsecret on endpoint createsecret on endpoint create (same format)
Add an event typePOST /api/v1/event-typePOST /v1/outbound/event-types or PUT /v1/outbound/event-types/{name}
PublishPOST /api/v1/app/{app_id}/msgPOST /v1/outbound/messages
Rotate a secretPOST /api/v1/app/{app_id}/endpoint/{endpoint_id}/secret/rotatePOST /v1/outbound/endpoints/{id}/secret/rotate
Read a secretGET /api/v1/app/{app_id}/endpoint/{endpoint_id}/secretPOST /v1/outbound/endpoints/{id}/secret/reveal
Resend one messagePOST /api/v1/app/{app_id}/msg/{msg_id}/endpoint/{endpoint_id}/resendPOST /v1/outbound/endpoints/{id}/retry
Recover failedPOST /api/v1/app/{app_id}/endpoint/{endpoint_id}/recoverPOST /v1/outbound/endpoints/{id}/recover
Replay missingPOST /api/v1/app/{app_id}/endpoint/{endpoint_id}/replay-missingPOST /v1/outbound/endpoints/{id}/replay-missing
Disable / enablePATCH the endpoint with disabledPOST /v1/outbound/endpoints/{id}/pause / .../resume
Failing endpointsPer applicationGET /v1/outbound/endpoints?state=failing across all consumers

Svix routes are from the Svix API reference.

Publish

Before, with Svix:

import { Svix } from "svix";

const svix = new Svix(process.env.SVIX_TOKEN!);

await svix.message.create(
  "customer_42",
  { eventType: "invoice.paid", eventId: "evt_9001", payload: { invoice_id: "inv_123", amount: 4900 } },
  { idempotencyKey: "invoice-paid:inv_123" },
);

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: "invoice-paid:inv_123" },
);

Go, before (github.com/svix/svix-webhooks/go) and after (github.com/webhookrelay/webhookrelay-go):

// Before
_, err := svixClient.Message.Create(ctx, "customer_42", models.MessageIn{
    EventType: "invoice.paid",
    Payload:   map[string]any{"invoice_id": "inv_123", "amount": 4900},
}, nil)

// 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: "invoice-paid:inv_123",
})

curl:

# Before
curl -X POST "https://api.us.svix.com/api/v1/app/customer_42/msg" \
  -H "Authorization: Bearer $SVIX_TOKEN" -H 'Content-Type: application/json' \
  -H 'idempotency-key: invoice-paid:inv_123' \
  -d '{"eventType":"invoice.paid","payload":{"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 with its Svix secret

Read the endpoint's current secret from Svix and pass it as secret when you create the Webhook Relay endpoint. The secret stays in memory: don't log it or write it to a file.

// Before
await svix.application.create({ name: "Acme", uid: "customer_42" });
await svix.endpoint.create("customer_42", { url: "https://acme.example/webhooks", eventTypes: ["invoice.paid"] });

// After: import an existing Svix endpoint with its current secret.
await relay.outbound.consumers.upsert("customer_42", { name: "Acme" });
const { key } = await svix.endpoint.getSecret("customer_42", svixEndpointId); // "whsec_…"

const auth = Buffer.from(`${process.env.RELAY_KEY}:${process.env.RELAY_SECRET}`).toString("base64");
const res = await fetch("https://my.webhookrelay.com/v1/outbound/consumers/customer_42/endpoints", {
  method: "POST",
  headers: { Authorization: `Basic ${auth}`, "Content-Type": "application/json" },
  body: JSON.stringify({ url: "https://acme.example/webhooks", event_types: ["invoice.paid"], auto_disable: true, secret: key }),
});
if (!res.ok) throw new Error(`create endpoint: ${res.status}`); // errors never echo the secret
const { id } = await res.json();

// New customers after the migration: let Webhook Relay generate the secret.
const endpoint = await relay.outbound.endpoints.create("customer_43", {
  url: "https://globex.example/webhooks",
  eventTypes: ["invoice.paid"],
  autoDisable: true,
});
// endpoint.secret is the new whsec_ secret. Store it like a password.

The SDKs add a secret option to endpoints.create (Go: OutboundEndpointOptions.Secret) in their next release; until then, create imported endpoints with the REST call above. For a handful of customers, the dashboard's Add endpoint dialog has an Existing signing secret field. MCP: create_outbound_endpoint accepts secret.

Go: api.UpsertOutboundConsumer(...), api.CreateOutboundEndpoint(...), api.RotateOutboundEndpointSecret(id), api.RetryOutboundDelivery(endpointID, messageID). MCP: upsert_outbound_consumer, create_outbound_endpoint, publish_outbound_message, rotate_outbound_endpoint_secret, retry_outbound_delivery.

What changes for your customers

SvixOutbound Gateway
ID headersvix-idwebhook-id
Timestamp headersvix-timestampwebhook-timestamp
Signature headersvix-signature (v1,<base64>)webhook-signature (v1,<base64>)
Signed content{id}.{timestamp}.{body}, HMAC-SHA256Same
Secretwhsec_…The same whsec_…, imported
Extra headerswebhook-event-type, webhook-consumer

Svix documents that its libraries support both the svix- and the webhook- header names. So:

  • Receivers using a Svix library (or any Standard Webhooks library): no change. The secret is the same and the library reads the webhook-* headers.
  • Receivers that read svix-* headers by name in their own code: read the webhook-* names too. Ask these customers to accept both before you switch them.
  • Endpoints on asymmetric whsk_ keys: they get a new whsec_ secret. During the window, the receiver accepts either secret:
import { Webhook } from "svix";

// Old Svix key and new Webhook Relay secret. Only needed for endpoints whose secret couldn't be imported.
const verifiers = [process.env.WEBHOOK_SECRET_RELAY, process.env.WEBHOOK_SECRET_SVIX]
  .filter(Boolean)
  .map((secret) => new Webhook(secret));

export function verify(rawBody, headers) {
  for (const wh of verifiers) {
    try {
      return wh.verify(rawBody, headers); // accepts svix-* or webhook-* headers
    } catch {}
  }
  throw new Error("invalid signature");
}

If you rotate the imported secret later, Webhook Relay signs with both keys for 24 hours, the same as Svix.

Cutover plan

  1. Import secrets. Run the import script with DRY_RUN=true, review the report, then with DRY_RUN=false. It creates event types, one consumer per Svix application and each endpoint with its current Svix secret, then pauses every new endpoint. Rollback: nothing is live. Leave the endpoints paused or delete them.
  2. Dual publish. Next to every svix.message.create, call relay.outbound.messages.publish with the same consumer, event type, payload and idempotency key, behind a feature flag. Svix still delivers. Webhook Relay records each message as rejected on the paused endpoints. Rollback: turn the flag off.
  3. Switch, one customer at a time (needs approval). Resume the customer's Webhook Relay endpoints (POST /v1/outbound/endpoints/{id}/resume) and disable their Svix endpoints (PATCH with "disabled": true). Don't delete anything, and don't call replay-missing for the shadow period: Svix already delivered those messages. Start with an internal test customer. Customers who read svix-* headers by name, or whose endpoint got a new secret, switch only after they confirm the receiver change above. Rollback: pause the Webhook Relay endpoints and re-enable the Svix endpoints.
  4. End dual publish. When every customer is switched and Svix has no deliveries left in retry, remove the Svix call and the svix package from the publishing code. Rollback: restore the Svix call; its applications and endpoints are untouched.
  5. Decommission. A human closes the Svix environment after exporting any history they need.

Verify

  1. Create a free Webhook Bin and add it as an endpoint of a test consumer with a test secret in Svix's format, so you prove the import path too:
    BIN=$(curl -s -X POST https://bin.webhookrelay.com/v1/bins | jq -r .id)
    export TEST_SECRET="whsec_$(openssl rand -base64 24)"   # test-only secret
    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"}'
    jq -n --arg url "https://bin.webhookrelay.com/v1/webhooks/$BIN" --arg secret "$TEST_SECRET" \
      '{url: $url, event_types: ["*"], secret: $secret}' |
    curl -s --user "$RELAY_KEY:$RELAY_SECRET" -X POST https://my.webhookrelay.com/v1/outbound/consumers/migration-test/endpoints \
      -H 'Content-Type: application/json' --data @- | jq '{id, state}'
    
  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. Verify the capture with TEST_SECRET using your customers' sample receiver code and a Svix library, or paste it into the Standard Webhooks signature verifier. It must pass without any code change.
  4. During dual publishing, compare per customer and event type: Svix's message count (GET /api/v1/app/{app_id}/msg) against GET /v1/outbound/messages?consumer=<id>, and confirm each paused endpoint shows the expected rejected deliveries (GET /v1/outbound/endpoints/{id}/deliveries?status=rejected).
  5. After switching a customer, check GET /v1/outbound/endpoints/{id}/deliveries?status=failed, GET /v1/outbound/endpoints?state=failing and GET /v1/outbound/health, or the dashboard's Endpoint health tab.

Import script

This script reads Svix (read-only), including each endpoint's secret, and creates the same configuration in Webhook Relay. It checks your plan limits before writing anything, 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, so you can fix the cause and run it again. Endpoints are created with the REST call because the SDKs don't expose secret until their next release. Run it with npx tsx migrate-svix.mts.

// migrate-svix.mts
// Env: SVIX_TOKEN, SVIX_API_URL (your region, e.g. https://api.us.svix.com or https://api.eu.svix.com),
//      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 SVIX_API = process.env.SVIX_API_URL ?? "https://api.us.svix.com";
const RELAY_API = "https://my.webhookrelay.com";
const RELAY_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"; // source endpoint 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;
// Svix's own symmetric secret format, which Webhook Relay accepts as is. Asymmetric whsk_ keys don't match.
const IMPORTABLE = /^(whsec_)?[A-Za-z0-9+/=]{32,100}$/;

const relay = new WebhookRelay();

type PlanEndpoint = {
  sourceId: string; url: string; eventTypes: string[]; description?: string;
  headers?: Record<string, string>; rate?: number; disabledAtSource?: boolean;
  secret?: string; // held in memory only
};
type Plan = {
  eventTypes: { name: string; description?: string; deprecated?: boolean }[];
  consumers: { id: string; name?: string; endpoints: PlanEndpoint[] }[];
  warnings: string[];
};

// ---- 1. Export from Svix (read-only) ----
async function svix<T>(path: string, query: Record<string, string> = {}): Promise<T> {
  const url = new URL(path, SVIX_API);
  for (const [k, v] of Object.entries(query)) url.searchParams.set(k, v);
  const res = await fetch(url, { headers: { Authorization: `Bearer ${process.env.SVIX_TOKEN}` } });
  if (!res.ok) throw new Error(`Svix GET ${path}: ${res.status}`); // no body: the secret route's body is the secret
  return res.json() as Promise<T>;
}

async function* svixList<T>(path: string): AsyncGenerator<T> {
  let iterator: string | undefined;
  for (;;) {
    const page = await svix<{ data: T[]; done: boolean; iterator: string }>(path, {
      limit: "100", ...(iterator ? { iterator } : {}),
    });
    yield* page.data;
    if (page.done) return;
    iterator = page.iterator;
  }
}

async function exportPlan(): Promise<Plan> {
  const plan: Plan = { eventTypes: [], consumers: [], warnings: [] };
  for await (const et of svixList<any>("/api/v1/event-type")) {
    if (!et.archived) plan.eventTypes.push({ name: et.name, description: et.description, deprecated: et.deprecated });
  }
  for await (const app of svixList<any>("/api/v1/app")) {
    const consumer = { id: app.uid ?? app.id, name: app.name, endpoints: [] as PlanEndpoint[] };
    for await (const ep of svixList<any>(`/api/v1/app/${app.id}/endpoint`)) {
      const h = await svix<{ headers: Record<string, string>; sensitive: string[] }>(
        `/api/v1/app/${app.id}/endpoint/${ep.id}/headers`);
      const headers = Object.fromEntries(Object.entries(h.headers).filter(([k]) => !RESERVED.test(k)));
      if (h.sensitive.length) plan.warnings.push(`${consumer.id} ${ep.url}: re-enter redacted headers by hand: ${h.sensitive.join(", ")}`);
      if (ep.channels?.length) plan.warnings.push(`${consumer.id} ${ep.url}: channels ${ep.channels.join(", ")} have no equivalent`);

      const { key } = await svix<{ key: string }>(`/api/v1/app/${app.id}/endpoint/${ep.id}/secret`);
      const secret = IMPORTABLE.test(key) ? key : undefined;
      if (!secret) plan.warnings.push(`${consumer.id} endpoint ${ep.id}: asymmetric key, gets a NEW secret (receiver must accept both)`);

      consumer.endpoints.push({
        sourceId: ep.id, url: ep.url,
        eventTypes: ep.eventTypes?.length ? ep.eventTypes : ["*"],
        description: ep.description || undefined, headers,
        rate: ep.throttleRate ? Math.min(ep.throttleRate, 1000) : undefined,
        disabledAtSource: ep.disabled, secret,
      });
    }
    plan.consumers.push(consumer);
  }
  return plan;
}

// ---- 2. Check access and limits before writing anything ----
async function checkPlan(plan: Plan) {
  const res = await fetch(`${RELAY_API}/v1/outbound/access`, { headers: { Authorization: RELAY_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 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. Errors never echo the secret.
async function createEndpoint(consumer: string, e: PlanEndpoint): Promise<string> {
  const res = await fetch(`${RELAY_API}/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 carries 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 { id: 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 Svix]" : ""}`);
    }
  }
}

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 imported secrets).`);
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, filter plan.consumers by ID prefix) and review the output between runs. There is no bulk import endpoint.

Let your agent do it

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

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

  1. Inventory every use of the Svix SDK and Svix API (publishing, application and endpoint management, secret display, receiver docs).
  2. Check GET https://my.webhookrelay.com/v1/outbound/access: confirm enabled is true and that our Svix applications, endpoints and event types 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 Svix endpoint's current secret through so customers keep their key. Every new endpoint must be paused.
  4. Add a Webhook Relay publish call next to every Svix publish call, behind a feature flag, with the same idempotency key.
  5. Run the playbook's verification checks with a Webhook Bin and report the results, including any endpoints that got a new secret or customers that read svix-* headers by name.

Never print, log or commit signing secrets. Never delete or modify Svix applications or endpoints. Stop and ask me for approval before resuming any Webhook Relay endpoint or disabling any Svix endpoint.

Next steps

Frequently asked questions

Do my customers need to change their code when I move from Svix to Webhook Relay?

Usually not. You import each Svix endpoint's existing whsec_ secret, so customers keep the same key. Webhook Relay sends webhook-id, webhook-timestamp and webhook-signature instead of the svix-* headers, with the same signing scheme, and Svix's verification libraries accept the unbranded webhook- headers. A receiver that verifies with a Svix or Standard Webhooks library needs no change. Only a receiver that reads the svix-* headers by name in its own code has to read the webhook-* names instead.

Can I import my Svix endpoint secrets?

Yes. Read each endpoint's secret with the Svix API (GET /api/v1/app/{app_id}/endpoint/{endpoint_id}/secret) and pass it as the secret field when you create the Webhook Relay endpoint. Webhook Relay accepts the same format Svix does: 32 to 100 standard base64 characters with an optional whsec_ prefix. The only exception is endpoints that use Svix's asymmetric whsk_ signing keys, which can't be imported and get a new whsec_ secret.

What does a Svix application become in Webhook Relay?

A consumer. Use the application's uid, which is usually your own customer ID, as the consumer ID so your publish code doesn't need a lookup table. Svix endpoints become endpoints of that consumer, and Svix event types become event types with the same names.

Is there a replacement for the Svix App Portal?

No. The Outbound Gateway doesn't have an embeddable consumer portal. Manage your customers' endpoints from your own product with the REST API or SDKs, and show them delivery history from GET /v1/outbound/endpoints/{id}/deliveries.

Did this page help you?