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, 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, or ask the dashboard agent.

1. Request access

The Outbound Gateway is in early access. Open Outbound webhooks in the dashboard and click Request to enable. This opens a support ticket, and we reply there when the feature is on.

You can check the status from code. enabled is true once the feature is turned on, and POST /v1/outbound/access/request makes the same request as the button:

curl --user "$RELAY_KEY:$RELAY_SECRET" https://my.webhookrelay.com/v1/outbound/access
# {"enabled":true}

Until access is enabled, every other /v1/outbound route returns 403.

2. Create an access token

Create a standalone token on the Access Tokens page. Once the Outbound Gateway is enabled, 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": { "role": "member", "outbound": "publish" }.

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.

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.

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.

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. 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

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 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?