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
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.
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:
That was enough to prove people wanted it. It wasn't enough to be trusted:
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.
Two parts of Macroplane talk to the gateway:
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.
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:
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).
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.
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.
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.
We test the integration at three levels.
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.
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:
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.
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.
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.
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.
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.
Today the catalog has price_alert.triggered and webhook.test. An endpoint subscribed to all events will receive any event types we add later.
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.
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.
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.
Today the catalog has price_alert.triggered and webhook.test. An endpoint subscribed to all events will receive any event types we add later.