Go client for the Webhook Relay v1 API.
Webhook Relay receives webhooks and HTTP requests on a public endpoint and delivers them wherever you need — public URLs, services behind a firewall or on localhost, WebSocket clients, or on a schedule — with in-flight transformations, durable retries, throttling and fine-grained routing. This library configures all of it from Go.
go get github.com/webhookrelay/webhookrelay-goRequires a working Go environment (Go 1.23+).
Generate credentials here. There are two ways to authenticate:
-
API key (recommended) — a single key (looks like
sk-...) sent as a Bearer token:api, err := webhookrelay.NewWithAPIKey(os.Getenv("RELAY_API_KEY"))
-
Key & secret pair — sent as HTTP Basic auth:
api, err := webhookrelay.New(os.Getenv("RELAY_KEY"), os.Getenv("RELAY_SECRET"))
Create a bucket (which comes with a ready-to-use public input endpoint) and forward everything it receives to a destination:
package main
import (
"fmt"
"log"
"os"
"github.com/webhookrelay/webhookrelay-go"
)
func main() {
api, err := webhookrelay.NewWithAPIKey(os.Getenv("RELAY_API_KEY"))
if err != nil {
log.Fatal(err)
}
// A bucket groups an input (public URL) with one or more outputs (destinations).
bucket, err := api.CreateBucket(&webhookrelay.BucketCreateOptions{
Name: "sendgrid-to-segment",
})
if err != nil {
log.Fatal(err)
}
// Every bucket gets a default input. Point your webhook provider at this URL:
fmt.Println("Send webhooks to:", bucket.Inputs[0].EndpointURL())
// Forward everything the bucket receives to a public destination.
if _, err := api.CreateOutput(&webhookrelay.Output{
BucketID: bucket.ID,
Name: "segment",
Destination: "https://webhooks.segment.com?b=yyyy",
}); err != nil {
log.Fatal(err)
}
}Mark an output as Internal (or just point it at a private/localhost address) and
it will only be delivered by the relay
agent running inside your network. This lets an external provider reach a service
that has no public IP.
// Public providers deliver to bucket.Inputs[0].EndpointURL(); the agent picks the
// webhooks up and forwards them to your internal service.
_, err = api.CreateOutput(&webhookrelay.Output{
BucketID: bucket.ID,
Name: "local-dev",
Destination: "http://localhost:8080/webhooks",
Internal: true, // relayed by the agent, not the public forwarder
})Then run the agent to open the connection:
relay forward --bucket sendgrid-to-segment http://localhost:8080/webhooksEnable streaming on a bucket to receive its webhooks in real time over a WebSocket
(wss://my.webhookrelay.com/v1/socket) — handy for browsers, dashboards, or any
client that can't expose an endpoint.
bucket, err := api.GetBucket("sendgrid-to-segment")
if err != nil {
log.Fatal(err)
}
bucket.Stream = true
if _, err := api.UpdateBucket(bucket); err != nil {
log.Fatal(err)
}UpdateBucket replaces the whole bucket configuration (name, description, flags
and auth), so always start from GetBucket as above; a bucket without a Name is
rejected.
When acting as a WebSocket "transponder" (your client, not Webhook Relay, produces
the HTTP response), send the response back within 10 seconds using UpdateWebhookLog:
err = api.UpdateWebhookLog(&webhookrelay.WebhookLogsUpdateRequest{
ID: logID, // the webhook's log ID, received over the socket
StatusCode: 200,
Status: webhookrelay.RequestStatusSent,
ResponseBody: []byte(`{"ok":true}`),
})Prefer to pull instead of being pushed? Poll a bucket's logs and process new
webhooks yourself. BucketID is required; filter by status, time range and paginate.
res, err := api.ListWebhookLogs(&webhookrelay.WebhookLogsListOptions{
BucketID: bucket.ID,
Status: webhookrelay.RequestStatusReceived,
Limit: 50,
})
if err != nil {
log.Fatal(err)
}
for _, entry := range res.Data {
fmt.Printf("%s %s -> %d\n", entry.Method, entry.ID, entry.StatusCode)
// entry.Body, entry.Headers, entry.RawQuery ...
}If a destination is down, durable delivery persists failing webhooks and keeps retrying on a backoff schedule until they succeed or hit a deadline — so an outage doesn't lose events.
import "time"
_, err = api.CreateOutput(&webhookrelay.Output{
BucketID: bucket.ID,
Name: "billing",
Destination: "https://api.internal.example.com/webhooks",
Durability: &webhookrelay.DurabilityConfig{
Enabled: true,
Schedule: "long", // "seconds", "medium", "long" or "custom"
Deadline: 30 * 24 * time.Hour, // give up after 30 days
HandoffAfter: 15 * time.Minute, // move to durable retries after 15m of failures
},
})Cap how fast webhooks are delivered to a fragile or rate-limited destination. Queue and release at a fixed rate, or bound concurrency.
// Rate limit: at most 10 deliveries per minute.
_, err = api.CreateOutput(&webhookrelay.Output{
BucketID: bucket.ID,
Name: "rate-limited-api",
Destination: "https://api.example.com/webhooks",
Throttle: &webhookrelay.ThrottleConfig{
Enabled: true,
Mode: webhookrelay.ThrottleModeRate,
Rate: 10,
Interval: webhookrelay.ThrottleIntervalMinute,
},
})
// Or bound in-flight concurrency instead:
throttle := &webhookrelay.ThrottleConfig{
Enabled: true,
Mode: webhookrelay.ThrottleModeConcurrency,
MaxConcurrent: 5,
MaxQueueDepth: 1000, // reject with HTTP 429 once the queue is full
}
_ = throttleRecover never-attempted deliveries that arrived while an internal agent was
disconnected. The server scans at most Limit recent rows inside Lookback.
_, err = api.CreateOutput(&webhookrelay.Output{
BucketID: bucket.ID,
Name: "cluster-agent",
Destination: "http://webhook-handler.default.svc:8080/webhooks",
Internal: true,
ReplayMissing: &webhookrelay.ReplayMissingConfig{
Enabled: true,
Lookback: 30 * time.Minute,
Limit: 250,
},
})Register each customer as a consumer with the HTTPS endpoint they give you, then
publish events. Webhook Relay signs every delivery
(Standard Webhooks), retries failures durably
for up to 48 hours and keeps the delivery history. GetOutboundAccess reports
whether outbound webhooks are enabled for your account, and its limits.
// Once per customer.
_, err = api.UpsertOutboundEventType(&webhookrelay.OutboundEventTypeOptions{Name: "invoice.paid"})
_, err = api.UpsertOutboundConsumer(&webhookrelay.OutboundConsumerOptions{ID: "customer_42", Name: "Acme"})
endpoint, err := api.CreateOutboundEndpoint(&webhookrelay.OutboundEndpointOptions{
Consumer: "customer_42",
URL: "https://customer.example/webhooks",
EventTypes: []string{"invoice.paid"},
})
// endpoint.Secret is the signing secret your customer verifies deliveries with.
// Migrating from Svix or another Standard Webhooks sender? Set Secret to the
// endpoint's existing whsec_ secret so your customer keeps verifying with it.
// Every time the event happens.
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", // retrying with the same key never publishes twice
})Publishing is asynchronous: the returned message is the durable acceptance
receipt. GetOutboundMessage shows each endpoint's delivery and attempts;
RetryOutboundDelivery, RecoverOutboundDeliveries and
ReplayMissingOutboundDeliveries re-send in the background and return a
recovery task. Without an IdempotencyKey the client generates one per call,
so its own automatic retries are safe.
Watch endpoint health across all your customers. Endpoints carry Stats
(attempts and failures over the last 24 hours) when listed or read:
health, err := api.GetOutboundHealth()
// health.Endpoints["failing"], health.Stats.Attempts, health.Stats.Failures
// Failing endpoints come longest failing first. Filter by Consumer too;
// Limit is 1-100 (0 means 50), page with Offset.
failing, err := api.ListAllOutboundEndpoints(&webhookrelay.OutboundEndpointListOptions{
State: webhookrelay.OutboundEndpointStateFailing,
Limit: 20,
})Customers who would rather receive events in their own cloud than at an HTTPS
URL get a cloud endpoint: an SQS queue, SNS topic, S3 bucket, Pub/Sub topic
or GCS bucket in their account (OutboundEndpointTypeAWSSQS, …AWSSNS,
…AWSS3, …GCPPubSub, …GCPGCS). Deliveries carry the body unchanged, with
the Standard Webhooks values as message attributes or object metadata.
Credentials are theirs, write-only and never returned: reads show
CredentialsIdentity (the access key ID or service account email), and an
update without Credentials keeps the stored ones. Custom headers apply to
webhooks only.
endpoint, err := api.CreateOutboundEndpoint(&webhookrelay.OutboundEndpointOptions{
Consumer: "customer_42",
Type: webhookrelay.OutboundEndpointTypeAWSSQS,
Destination: &webhookrelay.OutboundDestination{AWSSQS: &webhookrelay.OutboundAWSSQS{
QueueURL: "https://sqs.eu-west-1.amazonaws.com/123456789012/invoices",
}},
Credentials: &webhookrelay.OutboundEndpointCredentials{AWS: &webhookrelay.OutboundAWSCredentials{
AccessKeyID: "AKIA...", SecretAccessKey: "...",
}},
EventTypes: []string{"invoice.paid"},
})
// endpoint.URL is derived from the queue; endpoint.CredentialsIdentity == "AKIA..."Migrating many customers at once? StartOutboundImport creates event types,
consumers and endpoints (webhooks or cloud destinations, optionally with their
existing whsec_ secrets and starting Paused for a cutover) in one background
task of up to 5000 items. The whole import is validated first; a 400 APIError
lists up to 100 refused items in ImportErrors (its Message gives the total)
and writes nothing. Items that
already exist are left unchanged, so re-running an import is safe.
task, err := api.StartOutboundImport(&webhookrelay.OutboundImportOptions{
EventTypes: []webhookrelay.OutboundImportEventType{{Name: "invoice.paid"}},
Consumers: []webhookrelay.OutboundImportConsumer{{ID: "customer_42", Name: "Acme"}},
Endpoints: []webhookrelay.OutboundImportEndpoint{{
Consumer: "customer_42",
URL: "https://customer.example/webhooks",
EventTypes: []string{"invoice.paid"},
Secret: "whsec_...", // the customer's existing signing secret
Paused: true, // resume once you stop sending from the old system
}},
})
var apiErr *webhookrelay.APIError
if errors.As(err, &apiErr) {
fmt.Println(apiErr.Message) // the total, or why the import as a whole was refused
for _, item := range apiErr.ImportErrors {
fmt.Printf("%s #%d %s: %s\n", item.Kind, item.Index, item.Key, item.Error)
}
}
if err != nil {
return err
}
// One import runs at a time: sending the same import again returns the
// running task, a different one gets a 409. Bound the wait with
// api.WithContext.
for !task.Done() {
time.Sleep(2 * time.Second)
if task, err = api.GetOutboundImport(task.ID); err != nil {
return err
}
}
if task.Status == webhookrelay.OutboundImportStatusFailed {
// Errors lists refused items when nothing was written; otherwise Error
// names the item that stopped it, and starting it again resumes it.
return fmt.Errorf("import %s failed: %s", task.ID, task.Error)
}Item keys name endpoints by URL, which may be sensitive, so log them with care.
A runnable version, the import included (ImportCustomers), lives in
examples/outbound.
The client covers the rest of the Webhook Relay API too:
- Transformation functions (JavaScript / Lua) to reshape webhooks in flight —
CreateFunction,SetFunctionConfigurationVariable,InvokeFunction. Attach one to an input or output viaFunctionID. - Recurring webhooks on a cron schedule —
CreateCron. - Tunnels to expose a local service over a public hostname —
CreateTunnel. - Service connections (GCP / AWS / Azure) plus cloud event inputs (Pub/Sub, S3, SQS, SNS) and outputs (Pub/Sub, GCS, S3, SQS, SNS, Discord, Slack).
- Email-to-webhook — an inbound email address that relays mail through a bucket
(
CreateServiceConnectionInputwith typeemail). - Notification integrations —
CreateIntegration,AddIntegrationBucket. - Routing rules, retries, TLS controls, custom domains, and webhook logs.
New / NewWithAPIKey accept functional options:
api, err := webhookrelay.NewWithAPIKey(os.Getenv("RELAY_API_KEY"),
webhookrelay.WithUserAgent("my-app/1.2.3"),
webhookrelay.WithRetryPolicy(3, 1, 30), // retries, min/max backoff (seconds)
)Available options: WithHTTPClient, WithAPIEndpointURL, WithHeaders,
WithRetryPolicy, WithUserAgent, WithLogger.
Non-2xx responses are returned as *webhookrelay.APIError with the HTTP
status, the error message from a {"error":"..."} body, the raw body and
any Retry-After delay. Transport failures are plain errors, so a network
blip is never mistaken for a 404:
_, err := api.PublishOutboundMessage(&webhookrelay.OutboundMessagePublishOptions{...})
switch {
case err == nil:
case webhookrelay.IsNotFound(err):
// the consumer has no endpoints yet
case webhookrelay.IsBadRequest(err):
var apiErr *webhookrelay.APIError
errors.As(err, &apiErr)
showToUser(apiErr.Message)
default:
return err
}IsConflict and IsRateLimited cover 409 and 429.
See LICENSE.