Macroplane › Blog › How We Run Multi-Tenant Outbound Webhooks on Webhook Relay

How We Run Multi-Tenant Outbound Webhooks on Webhook Relay

2026-10-30

How Macroplane delivers signed, retried, per-user webhooks without running webhook infrastructure: one Webhook Relay consumer per user, idempotent publishes, a zero-downtime migration and how we test it.

More Macroplane supply-chain guides · Baskets · Investment theses · Macro trends

How We Run Multi-Tenant Outbound Webhooks on Webhook Relay

Macroplane sends webhooks to its users without running any webhook delivery infrastructure of its own. Our code decides what to send and to whom. The Webhook Relay Outbound Gateway signs every request, delivers it, retries it, rate-limits it and keeps the delivery log. This post explains how we wired it in: the tenancy model, the publish path, how we made alerts idempotent, how we migrated existing users, and how we test it. It also covers the gaps we hit along the way and how they got fixed upstream.

If you want to use webhooks rather than read about how they're built, start with Stock Price Alert Webhooks, which covers setup, the payload and signature verification.

Where we started

Price alerts watch the companies in a thesis or basket and fire when one moves past your threshold in a day. The first version of the webhook channel was what most products start with:

  • one URL stored on each price-alert subscription;
  • a POST from our server when the alert fired;
  • three retries over roughly two seconds, then give up.

That was enough to prove people wanted it. It wasn't enough to be trusted:

  • No signature. A receiver had no way to tell a real alert from a forged one.
  • No durability. If your server was deploying for 30 seconds, the alert was gone.
  • No visibility. Neither side could see what was sent, when, or what came back.
  • One URL per alert. Pointing ten alerts at a new server meant editing ten alerts.

Fixing that ourselves would have meant a queue, a retry scheduler with backoff, a signing service with secret rotation, a per-attempt log store with retention, rate limiting and auto-disable for dead endpoints. That's a webhook platform. We build supply-chain research, so we put the platform behind an API instead.

The shape of the integration

Two parts of Macroplane talk to the gateway:

  • The alert sweep publishes events. One price alert becomes one message.
  • Settings → Webhooks manages each user's endpoints: create, pause, rotate the secret, read the delivery log, send a test event.

Both go through a single Go package, pkg/webhooks, built on the official Go SDK. Nothing else in the codebase talks to Webhook Relay directly.

Tenancy: one consumer per user

The Outbound Gateway's model has three objects: consumers (your customers), endpoints (each consumer's URLs) and messages (events you publish to a consumer). That maps directly onto a multi-tenant product:

  • every Macroplane user is one consumer, with ID macroplane- + their user ID, created the first time they add an endpoint;
  • a user's endpoints belong to their consumer;
  • a message is published to one consumer, so it can only ever reach that user's endpoints.

The prefix matters more than it looks. Dev and production share one Webhook Relay account, and the prefix (macroplane-dev- locally) keeps their consumers from ever colliding.

The one rule we enforce ourselves is endpoint ownership. Settings routes take an endpoint ID from the request, so before any read, change, secret reveal or delivery-log lookup, the service loads the endpoint and checks who owns it:

Someone else's endpoint gets the same "not found" as an endpoint that doesn't exist, so IDs can't be probed. This check is the only tenancy logic in the integration, so it's also the most heavily tested part (see below).

Publishing an alert

When the sweep fires, the notification service publishes one price_alert.triggered message to the user's consumer:

Webhook Relay then delivers it to every endpoint of that user subscribed to the event, signs each request with that endpoint's secret (Standard Webhooks: webhook-id, webhook-timestamp, webhook-signature), retries failures with backoff, and records every attempt. Publishing succeeds once the gateway has durably accepted the message, so a slow receiver never holds up the sweep for anyone else.

The event catalog lives in code and is registered at startup. It has two types today, price_alert.triggered and webhook.test, each with an example payload that shows up in the gateway's dashboard. Adding an event means appending to that catalog and calling Publish.

Idempotency: one alert, one delivery

Cron jobs get retried. A sweep can fail halfway and run again, and a naive publisher would send the same alert twice. Each real alert gets a deterministic event ID built from the subscription, the minute it fired and a hash of the tickers involved:

That ID becomes the publish idempotency key. A second publish of the same alert returns the original message instead of creating a new one. Test sends from Settings deliberately carry no event ID, so pressing the button twice sends twice.

Migrating existing users without breaking them

Existing subscriptions had a webhook URL stored on them. On startup, the server walks the active subscriptions, creates an endpoint for each stored URL (reusing one if the user already has an endpoint with that URL), then clears the old column. The import is idempotent: a restart imports nothing new. URLs the gateway refuses, such as plain http://, stay on the subscription and get logged for follow-up rather than silently dropped.

The API stayed backwards compatible too. A client that still sends webhook_url when saving an alert has that URL imported as an endpoint, so older integrations keep working without changes.

Testing it

We test the integration at three levels.

  • An in-memory gateway. pkg/webhooks/webhookstest is a fake Outbound Gateway that enforces the rules we depend on: endpoints belong to one consumer, publishing to an unknown consumer is a 404, endpoints must be https, and a message only reaches the consumer's subscribed endpoints. Service and HTTP tests run two users against it and check that every route returns 404 for the other user's endpoint, and that a publish for one user never addresses the other's endpoint.
  • A live smoke test. go run ./cmd/webhooks-smoke runs the real path against the real API. It creates a throwaway user with an endpoint pointing at a free Webhook Bin, sends a price alert through the notification service, and waits for the delivery. It then checks the Standard Webhooks signature, the payload contract, that a duplicate publish delivers exactly once, tenant isolation, and the delivery log. Finally it deletes everything it created.
  • The full stack. Before release we ran the server against a copy of our data and drove Settings → Webhooks in a real browser: add an endpoint, copy the secret, send a test, read the delivery log.

Running it in production

The production integration is three settings: an API key, its secret and the consumer prefix. The credential is an access token restricted to outbound operations only: it can manage consumers, endpoints and messages, and nothing else in the Webhook Relay account. It lives in our secret store, not in the repository.

Day to day there's nothing to operate. When a user reports a missed alert, the answer is in the delivery log, which both they and we can see: the status code their server returned, how many retries ran, and when.

What we learned

Integrating a product made by people you can talk to means rough edges get fixed instead of worked around. We filed every snag we hit, and each fix is merged upstream and rolling out with the next releases:

  • Paused endpoints skip, they don't queue. Events published while an endpoint is paused are recorded as skipped, and resuming doesn't replay them. That's the right behaviour for price alerts, where a stale move isn't worth delivering. But our first draft of the UI copy said the opposite, and we only caught it in end-to-end testing. The publish receipt now lists skipped endpoints separately, so it says exactly who was skipped.
  • Testing one endpoint. Messages go to a consumer, so our "Send test event" button reaches all of a user's endpoints that subscribe to test events. The gateway's per-endpoint test send no longer counts toward the message quota, and we'll move the button onto it so a test reaches exactly the endpoint you clicked.
  • Typed errors in the Go SDK. We match on error strings to tell a 404 from a validation error. The SDK's next release returns a typed APIError with the status code and helpers like IsNotFound.
  • Quota-aware retries. The SDK retried every 429, including the one that means the monthly message quota is used up, which can't succeed until the next billing period. The fix stops retrying those and honours Retry-After for transient ones.
  • Least-privilege tokens. Creating an outbound-only token required a role field the docs didn't mention. The role now defaults sensibly.

One bug we found was ours, not the gateway's. Turning a price alert off and back on failed, because the unsubscribed row was soft-deleted but still held the unique index. The end-to-end test hit it on its second run, and it's fixed.

Should you build or buy outbound webhooks?

If webhooks are your product, build them. If they're a feature your customers expect, here's our rule of thumb: the core of a production webhook system is signing, durable retries, per-tenant isolation, delivery logs, secret rotation and idempotency. If you can't afford to spend weeks on that core, put it behind an API. Our whole integration is one Go package, a settings page and their tests.

The Outbound Gateway is available on every Webhook Relay plan, including Free. The outbound docs cover the consumer, endpoint and message model in more detail.

Does Macroplane store my webhook signing secret?

No. Endpoints and their secrets live in Webhook Relay, under a consumer that belongs to your Macroplane account. Macroplane asks the gateway for the secret when you reveal it in Settings and doesn't keep a copy.

Can another Macroplane user see or trigger my endpoints?

No. Every endpoint operation checks that the endpoint belongs to your consumer. Events are published to your consumer only, so they can only reach your endpoints.

What happens if Webhook Relay is unavailable when an alert fires?

The publish fails, the alert is recorded as not delivered, and the next sweep tries again. Alerts aren't silently dropped, and the idempotency key prevents a double delivery when the retry succeeds.

Can I receive events other than price alerts?

Today the catalog has price_alert.triggered and webhook.test. An endpoint subscribed to all events will receive any event types we add later.

Related reading

  • Stock Price Alert Webhooks: Signed Alerts for Your Bots and Workflows
  • What is Standard Webhooks?
  • Webhook Relay outbound webhooks documentation

Does Macroplane store my webhook signing secret?

No. Endpoints and their secrets live in Webhook Relay, under a consumer that belongs to your Macroplane account. Macroplane asks the gateway for the secret when you reveal it in Settings and doesn't keep a copy.

Can another Macroplane user see or trigger my endpoints?

No. Every endpoint operation checks that the endpoint belongs to your consumer. Events are published to your consumer only, so they can only reach your endpoints.

What happens if Webhook Relay is unavailable when an alert fires?

The publish fails, the alert is recorded as not delivered, and the next sweep tries again. Alerts aren't silently dropped, and the idempotency key prevents a double delivery when the retry succeeds.

Can I receive events other than price alerts?

Today the catalog has price_alert.triggered and webhook.test. An endpoint subscribed to all events will receive any event types we add later.

Referenced on this page

  • Stock Price Alert Webhooks