Skip to content

Webhooks#

Webhooks notify a backend service of yours when an event of interest happens on a private BSR instance. Common uses:

  • Trigger downstream CI/CD on a successful buf push.
  • Notify a Slack channel or issue tracker when modules change.
  • Tag a Git repository in response to a BSR commit.

Webhooks are in alpha with a single supported event, WEBHOOK_EVENT_REPOSITORY_PUSH, fired on a successful buf push. The buf.alpha.webhook.v1alpha1 and buf.alpha.registry.v1alpha1 packages and the buf beta registry webhook CLI commands are part of that alpha surface; expect them to change.

This feature is private-instance only and isn’t available on the public buf.build.

In every example below, replace your-bsr-instance.example.com with the domain of your private BSR instance.

How webhooks work#

Two pieces are involved: an event listener you run, and a webhook subscription on the BSR.

The callback contract for the listener:

  • HTTPS URL.
  • Path suffix /buf.alpha.webhook.v1alpha1.EventService/Event, with no query string or fragment.
  • Accepts Content-Type: application/proto and decodes the body as an EventRequest.
  • Returns the matching EventResponse.

The BSR delivers events in batches of up to 20 every 5 seconds by default. On on-prem deployments these limits are configurable. Delivery is best-effort: events aren’t signed and aren’t retried, so treat the channel as at-most-once and reconcile from the BSR if you need stronger guarantees.

Enable webhooks on your instance#

Webhooks are disabled by default on private BSR instances and enabled by default on on-prem deployments. To turn them on for a private BSR instance, contact Support or your Buf representative.

Write an event listener#

The example below uses generated Go and the Connect library to implement the Webhook Event service:

$ go get buf.build/gen/go/bufbuild/buf/protocolbuffers/go
$ go get buf.build/gen/go/bufbuild/buf/connectrpc/go
bsr/webhooks/cmd/main.go
package main

import (
    "context"
    "fmt"
    "net/http"

    webhookconnect "buf.build/gen/go/bufbuild/buf/connectrpc/gosimple/buf/alpha/webhook/v1alpha1/webhookv1alpha1connect"
    registryv1alpha1 "buf.build/gen/go/bufbuild/buf/protocolbuffers/go/buf/alpha/registry/v1alpha1"
    webhookv1alpha1 "buf.build/gen/go/bufbuild/buf/protocolbuffers/go/buf/alpha/webhook/v1alpha1"
)

type webhookEventHandler struct{}

func (h *webhookEventHandler) Event(
    _ context.Context,
    req *webhookv1alpha1.EventRequest,
) (*webhookv1alpha1.EventResponse, error) {
    // Handle the type-safe incoming request for the push event:
    switch req.GetEvent() {
    case registryv1alpha1.WebhookEvent_WEBHOOK_EVENT_REPOSITORY_PUSH:
        pushEvent := req.GetPayload().GetRepositoryPush()
        fmt.Println("received repo push event:", pushEvent)
    default:
        fmt.Println("unknown event:", req.GetEvent())
    }

    // Webhook listener has an empty response
    return &webhookv1alpha1.EventResponse{}, nil
}

// Connect handler based on: https://connectrpc.com/docs/go/getting-started#implement-handler
func main() {
    mux := http.NewServeMux()
    mux.Handle(webhookconnect.NewEventServiceHandler(&webhookEventHandler{}))
    http.ListenAndServe("localhost:8080", mux)
}

If you can’t use Connect, any HTTP server that satisfies the callback contract above works: accept application/proto, decode the body as EventRequest, return the matching EventResponse.

Manage subscriptions#

Subscriptions are scoped to a single repository. Each repository event allows a single webhook, so a repository has at most one webhook subscription. Creating, listing, and deleting subscriptions requires the Admin role on the repository.

With the Buf CLI#

The recommended path:

Create
$ buf beta registry webhook create \
  --owner="<the organization that owns the repository>" \
  --repository="<the repository name>" \
  --callback-url="https://your.callback.url/buf.alpha.webhook.v1alpha1.EventService/Event" \
  --event="WEBHOOK_EVENT_REPOSITORY_PUSH" \
  --remote="your-bsr-instance.example.com"
List
$ buf beta registry webhook list \
  --owner="<the organization that owns the repository>" \
  --repository="<the repository name>" \
  --remote="your-bsr-instance.example.com"
Delete
$ buf beta registry webhook delete \
  --id="the-webhook-id-that-will-be-deleted" \
  --remote="your-bsr-instance.example.com"

With a Go Connect client#

For programmatic management from a Go service, generate the WebhookService client and use it directly:

$ go get buf.build/gen/go/bufbuild/buf/connectrpc/go
package main

import (
    "context"
    "log"
    "net/http"

    registryv1alpha1 "buf.build/gen/go/bufbuild/buf/connectrpc/go/buf/alpha/registry/v1alpha1"
    registryconnect "buf.build/gen/go/bufbuild/buf/connectrpc/go/buf/alpha/registry/v1alpha1/registryv1alpha1connect"
    connect "connectrpc.com/connect"
)

func main() {
    connectClient := registryconnect.NewWebhookServiceClient(
        http.DefaultClient,
        "https://your-bsr-instance.example.com",
    )

    createReq := connect.NewRequest(&registryv1alpha1.CreateWebhookRequest{
        WebhookEvent:   registryv1alpha1.WebhookEvent_WEBHOOK_EVENT_REPOSITORY_PUSH,
        OwnerName:      "ORG_NAME_OR_USERNAME",
        RepositoryName: "REPOSITORY_NAME",
        CallbackUrl:    "https://your.callback.url/buf.alpha.webhook.v1alpha1.EventService/Event",
    })
    createReq.Header().Add("Authorization", "Bearer YOUR_API_TOKEN")

    createResp, err := connectClient.CreateWebhook(context.Background(), createReq)
    if err != nil {
        log.Fatalf("creating webhook failed: %v", err)
    }
    webhook := createResp.Msg.Webhook
    if webhook == nil {
        log.Fatal("nil webhook response")
    }
    log.Println("new webhook created!", webhook)

    listReq := connect.NewRequest(&registryv1alpha1.ListWebhooksRequest{
        OwnerName:      "ORG_NAME_OR_USERNAME",
        RepositoryName: "REPOSITORY_NAME",
    })
    listReq.Header().Add("Authorization", "Bearer YOUR_API_TOKEN")

    listResp, err := connectClient.ListWebhooks(context.Background(), listReq)
    if err != nil {
        log.Fatalf("list webhooks failed: %v", err)
    }
    log.Println("existing webhooks:", listResp)

    deleteReq := connect.NewRequest(&registryv1alpha1.DeleteWebhookRequest{
        WebhookId: webhook.WebhookId,
    })
    deleteReq.Header().Add("Authorization", "Bearer YOUR_API_TOKEN")

    deleteResp, err := connectClient.DeleteWebhook(context.Background(), deleteReq)
    if err != nil {
        log.Fatalf("delete webhook failed: %v", err)
    }
    log.Println("webhook deleted:", deleteResp)
}

With curl#

Because the BSR is also written with Connect handlers, the same RPCs work over plain HTTP with JSON.

Create with curl
$ curl --location --request POST 'https://your-bsr-instance.example.com/buf.alpha.registry.v1alpha1.WebhookService/CreateWebhook' \
--header 'Authorization: Bearer <BSR api token>' \
--header 'Content-Type: application/json' \
--data-raw '{
    "owner_name": "<the organization that owns the repository>",
    "repository_name": "<the repository name>",
    "webhook_event": "WEBHOOK_EVENT_REPOSITORY_PUSH",
    "callback_url": "https://your.callback.url/buf.alpha.webhook.v1alpha1.EventService/Event"
}'
List with curl
$ curl --location --request POST 'https://your-bsr-instance.example.com/buf.alpha.registry.v1alpha1.WebhookService/ListWebhooks' \
--header 'Authorization: Bearer <your BSR api token>' \
--header 'Content-Type: application/json' \
--data-raw '{
    "owner_name": "<the organization that owns the repository>",
    "repository_name": "<the repository name>"
}'
Delete with curl
$ curl --location --request POST 'https://your-bsr-instance.example.com/buf.alpha.registry.v1alpha1.WebhookService/DeleteWebhook' \
--header 'Authorization: Bearer <your BSR api token>' \
--header 'Content-Type: application/json' \
--data-raw '{
    "webhook_id": "the-webhook-id-that-will-be-deleted"
}'

Test your listener#

Listeners accept application/proto, not JSON, so a useful local test sends an EventRequest already encoded as Protobuf binary. The snippet below pipes a JSON EventRequest through buf beta convert to produce the binary payload, then posts it to your callback URL with curl:

$ echo '{
  "event": "WEBHOOK_EVENT_REPOSITORY_PUSH",
  "payload": {
    "repositoryPush": {
      "eventTime": "2022-07-11T15:07:30Z",
      "repository": {
        "id": "my-repo-id",
        "name": "my-repo-name",
        "createTime": "2022-07-10T15:07:30Z",
        "updateTime": "2022-07-10T18:07:30Z",
        "userId": "the-user-id",
        "visibility": "VISIBILITY_PUBLIC"
      },
      "repositoryCommit": {
        "author": "the-author-username",
        "commitSequenceId": 10,
        "createTime": "2022-07-11T15:07:30Z",
        "id": "the-commit-id",
        "name": "the-commit-name",
        "digest": "the-commit-digest",
        "tags": [
          {
            "author": "the-tag-author",
            "commitName": "the-commit-hash",
            "id": "the-tag-id",
            "createTime": "2022-07-11T15:07:30Z",
            "name": "the-tag-name"
          }
        ]
      }
    }
  }
}' | \
    buf beta convert buf.build/bufbuild/buf \
        --input=-#format=json \
        --type buf.alpha.webhook.v1alpha1.EventRequest | \
    curl -X POST https://your.callback.url/buf.alpha.webhook.v1alpha1.EventService/Event \
        -H "Content-Type: application/proto" \
        --data-binary @-

Payload reference#

  • EventRequest: the body of every webhook delivery; for WEBHOOK_EVENT_REPOSITORY_PUSH, the repositoryPush payload includes the commit (with author) and the repository it was pushed to.
  • EventResponse: the response your listener returns.
  • WebhookEvent: enum of supported events; today only WEBHOOK_EVENT_REPOSITORY_PUSH.