DocumentationSend webhooks to your customers from a Go service

Send webhooks to your customers from a Go service

Publish signed webhooks to your customers from Go with webhookrelay-go: event catalog, consumers, endpoints, idempotent publishing from a transactional outbox, delivery inspection, failing-endpoint reports and recovery.

To send webhooks to your customers from a Go service, add github.com/webhookrelay/webhookrelay-go, register each customer as a consumer with their HTTPS endpoint, and call api.PublishOutboundMessage with an idempotency key when an event happens. Webhook Relay's Outbound Gateway signs each delivery with Standard Webhooks headers, delivers it to every subscribed endpoint, retries failures up to eight times within 48 hours (5s, 5m, 30m, 2h, 5h, 10h, 10h), paces each endpoint separately and keeps every attempt for 35 days.

This tutorial builds a small CLI, webhooks, with the commands a real service needs:

CommandWhat it does
webhooks catalogCreates or updates your event types
webhooks onboard <customer-id> <name> <url>Registers a customer's endpoint and prints its signing secret
webhooks outboxPublishes pending events from a Postgres outbox table, idempotently
webhooks inspect <message-id>Shows where a message went and what each endpoint answered
webhooks failingLists failing endpoints across all customers
webhooks recover <endpoint-id> <since>Re-sends deliveries that exhausted their retries after an outage

Prerequisites

  • A Webhook Relay account on the Basic plan or above. The Outbound Gateway is self-serve on Basic ($9.99/month), Business and Pro, not on the Free plan.
  • An outbound-restricted access token from the Access Tokens page. Choose Outbound: manage for the onboarding commands and Outbound: publish only ("permissions": { "outbound": "publish" }) for the process that runs the outbox worker. Restricted tokens can only reach /v1/outbound/*.
  • Go 1.23 or newer.
  • A public HTTPS URL to test with, for example a free Webhook Bin.
mkdir webhooks && cd webhooks
go mod init example.com/webhooks
go get github.com/webhookrelay/webhookrelay-go@latest

export RELAY_KEY=YOUR_TOKEN_KEY
export RELAY_SECRET=YOUR_TOKEN_SECRET

1. The client and the event catalog

webhookrelay.New takes the token key and secret (HTTP Basic auth). UpsertOutboundEventType creates or updates an event type, so catalog is safe to run on every deploy.

// main.go
package main

import (
    "fmt"
    "log"
    "os"
    "time"

    "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)
    }
    if len(os.Args) < 2 {
        log.Fatal("usage: webhooks catalog|onboard|outbox|inspect|failing|recover")
    }

    switch args := os.Args[2:]; os.Args[1] {
    case "catalog":
        err = syncCatalog(api)
    case "onboard":
        need(args, 3)
        err = onboard(api, args[0], args[1], args[2])
    case "outbox":
        err = runOutbox(api, os.Getenv("DATABASE_URL"))
    case "inspect":
        need(args, 1)
        err = inspect(api, args[0])
    case "failing":
        err = failing(api)
    case "recover":
        need(args, 2)
        var since time.Time
        if since, err = time.Parse(time.RFC3339, args[1]); err == nil {
            err = recoverSince(api, args[0], since)
        }
    default:
        err = fmt.Errorf("unknown command %q", os.Args[1])
    }
    if err != nil {
        log.Fatal(err)
    }
}

func need(args []string, n int) {
    if len(args) < n {
        log.Fatalf("expected %d arguments, got %d", n, len(args))
    }
}

func syncCatalog(api *webhookrelay.API) error {
    events := []webhookrelay.OutboundEventTypeOptions{
        {
            Name:        "invoice.paid",
            Description: "An invoice was paid in full",
            Example:     map[string]interface{}{"invoice_id": "inv_123", "amount": 4900, "currency": "usd"},
        },
        {
            Name:        "invoice.refunded",
            Description: "An invoice was refunded, fully or partially",
            Example:     map[string]interface{}{"invoice_id": "inv_123", "refund_id": "re_9", "amount": 4900},
        },
    }
    for i := range events {
        if _, err := api.UpsertOutboundEventType(&events[i]); err != nil {
            return fmt.Errorf("event type %s: %w", events[i].Name, err)
        }
    }
    return nil
}

2. Onboard a customer

Use your own customer ID as the consumer ID. CreateOutboundEndpoint returns the endpoint with its whsec_ signing secret, which you show to the customer once. RevealOutboundEndpointSecret returns it again later.

// onboard.go
package main

import (
    "fmt"

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

func onboard(api *webhookrelay.API, customerID, name, url string) error {
    if _, err := api.UpsertOutboundConsumer(&webhookrelay.OutboundConsumerOptions{ID: customerID, Name: name}); err != nil {
        return err
    }
    endpoint, err := api.CreateOutboundEndpoint(&webhookrelay.OutboundEndpointOptions{
        Consumer:    customerID,
        URL:         url,
        EventTypes:  []string{"invoice.paid", "invoice.refunded"}, // or []string{"*"}
        Description: "Production webhook receiver",
        Timeout:     15,   // seconds per attempt, up to 60
        AutoDisable: true, // disable after 100 failures in a row spanning 12+ hours
    })
    if err != nil {
        return err
    }
    fmt.Printf("endpoint %s (%s)\nsigning secret for the customer: %s\n", endpoint.ID, endpoint.State, endpoint.Secret)
    return nil
}

The URL must be public HTTPS; hosts that resolve to private or loopback addresses are rejected.

3. Publish from a transactional outbox

Webhook Relay makes delivery durable once it accepts a message. The gap it can't close is inside your service: a request handler that commits an order and then crashes before calling publish loses the event. The fix is a transactional outbox: write the event to a table in the same transaction as the business change, and let a worker publish it.

CREATE TABLE webhook_outbox (
  id           BIGSERIAL PRIMARY KEY,
  customer_id  TEXT        NOT NULL,
  event_type   TEXT        NOT NULL,
  event_key    TEXT        NOT NULL UNIQUE, -- e.g. invoice-paid:inv_123
  payload      JSONB       NOT NULL,
  created_at   TIMESTAMPTZ NOT NULL DEFAULT now(),
  published_at TIMESTAMPTZ,
  message_id   TEXT
);

-- In your business transaction:
-- INSERT INTO webhook_outbox (customer_id, event_type, event_key, payload)
-- VALUES ($1, 'invoice.paid', 'invoice-paid:' || $2, $3);

The worker claims a batch with FOR UPDATE SKIP LOCKED (so several replicas can run it), publishes each row with event_key as the idempotency key, and records the message ID. If the worker crashes after publishing but before marking the row, the next run publishes it again and Webhook Relay returns the original receipt instead of delivering twice.

// outbox.go
package main

import (
    "context"
    "database/sql"
    "encoding/json"
    "fmt"
    "log"
    "time"

    "github.com/webhookrelay/webhookrelay-go"
    // Register a Postgres driver, e.g. _ "github.com/jackc/pgx/v5/stdlib"
)

func runOutbox(api *webhookrelay.API, databaseURL string) error {
    db, err := sql.Open("pgx", databaseURL)
    if err != nil {
        return err
    }
    defer db.Close()

    for {
        n, err := publishBatch(context.Background(), api, db, 100)
        if err != nil {
            log.Printf("outbox: %v", err)
        }
        if n == 0 {
            time.Sleep(time.Second)
        }
    }
}

func publishBatch(ctx context.Context, api *webhookrelay.API, db *sql.DB, limit int) (int, error) {
    tx, err := db.BeginTx(ctx, nil)
    if err != nil {
        return 0, err
    }
    defer tx.Rollback()

    rows, err := tx.QueryContext(ctx, `
        SELECT id, customer_id, event_type, event_key, payload
        FROM webhook_outbox
        WHERE published_at IS NULL
        ORDER BY id
        LIMIT $1
        FOR UPDATE SKIP LOCKED`, limit)
    if err != nil {
        return 0, err
    }
    type row struct {
        id                              int64
        customerID, eventType, eventKey string
        payload                         json.RawMessage
    }
    var batch []row
    for rows.Next() {
        var r row
        if err := rows.Scan(&r.id, &r.customerID, &r.eventType, &r.eventKey, &r.payload); err != nil {
            rows.Close()
            return 0, err
        }
        batch = append(batch, r)
    }
    rows.Close()

    for _, r := range batch {
        message, err := api.PublishOutboundMessage(&webhookrelay.OutboundMessagePublishOptions{
            Consumer:       r.customerID,
            EventType:      r.eventType,
            EventID:        r.eventKey,
            Payload:        r.payload, // json.RawMessage is sent as-is, up to 256 KiB
            IdempotencyKey: r.eventKey,
        })
        if err != nil {
            // Leave the row unpublished; the next batch retries it with the same key.
            return 0, fmt.Errorf("publish %s: %w", r.eventKey, err)
        }
        if _, err := tx.ExecContext(ctx,
            `UPDATE webhook_outbox SET published_at = now(), message_id = $1 WHERE id = $2`,
            message.ID, r.id); err != nil {
            return 0, err
        }
    }
    return len(batch), tx.Commit()
}

If a customer has no consumer yet, publish returns a 404 error. Either create consumers when customers sign up, or skip events for customers who have not registered an endpoint. The Go client already retries 429 and 5xx responses with backoff, and a 409 means the key was reused for a different payload, which is a bug worth alerting on.

4. Inspect where a message went

GetOutboundMessage returns the payload and one delivery per addressed endpoint. ListOutboundDeliveries answers "what did this endpoint receive?" and filters by status (sent, failed, stalled, received, rejected), event type or message.

// inspect.go
package main

import (
    "fmt"

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

func inspect(api *webhookrelay.API, messageID string) error {
    message, err := api.GetOutboundMessage(messageID)
    if err != nil {
        return err
    }
    fmt.Printf("%s %s for %s, addressed to %d endpoint(s)\n", message.ID, message.EventType, message.Consumer, len(message.EndpointIDs))
    for _, d := range message.Deliveries {
        fmt.Printf("  endpoint %s: %s, last status %d, retries %d\n", d.OutputID, d.Status, d.StatusCode, d.Retries)
    }
    if len(message.UnavailableEndpointIDs) > 0 {
        fmt.Printf("  still loading: %v (ask again shortly)\n", message.UnavailableEndpointIDs)
    }

    // Recent failures on the first endpoint, for a support ticket.
    if len(message.EndpointIDs) > 0 {
        failed, err := api.ListOutboundDeliveries(&webhookrelay.OutboundDeliveryListOptions{
            EndpointID: message.EndpointIDs[0],
            Status:     string(webhookrelay.RequestStatusFailed),
            Limit:      10,
        })
        if err != nil {
            return err
        }
        fmt.Printf("  %d recent failed deliveries on %s\n", len(failed), message.EndpointIDs[0])
    }
    return nil
}

5. Report failing endpoints and recover after an outage

An endpoint becomes failing after 5 failed attempts in a row or one exhausted delivery; a 410 Gone response disables it. Run failing from a cron job and tell the customer before they notice.

// health.go
package main

import (
    "fmt"
    "time"

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

func failing(api *webhookrelay.API) error {
    health, err := api.GetOutboundHealth()
    if err != nil {
        return err
    }
    fmt.Printf("since %s: %d attempts, %d failures; endpoints by state: %v\n",
        health.Since.Format(time.RFC3339), health.Stats.Attempts, health.Stats.Failures, health.Endpoints)
    if health.Endpoints[webhookrelay.OutboundEndpointStateFailing] == 0 {
        return nil
    }
    endpoints, err := api.ListAllOutboundEndpoints(&webhookrelay.OutboundEndpointListOptions{
        State: webhookrelay.OutboundEndpointStateFailing,
        Limit: 20,
    })
    if err != nil {
        return err
    }
    for _, e := range endpoints {
        var failures int64
        if e.Stats != nil {
            failures = e.Stats.Failures
        }
        fmt.Printf("%s %s failing since %v, %d failures in 24h\n", e.Consumer, e.URL, e.FailingSince, failures)
    }
    return nil
}

// recoverSince re-sends deliveries whose retries ran out since the outage began.
// Every copy keeps its original webhook-id, so deduplicating receivers stay correct.
func recoverSince(api *webhookrelay.API, endpointID string, since time.Time) error {
    task, err := api.RecoverOutboundDeliveries(endpointID, since)
    if err != nil {
        return err
    }
    for task.Status == "pending" || task.Status == "running" {
        time.Sleep(2 * time.Second)
        if task, err = api.GetOutboundRecoveryTask(task.ID); err != nil {
            return err
        }
    }
    fmt.Printf("recovery %s: %s, %d deliveries re-sent %s\n", task.ID, task.Status, task.Processed, task.Error)
    return nil
}

Two related calls cover the other recovery cases:

  • api.RetryOutboundDelivery(endpointID, messageID) re-sends one message to one endpoint now.
  • api.ReplayMissingOutboundDeliveries(endpointID, since) sends messages that were skipped while the endpoint was paused or disabled. Call api.ResumeOutboundEndpoint(endpointID) first.

Run it

go mod tidy
go run . catalog
go run . onboard customer_42 Acme https://bin.webhookrelay.com/v1/webhooks/YOUR_BIN_ID
DATABASE_URL=postgres://localhost/app go run . outbox   # after inserting a row into webhook_outbox
go run . inspect MESSAGE_ID
go run . failing
go run . recover ENDPOINT_ID 2026-10-24T00:00:00Z

Each delivery arrives as a POST with webhook-id, webhook-timestamp, webhook-signature, webhook-event-type and webhook-consumer headers. Send customers their whsec_ secret and the receiving guide, which includes a standard-library Go verifier.

Next steps

Frequently asked questions

Is PublishOutboundMessage safe to retry in Go?

Yes. Set IdempotencyKey to a value derived from your event, such as invoice-paid:inv_123. Repeating the publish with the same key and message returns the original receipt instead of delivering twice. If you leave the key empty, the Go SDK generates one per call so its own automatic retries are safe, but your own retries are only deduplicated when you pass a key.

Why use a transactional outbox if Webhook Relay already retries deliveries?

Webhook Relay retries delivery to your customers once it has accepted the message. The outbox covers the step before that: if your service commits a database change and crashes before calling publish, the event row is still in the outbox and a worker publishes it later. The idempotency key makes the worker's retries safe.

How do I find customers whose webhook endpoints are failing?

Call GetOutboundHealth for counts by state and the last 24 hours of attempts and failures, then ListAllOutboundEndpoints with State set to failing. It returns failing endpoints across all consumers, longest failing first, each with its failure count over the last 24 hours.

What do Go receivers need to verify the webhooks?

Each delivery carries Standard Webhooks headers: webhook-id, webhook-timestamp and webhook-signature, an HMAC-SHA256 over the id, timestamp and raw body keyed with the endpoint's whsec_ secret. The Webhook Relay receiving guide has a 40-line Go verification function using only the standard library.

Did this page help you?