DocumentationSet up the Outbound Gateway

Set up the Outbound Gateway

Step-by-step setup for sending webhooks to your customers with Webhook Relay: access token, event types, consumers, endpoints and publishing with an idempotency key. Examples in curl, JavaScript and Go.

To send your first outbound webhook, sign up on any plan (Free included), create an outbound access token, add an event type and a consumer, register the consumer's HTTPS endpoint, then publish a message with an idempotency key. Every step below shows the REST call with curl, followed by the same flow using the TypeScript SDK (@webhookrelay/sdk) and the Go SDK (webhookrelay-go). You can also do all of this from the Outbound webhooks page in the dashboard, where the create dialogs show the equivalent SDK call next to the form, or ask the dashboard agent.

New to the product? The Outbound Gateway overview explains what it does, and the webhooks-as-a-service comparison compares it with Svix, Hookdeck Outpost and others.

1. Sign up and check access

The Outbound Gateway is generally available and self-serve on every plan, Free included (and Enterprise). Sign up, then open Outbound webhooks in the dashboard. No support ticket is needed. Business and Pro serve unlimited customers; every plan includes a monthly message allowance.

PlanPrice (monthly / annual)Customers (consumers)Endpoints per customerEvent typesMessages / month
Free$050520050,000
Basic$9.99 / $8.9910,00010300100,000
Business$79.99 / $71.99Unlimited255001,000,000
Pro$249.99 / $224.99Unlimited1001,0005,000,000

A message is one accepted publish: fan-out to every subscribed endpoint and retries don't count again, a payload up to 256 KiB is one message, and outbound messages don't count toward your plan's inbound webhook quota. Over the monthly message quota, publishing is refused with 429 until the next billing period, unless you add extra outbound message packs on the plan page: +100,000 messages for $5 a month on Basic, +1,000,000 for $20 on Business and +5,000,000 for $50 on Pro ($54, $216 and $540 a year). Enterprise plans can raise or remove any limit.

You can confirm from code. enabled is true on every plan, limits shows your account's effective limits (messages is the monthly quota including packs) and usage shows what you have used this billing period:

curl --user "$RELAY_KEY:$RELAY_SECRET" https://my.webhookrelay.com/v1/outbound/access
# {"enabled":true,"limits":{"consumers":50,"endpoints_per_consumer":5,"event_types":200,"messages":50000,...},"usage":{...}}

"enabled": false means outbound was turned off for the account by its subscription or by support; contact support to turn it back on. Until then, every other /v1/outbound route returns 403. MCP clients and the dashboard agent can call get_outbound_access for the same answer.

2. Create an access token

Create a standalone token on the Access Tokens page. On a plan with the Outbound Gateway, the token form has an Outbound option that restricts the token to the outbound API:

RestrictionAllowsUse it for
Outbound: publish onlyPublishing messagesThe service that emits events
Outbound: readReading configuration, messages and deliveriesDashboards and support tooling
Outbound: manageEvery outbound operationOnboarding code that creates consumers and endpoints

An outbound-restricted token can't call any other Webhook Relay API or subscribe to buckets or tunnels. Its permission role is still an upper limit: making changes or publishing requires Member or higher, while Viewer can only read. See access tokens for roles and the token REST API, where the same restriction is set as permissions.outbound (read, publish or manage), for example "permissions": { "role": "member", "outbound": "publish" }. A restricted token reaches only /v1/outbound/* routes and the outbound MCP tools; every other route refuses it.

Tokens are a key and a secret, sent with HTTP Basic authentication. The examples below read them from the environment:

export RELAY_KEY=YOUR_TOKEN_KEY
export RELAY_SECRET=YOUR_TOKEN_SECRET

Keep these credentials server-side; never publish from a browser or mobile app.

3. Create event types

Event types make up your catalog. Endpoints subscribe to them by name. Names can use up to 128 letters, numbers, dots, colons, underscores and hyphens. An optional example payload (up to 64 KiB) documents the event.

curl --user "$RELAY_KEY:$RELAY_SECRET" \
  -H 'Content-Type: application/json' \
  -X POST https://my.webhookrelay.com/v1/outbound/event-types \
  -d '{
    "name": "invoice.paid",
    "description": "An invoice was paid",
    "example": { "invoice_id": "inv_123", "amount": 4900 }
  }'

PUT /v1/outbound/event-types/{name} creates or updates an event type. Set "deprecated": true to stop new messages of that type from being published.

4. Create a consumer

A consumer is one of your customers. Use your own customer ID as the consumer ID. PUT creates the consumer or updates its name and default rate (deliveries per second for its endpoints; 0 means 50).

curl --user "$RELAY_KEY:$RELAY_SECRET" \
  -H 'Content-Type: application/json' \
  -X PUT https://my.webhookrelay.com/v1/outbound/consumers/customer_42 \
  -d '{ "name": "Acme" }'

A deleted consumer's ID can't be reused, so its delivery history always stays unambiguous.

5. Add the consumer's endpoint

Register the HTTPS URL your customer gives you and the event types it should receive (["*"] subscribes to all event types). The response includes the endpoint's signing secret (whsec_…). Give it to your customer so they can verify deliveries. If the customer already verifies with a secret from another provider, import it instead.

curl --user "$RELAY_KEY:$RELAY_SECRET" \
  -H 'Content-Type: application/json' \
  -X POST https://my.webhookrelay.com/v1/outbound/consumers/customer_42/endpoints \
  -d '{
    "url": "https://acme.example/webhooks",
    "event_types": ["invoice.paid"],
    "auto_disable": true
  }'

The response (abridged) carries the secret:

{
  "id": "6f0c2c0e-5a3b-4f43-9d0a-4c4f7d7a2b11",
  "consumer": "customer_42",
  "url": "https://acme.example/webhooks",
  "event_types": ["invoice.paid"],
  "state": "active",
  "auto_disable": true,
  "secret": "whsec_…"
}

Optional endpoint fields:

FieldDefaultNotes
descriptionemptyUp to 2,000 characters.
headersnoneUp to 32 extra request headers. webhook-*, Host, Content-Length, Transfer-Encoding and Connection are reserved. Custom headers are left out of the recorded attempt history.
rateconsumer's rate, else 50Deliveries per second, up to 1,000. 0 inherits the default.
timeout15Seconds per attempt, up to 60.
auto_disablefalseDisable the endpoint after 100 failed attempts in a row spanning at least 12 hours.
function_idnoneA Function that rewrites the JSON body or drops the message.

The URL must be public HTTPS, with no credentials or fragment. Hosts that resolve to private, loopback or link-local addresses are rejected. Each URL can be registered only once per consumer. Your code can retrieve the secret again later with POST /v1/outbound/endpoints/{id}/secret/reveal.

Import an existing signing secret

When a customer moves from another sender, pass their current secret as secret on create so they keep verifying with the key they already have. It must be 32 to 100 standard base64 characters (padded or not) that decode to at least 24 bytes; the whsec_ prefix is optional. That is the format Svix and other Standard Webhooks senders issue. URL-safe base64, UUIDs and asymmetric whsk_ / whpk_ keys are refused with 400, and the error never echoes the secret. Only the create call accepts secret; an imported secret is encrypted at rest and rotates like a generated one.

# EXISTING_SECRET comes from the old provider. Keep it out of logs and shell history.
jq -n --arg secret "$EXISTING_SECRET" \
  '{url: "https://acme.example/webhooks", event_types: ["invoice.paid"], secret: $secret}' |
curl -s --user "$RELAY_KEY:$RELAY_SECRET" \
  -H 'Content-Type: application/json' \
  -X POST https://my.webhookrelay.com/v1/outbound/consumers/customer_42/endpoints \
  --data @- | jq '{id, state}'

In the dashboard, the Add endpoint dialog has an optional Existing signing secret field, and the MCP tool create_outbound_endpoint accepts secret. In the Go SDK, set OutboundEndpointOptions.Secret (v0.8.0 and later). The TypeScript SDK adds a secret option to endpoint create in its next release; until then, use the REST call for imported secrets from TypeScript. See the migration guides for moving whole accounts.

6. Publish a message

Publish whenever the event happens in your system. Send an Idempotency-Key header derived from the event, so a retry after a timeout or crash can never publish twice. payload is any JSON value up to 256 KiB. event_id is an optional reference of your own (up to 128 characters) that is stored on the message receipt.

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

The API answers 202 Accepted with the durable receipt (without the payload). Delivery happens asynchronously:

{
  "id": "0199cf5e-7a3c-7d2e-9b1a-3f4e5d6c7b8a",
  "consumer": "customer_42",
  "event_type": "invoice.paid",
  "event_id": "evt_9001",
  "endpoint_ids": ["6f0c2c0e-5a3b-4f43-9d0a-4c4f7d7a2b11"],
  "created_at": "2026-10-10T09:12:44Z"
}

The message id is sent to every endpoint as the webhook-id header. It stays the same across retries and replays.

endpoint_ids lists the endpoints the message will be delivered to, so its length is the number of live destinations. A subscribed endpoint that is paused or disabled when you publish isn't in it. It is listed in skipped_endpoint_ids instead, and skip_reasons gives its state. Both fields are left out when nothing was skipped:

{
  "endpoint_ids": ["6f0c2c0e-5a3b-4f43-9d0a-4c4f7d7a2b11"],
  "skipped_endpoint_ids": ["9b2d7a10-3c4e-4f5a-8b6c-7d8e9f0a1b2c"],
  "skip_reasons": { "9b2d7a10-3c4e-4f5a-8b6c-7d8e9f0a1b2c": "paused" }
}

A skipped endpoint still gets a delivery, recorded as rejected, so you can replay it after you resume the endpoint.

ResponseMeaning
202Accepted, or an idempotent repeat that returns the original receipt.
400Invalid consumer or event type name, invalid JSON, or a deprecated event type.
404The consumer doesn't exist.
409The idempotency key was already used for a different message.
413The payload is larger than 256 KiB.
42910,000 messages are already waiting to be prepared for delivery, or the account reached its monthly message quota. For the backlog, retry later with the same key.

7. Check the delivery

GET /v1/outbound/messages/{id} returns the payload and one delivery per endpoint, including each attempt's status code, response headers and body. A message that was just accepted may still be in preparation, in which case it has no deliveries yet.

curl --user "$RELAY_KEY:$RELAY_SECRET" \
  https://my.webhookrelay.com/v1/outbound/messages/0199cf5e-7a3c-7d2e-9b1a-3f4e5d6c7b8a

Send a test event to one endpoint

A message goes to every endpoint of the consumer that subscribes to its event type. To check a single endpoint, for example from a "Send test event" button in your app, send a test event to that endpoint:

curl --user "$RELAY_KEY:$RELAY_SECRET" \
  -H 'Content-Type: application/json' \
  -H "Idempotency-Key: $(uuidgen)" \
  -X POST https://my.webhookrelay.com/v1/outbound/endpoints/6f0c2c0e-5a3b-4f43-9d0a-4c4f7d7a2b11/test \
  -d '{"event_type": "invoice.paid"}'

Only that endpoint receives it. The delivery is signed, retried and recorded in the endpoint's delivery history like any other, and the receipt has "test": true. The body is optional:

  • event_type defaults to the endpoint's first subscribed event type. It is required when the endpoint subscribes to all events (*).
  • payload defaults to the event type's example, or {"event_type": "...", "test": true} if it doesn't have one.

Test events don't count toward the monthly message quota. An account can send 100 test events in any 24 hours, and the next one answers 429. A paused or disabled endpoint answers 409 instead of skipping the test, so resume it first. In the dashboard, use Send test event on the endpoint page. Over MCP, use the send_outbound_test tool.

The same flow with the SDKs

JavaScript / TypeScript

Install with npm install @webhookrelay/sdk. With no arguments, the client reads RELAY_KEY and RELAY_SECRET (or a RELAY_API_KEY account API key) from the environment:

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

const relay = new WebhookRelay({ key: process.env.RELAY_KEY, secret: process.env.RELAY_SECRET });

// Once per customer: register the HTTPS endpoint they give you.
await relay.outbound.eventTypes.upsert("invoice.paid", { description: "An invoice was paid" });
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 signing secret your customer verifies deliveries with.

// Every time the event happens. Retrying with the same key never publishes twice.
const message = await relay.outbound.messages.publish(
  { consumer: "customer_42", eventType: "invoice.paid", payload: { invoice_id: "inv_123", amount: 4900 } },
  { idempotencyKey: "invoice-paid:inv_123" },
);

// Later: the payload and each endpoint's delivery with its attempts.
const detail = await relay.outbound.messages.get(message.id);

Go

Install with go get github.com/webhookrelay/webhookrelay-go:

package main

import (
    "log"
    "os"

    "github.com/webhookrelay/webhookrelay-go"
)

func main() {
    api, err := webhookrelay.New(os.Getenv("RELAY_KEY"), os.Getenv("RELAY_SECRET"))
    if err != nil {
        log.Fatal(err)
    }

    // Once per customer: register the HTTPS endpoint they give you.
    if _, err := api.UpsertOutboundEventType(&webhookrelay.OutboundEventTypeOptions{
        Name:        "invoice.paid",
        Description: "An invoice was paid",
    }); err != nil {
        log.Fatal(err)
    }
    if _, err := api.UpsertOutboundConsumer(&webhookrelay.OutboundConsumerOptions{ID: "customer_42", Name: "Acme"}); err != nil {
        log.Fatal(err)
    }
    endpoint, err := api.CreateOutboundEndpoint(&webhookrelay.OutboundEndpointOptions{
        Consumer:    "customer_42",
        URL:         "https://acme.example/webhooks",
        EventTypes:  []string{"invoice.paid"},
        AutoDisable: true,
    })
    if err != nil {
        log.Fatal(err)
    }
    log.Printf("give this secret to the customer: %s", endpoint.Secret)

    // Every time the event happens. Retrying with the same key never publishes twice.
    message, 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",
    })
    if err != nil {
        log.Fatal(err)
    }
    log.Printf("accepted message %s", message.ID)
}

If you leave IdempotencyKey empty, the Go SDK generates one for each call so that its own automatic retries can't publish twice. The TypeScript SDK sends a key only when you pass idempotencyKey. In both SDKs, you should pass a key derived from your event so that retries performed by your code are deduplicated too.

Next steps

Frequently asked questions

Which plans include the Outbound Gateway?

The Outbound Gateway is generally available on every plan, Free included, and on Enterprise. Free covers 50 customers with 5 endpoints each, 200 event types and 50,000 messages a month; Basic ($9.99 per month) 10,000 customers, 10 endpoints each, 300 event types and 100,000 messages; Business ($79.99) unlimited customers, 25 endpoints each, 500 event types and 1 million messages; Pro ($249.99) unlimited customers, 100 endpoints each, 1,000 event types and 5 million messages. Retries and fan-out don't count as extra messages, and message packs add more volume: +100,000 for $5 a month on Basic, +1 million for $20 on Business, +5 million for $50 on Pro.

Can I import an existing signing secret when I create an endpoint?

Yes. Pass the optional secret field when you create the endpoint: 32 to 100 standard base64 characters, with or without the whsec_ prefix, which is the format Svix and other Standard Webhooks senders use. The customer keeps verifying with the key they already have. Without it, Webhook Relay generates a new whsec_ secret.

Which credential should my backend use to publish outbound webhooks?

Use a standalone access token restricted to outbound operations. Choose 'Outbound: publish only' for a service that only publishes messages, and 'Outbound: manage' for code that also creates consumers and endpoints. A restricted token can only call /v1/outbound routes and the outbound MCP tools. It needs the Member role or higher to make changes; a Viewer token can only read.

What happens if I publish the same message twice?

If both calls send the same Idempotency-Key header with the same consumer, event type, event ID and payload, the second call returns the original receipt and nothing is published again. Reusing a key with different content is rejected with 409 Conflict. Without a key, every call publishes a new message.

Do I have to create event types before publishing?

No. An event type that is not in your catalog is added the first time you publish it. However, an endpoint can only subscribe to event types that already exist (or to all of them with *), so creating event types first is usually simpler. Deprecated event types cannot be published.

Does an endpoint receive messages published before it was created?

No. When a message is accepted, Webhook Relay records which of the consumer's endpoints subscribe to its event type. Endpoints added or subscribed later receive only messages published after the change.

Did this page help you?