08463237cade5869673a6cded4700833cb4286f2

Author
Ayman Bagabas <ayman.bagabas@gmail.com>
Committer
GitHub <noreply@github.com>
Date

Message

Repository webhooks (#375)

* feat: export server version

* fix: move db driver imports to db package

* feat: implement server webhooks

- branch/tag events
- collaborators events
- push events
- repository events

- [x] Implement database logic
- [x] Add database migrations
- [x] Implement webhooks logic
- [x] Integrate webhooks with backend
- [x] Implement repository webhooks SSH command interface
- [x] Implement webhook deliveries listing

Fixes: https://github.com/charmbracelet/soft-serve/issues/148
Fixes: https://github.com/charmbracelet/soft-serve/pull/56
Fixes: https://github.com/charmbracelet/soft-serve/issues/49

* wip

* fix: remove unnecessary webhook events

* fix(db): postgres migration script

* fix(db): use returning instead of LastInsertId

* fix(webhook): limit the number of push commits to 20

* fix(webhook): rename html_url to http_url

* fix(http): return 404 when repository on go-get not found

Diff

This diff is truncated to protect this page.

   1diff --git a/cmd/soft/browse.go b/cmd/soft/browse.go
   2index 324e7cb469be682d73a1c3501d251f9f66214009..8cb760198a989cdf94a23e541d61bffe2ee775f4 100644
   3--- a/cmd/soft/browse.go
   4+++ b/cmd/soft/browse.go
   5@@ -299,3 +299,8 @@ func (r repository) UpdatedAt() time.Time {
   6 func (r repository) UserID() int64 {
   7 	return 0
   8 }
   9+
  10+// CreatedAt implements proto.Repository.
  11+func (r repository) CreatedAt() time.Time {
  12+	return time.Time{}
  13+}
  14diff --git a/cmd/soft/root.go b/cmd/soft/root.go
  15index e031a67a377a68c2f44a1277a74da17a5cc4d20c..c47a81f7e35abfcc05f65fd8fec25576d1ed4d19 100644
  16--- a/cmd/soft/root.go
  17+++ b/cmd/soft/root.go
  18@@ -15,6 +15,7 @@ import (
  19 	logr "github.com/charmbracelet/soft-serve/server/log"
  20 	"github.com/charmbracelet/soft-serve/server/store"
  21 	"github.com/charmbracelet/soft-serve/server/store/database"
  22+	"github.com/charmbracelet/soft-serve/server/version"
  23 	"github.com/spf13/cobra"
  24 	"go.uber.org/automaxprocs/maxprocs"
  25 )
  26@@ -28,6 +29,10 @@ var (
  27 	// against. It's set via ldflags when building.
  28 	CommitSHA = ""
  29 
  30+	// CommitDate contains the date of the commit that this application was
  31+	// built against. It's set via ldflags when building.
  32+	CommitDate = ""
  33+
  34 	rootCmd = &cobra.Command{
  35 		Use:          "soft",
  36 		Short:        "A self-hostable Git server for the command line",
  37@@ -61,6 +66,10 @@ func init() {
  38 		}
  39 	}
  40 	rootCmd.Version = Version
  41+
  42+	version.Version = Version
  43+	version.CommitSHA = CommitSHA
  44+	version.CommitDate = CommitDate
  45 }
  46 
  47 func main() {
  48diff --git a/git/commit.go b/git/commit.go
  49index 1e955ff31b405a98faa7804e96fe4b1d93214002..20f2050d49475cd0d5d1258fd1f9e1f7f95d6785 100644
  50--- a/git/commit.go
  51+++ b/git/commit.go
  52@@ -1,12 +1,20 @@
  53 package git
  54 
  55 import (
  56+	"regexp"
  57+
  58 	"github.com/gogs/git-module"
  59 )
  60 
  61 // ZeroID is the zero hash.
  62 const ZeroID = git.EmptyID
  63 
  64+// IsZeroHash returns whether the hash is a zero hash.
  65+func IsZeroHash(h string) bool {
  66+	pattern := regexp.MustCompile(`^0{40,}$`)
  67+	return pattern.MatchString(h)
  68+}
  69+
  70 // Commit is a wrapper around git.Commit with helper methods.
  71 type Commit = git.Commit
  72 
  73diff --git a/go.mod b/go.mod
  74index 25682ab2cf20e14a46498bc18a094d8557d4eab4..a5422cf75eaaee7c4a954524dcc8c03b80bbb1b6 100644
  75--- a/go.mod
  76+++ b/go.mod
  77@@ -31,6 +31,8 @@ require (
  78 	github.com/gobwas/glob v0.2.3
  79 	github.com/gogs/git-module v1.8.3
  80 	github.com/golang-jwt/jwt/v5 v5.0.0
  81+	github.com/google/go-querystring v1.1.0
  82+	github.com/google/uuid v1.3.0
  83 	github.com/gorilla/handlers v1.5.1
  84 	github.com/gorilla/mux v1.8.0
  85 	github.com/hashicorp/golang-lru/v2 v2.0.7
  86@@ -65,7 +67,6 @@ require (
  87 	github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
  88 	github.com/go-logfmt/logfmt v0.6.0 // indirect
  89 	github.com/golang/protobuf v1.5.3 // indirect
  90-	github.com/google/uuid v1.3.0 // indirect
  91 	github.com/gorilla/css v1.0.0 // indirect
  92 	github.com/inconshreveable/mousetrap v1.1.0 // indirect
  93 	github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
  94diff --git a/go.sum b/go.sum
  95index 22b77cacd99bb1c500becd06bad4eb4031a1941b..dd7cca011d45ac3064f1b3321f577f1f91e2cd47 100644
  96--- a/go.sum
  97+++ b/go.sum
  98@@ -72,8 +72,11 @@ github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaS
  99 github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
 100 github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
 101 github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
 102+github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
 103 github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
 104 github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
 105+github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
 106+github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
 107 github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
 108 github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
 109 github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
 110diff --git a/server/backend/collab.go b/server/backend/collab.go
 111index ffe4b5f6ee725cc6f0b47d7e68b9f7464b674e46..6b03c5d0e038ce03e37d838ec5f6b3a83c75f1a9 100644
 112--- a/server/backend/collab.go
 113+++ b/server/backend/collab.go
 114@@ -2,12 +2,15 @@ package backend
 115 
 116 import (
 117 	"context"
 118+	"errors"
 119 	"strings"
 120 
 121 	"github.com/charmbracelet/soft-serve/server/access"
 122 	"github.com/charmbracelet/soft-serve/server/db"
 123 	"github.com/charmbracelet/soft-serve/server/db/models"
 124+	"github.com/charmbracelet/soft-serve/server/proto"
 125 	"github.com/charmbracelet/soft-serve/server/utils"
 126+	"github.com/charmbracelet/soft-serve/server/webhook"
 127 )
 128 
 129 // AddCollaborator adds a collaborator to a repository.
 130@@ -20,11 +23,25 @@ func (d *Backend) AddCollaborator(ctx context.Context, repo string, username str
 131 	}
 132 
 133 	repo = utils.SanitizeRepo(repo)
 134-	return db.WrapError(
 135+	r, err := d.Repository(ctx, repo)
 136+	if err != nil {
 137+		return err
 138+	}
 139+
 140+	if err := db.WrapError(
 141 		d.db.TransactionContext(ctx, func(tx *db.Tx) error {
 142 			return d.store.AddCollabByUsernameAndRepo(ctx, tx, username, repo, level)
 143 		}),
 144-	)
 145+	); err != nil {
 146+		return err
 147+	}
 148+
 149+	wh, err := webhook.NewCollaboratorEvent(ctx, proto.UserFromContext(ctx), r, username, webhook.CollaboratorEventAdded)
 150+	if err != nil {
 151+		return err
 152+	}
 153+
 154+	return webhook.SendEvent(ctx, wh)
 155 }
 156 
 157 // Collaborators returns a list of collaborators for a repository.
 158@@ -75,9 +92,27 @@ func (d *Backend) IsCollaborator(ctx context.Context, repo string, username stri
 159 // It implements backend.Backend.
 160 func (d *Backend) RemoveCollaborator(ctx context.Context, repo string, username string) error {
 161 	repo = utils.SanitizeRepo(repo)
 162-	return db.WrapError(
 163+	r, err := d.Repository(ctx, repo)
 164+	if err != nil {
 165+		return err
 166+	}
 167+
 168+	wh, err := webhook.NewCollaboratorEvent(ctx, proto.UserFromContext(ctx), r, username, webhook.CollaboratorEventRemoved)
 169+	if err != nil {
 170+		return err
 171+	}
 172+
 173+	if err := db.WrapError(
 174 		d.db.TransactionContext(ctx, func(tx *db.Tx) error {
 175 			return d.store.RemoveCollabByUsernameAndRepo(ctx, tx, username, repo)
 176 		}),
 177-	)
 178+	); err != nil {
 179+		if errors.Is(err, db.ErrRecordNotFound) {
 180+			return proto.ErrCollaboratorNotFound
 181+		}
 182+
 183+		return err
 184+	}
 185+
 186+	return webhook.SendEvent(ctx, wh)
 187 }
 188diff --git a/server/backend/hooks.go b/server/backend/hooks.go
 189index fe10432edb74abc72279afb6cdd02d1ef2700ea1..3e5297eb953e0ecab5d790e930254d0939905d25 100644
 190--- a/server/backend/hooks.go
 191+++ b/server/backend/hooks.go
 192@@ -3,10 +3,14 @@ package backend
 193 import (
 194 	"context"
 195 	"io"
 196+	"os"
 197 	"sync"
 198 
 199+	"github.com/charmbracelet/soft-serve/git"
 200 	"github.com/charmbracelet/soft-serve/server/hooks"
 201 	"github.com/charmbracelet/soft-serve/server/proto"
 202+	"github.com/charmbracelet/soft-serve/server/sshutils"
 203+	"github.com/charmbracelet/soft-serve/server/webhook"
 204 )
 205 
 206 var _ hooks.Hooks = (*Backend)(nil)
 207@@ -28,8 +32,58 @@ func (d *Backend) PreReceive(_ context.Context, _ io.Writer, _ io.Writer, repo s
 208 // Update is called by the git update hook.
 209 //
 210 // It implements Hooks.
 211-func (d *Backend) Update(_ context.Context, _ io.Writer, _ io.Writer, repo string, arg hooks.HookArg) {
 212+func (d *Backend) Update(ctx context.Context, _ io.Writer, _ io.Writer, repo string, arg hooks.HookArg) {
 213 	d.logger.Debug("update hook called", "repo", repo, "arg", arg)
 214+
 215+	// Find user
 216+	var user proto.User
 217+	if pubkey := os.Getenv("SOFT_SERVE_PUBLIC_KEY"); pubkey != "" {
 218+		pk, _, err := sshutils.ParseAuthorizedKey(pubkey)
 219+		if err != nil {
 220+			d.logger.Error("error parsing public key", "err", err)
 221+			return
 222+		}
 223+
 224+		user, err = d.UserByPublicKey(ctx, pk)
 225+		if err != nil {
 226+			d.logger.Error("error finding user from public key", "key", pubkey, "err", err)
 227+			return
 228+		}
 229+	} else if username := os.Getenv("SOFT_SERVE_USERNAME"); username != "" {
 230+		var err error
 231+		user, err = d.User(ctx, username)
 232+		if err != nil {
 233+			d.logger.Error("error finding user from username", "username", username, "err", err)
 234+			return
 235+		}
 236+	} else {
 237+		d.logger.Error("error finding user")
 238+		return
 239+	}
 240+
 241+	// Get repo
 242+	r, err := d.Repository(ctx, repo)
 243+	if err != nil {
 244+		d.logger.Error("error finding repository", "repo", repo, "err", err)
 245+		return
 246+	}
 247+
 248+	// TODO: run this async
 249+	// This would probably need something like an RPC server to communicate with the hook process.
 250+	if git.IsZeroHash(arg.OldSha) || git.IsZeroHash(arg.NewSha) {
 251+		wh, err := webhook.NewBranchTagEvent(ctx, user, r, arg.RefName, arg.OldSha, arg.NewSha)
 252+		if err != nil {
 253+			d.logger.Error("error creating branch_tag webhook", "err", err)
 254+		} else if err := webhook.SendEvent(ctx, wh); err != nil {
 255+			d.logger.Error("error sending branch_tag webhook", "err", err)
 256+		}
 257+	}
 258+	wh, err := webhook.NewPushEvent(ctx, user, r, arg.RefName, arg.OldSha, arg.NewSha)
 259+	if err != nil {
 260+		d.logger.Error("error creating push webhook", "err", err)
 261+	} else if err := webhook.SendEvent(ctx, wh); err != nil {
 262+		d.logger.Error("error sending push webhook", "err", err)
 263+	}
 264 }
 265 
 266 // PostUpdate is called by the git post-update hook.
 267diff --git a/server/backend/repo.go b/server/backend/repo.go
 268index 2d8633cc3961c6883cea0942eef2aea67cadaa78..2a8a8d1be6b13eb3646162255fcd6435f94afaf1 100644
 269--- a/server/backend/repo.go
 270+++ b/server/backend/repo.go
 271@@ -21,6 +21,7 @@ import (
 272 	"github.com/charmbracelet/soft-serve/server/storage"
 273 	"github.com/charmbracelet/soft-serve/server/task"
 274 	"github.com/charmbracelet/soft-serve/server/utils"
 275+	"github.com/charmbracelet/soft-serve/server/webhook"
 276 )
 277 
 278 func (d *Backend) reposPath() string {
 279@@ -216,7 +217,20 @@ func (d *Backend) DeleteRepository(ctx context.Context, name string) error {
 280 	repo := name + ".git"
 281 	rp := filepath.Join(d.reposPath(), repo)
 282 
 283-	err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
 284+	user := proto.UserFromContext(ctx)
 285+	r, err := d.Repository(ctx, name)
 286+	if err != nil {
 287+		return err
 288+	}
 289+
 290+	// We create the webhook event before deleting the repository so we can
 291+	// send the event after deleting the repository.
 292+	wh, err := webhook.NewRepositoryEvent(ctx, user, r, webhook.RepositoryEventActionDelete)
 293+	if err != nil {
 294+		return err
 295+	}
 296+
 297+	if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
 298 		// Delete repo from cache
 299 		defer d.cache.Delete(name)
 300 
 301@@ -257,17 +271,20 @@ func (d *Backend) DeleteRepository(ctx context.Context, name string) error {
 302 		}
 303 
 304 		return os.RemoveAll(rp)
 305-	})
 306-	if errors.Is(err, db.ErrRecordNotFound) {
 307-		return proto.ErrRepoNotFound
 308+	}); err != nil {
 309+		if errors.Is(err, db.ErrRecordNotFound) {
 310+			return proto.ErrRepoNotFound
 311+		}
 312+
 313+		return db.WrapError(err)
 314 	}
 315 
 316-	return err
 317+	return webhook.SendEvent(ctx, wh)
 318 }
 319 
 320 // DeleteUserRepositories deletes all user repositories.
 321 func (d *Backend) DeleteUserRepositories(ctx context.Context, username string) error {
 322-	return d.db.TransactionContext(ctx, func(tx *db.Tx) error {
 323+	if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
 324 		user, err := d.store.FindUserByUsername(ctx, tx, username)
 325 		if err != nil {
 326 			return err
 327@@ -285,7 +302,11 @@ func (d *Backend) DeleteUserRepositories(ctx context.Context, username string) e
 328 		}
 329 
 330 		return nil
 331-	})
 332+	}); err != nil {
 333+		return db.WrapError(err)
 334+	}
 335+
 336+	return nil
 337 }
 338 
 339 // RenameRepository renames a repository.
 340@@ -301,6 +322,11 @@ func (d *Backend) RenameRepository(ctx context.Context, oldName string, newName
 341 	if err := utils.ValidateRepo(newName); err != nil {
 342 		return err
 343 	}
 344+
 345+	if oldName == newName {
 346+		return nil
 347+	}
 348+
 349 	oldRepo := oldName + ".git"
 350 	newRepo := newName + ".git"
 351 	op := filepath.Join(d.reposPath(), oldRepo)
 352@@ -331,7 +357,18 @@ func (d *Backend) RenameRepository(ctx context.Context, oldName string, newName
 353 		return db.WrapError(err)
 354 	}
 355 
 356-	return nil
 357+	user := proto.UserFromContext(ctx)
 358+	repo, err := d.Repository(ctx, newName)
 359+	if err != nil {
 360+		return err
 361+	}
 362+
 363+	wh, err := webhook.NewRepositoryEvent(ctx, user, repo, webhook.RepositoryEventActionRename)
 364+	if err != nil {
 365+		return err
 366+	}
 367+
 368+	return webhook.SendEvent(ctx, wh)
 369 }
 370 
 371diff --git a/server/backend/webhooks.go b/server/backend/webhooks.go
 372new file mode 100644
 373index 0000000000000000000000000000000000000000..6d676d35655f62a69bc1497191b2eea4c3d59ef6
 374--- /dev/null
 375+++ b/server/backend/webhooks.go
 376@@ -0,0 +1,279 @@
 377+package backend
 378+
 379+import (
 380+	"context"
 381+	"encoding/json"
 382+
 383+	"github.com/charmbracelet/log"
 384+	"github.com/charmbracelet/soft-serve/server/db"
 385+	"github.com/charmbracelet/soft-serve/server/db/models"
 386+	"github.com/charmbracelet/soft-serve/server/proto"
 387+	"github.com/charmbracelet/soft-serve/server/store"
 388+	"github.com/charmbracelet/soft-serve/server/webhook"
 389+	"github.com/google/uuid"
 390+)
 391+
 392+// CreateWebhook creates a webhook for a repository.
 393+func (b *Backend) CreateWebhook(ctx context.Context, repo proto.Repository, url string, contentType webhook.ContentType, secret string, events []webhook.Event, active bool) error {
 394+	dbx := db.FromContext(ctx)
 395+	datastore := store.FromContext(ctx)
 396+
 397+	return dbx.TransactionContext(ctx, func(tx *db.Tx) error {
 398+		lastID, err := datastore.CreateWebhook(ctx, tx, repo.ID(), url, secret, int(contentType), active)
 399+		if err != nil {
 400+			return db.WrapError(err)
 401+		}
 402+
 403+		evs := make([]int, len(events))
 404+		for i, e := range events {
 405+			evs[i] = int(e)
 406+		}
 407+		if err := datastore.CreateWebhookEvents(ctx, tx, lastID, evs); err != nil {
 408+			return db.WrapError(err)
 409+		}
 410+
 411+		return nil
 412+	})
 413+}
 414+
 415+// Webhook returns a webhook for a repository.
 416+func (b *Backend) Webhook(ctx context.Context, repo proto.Repository, id int64) (webhook.Hook, error) {
 417+	dbx := db.FromContext(ctx)
 418+	datastore := store.FromContext(ctx)
 419+
 420+	var wh webhook.Hook
 421+	if err := dbx.TransactionContext(ctx, func(tx *db.Tx) error {
 422+		h, err := datastore.GetWebhookByID(ctx, tx, repo.ID(), id)
 423+		if err != nil {
 424+			return db.WrapError(err)
 425+		}
 426+		events, err := datastore.GetWebhookEventsByWebhookID(ctx, tx, id)
 427+		if err != nil {
 428+			return db.WrapError(err)
 429+		}
 430+
 431+		wh = webhook.Hook{
 432+			Webhook:     h,
 433+			ContentType: webhook.ContentType(h.ContentType),
 434+			Events:      make([]webhook.Event, len(events)),
 435+		}
 436+		for i, e := range events {
 437+			wh.Events[i] = webhook.Event(e.Event)
 438+		}
 439+
 440+		return nil
 441+	}); err != nil {
 442+		return webhook.Hook{}, db.WrapError(err)
 443+	}
 444+
 445+	return wh, nil
 446+}
 447+
 448+// ListWebhooks lists webhooks for a repository.
 449+func (b *Backend) ListWebhooks(ctx context.Context, repo proto.Repository) ([]webhook.Hook, error) {
 450+	dbx := db.FromContext(ctx)
 451+	datastore := store.FromContext(ctx)
 452+
 453+	var webhooks []models.Webhook
 454+	webhookEvents := map[int64][]models.WebhookEvent{}
 455+	if err := dbx.TransactionContext(ctx, func(tx *db.Tx) error {
 456+		var err error
 457+		webhooks, err = datastore.GetWebhooksByRepoID(ctx, tx, repo.ID())
 458+		if err != nil {
 459+			return err
 460+		}
 461+
 462+		for _, h := range webhooks {
 463+			events, err := datastore.GetWebhookEventsByWebhookID(ctx, tx, h.ID)
 464+			if err != nil {
 465+				return err
 466+			}
 467+			webhookEvents[h.ID] = events
 468+		}
 469+
 470+		return nil
 471+	}); err != nil {
 472+		return nil, db.WrapError(err)
 473+	}
 474+
 475+	hooks := make([]webhook.Hook, len(webhooks))
 476diff --git a/server/config/config.go b/server/config/config.go
 477index aba8f508e3189c7058d2678018d42852f2177248..fa5e2de8ec8a2d62691a0aab7ecc76031acba694 100644
 478--- a/server/config/config.go
 479+++ b/server/config/config.go
 480@@ -40,6 +40,9 @@ type GitConfig struct {
 481 	// ListenAddr is the address on which the Git daemon will listen.
 482 	ListenAddr string `env:"LISTEN_ADDR" yaml:"listen_addr"`
 483 
 484+	// PublicURL is the public URL of the Git daemon server.
 485+	PublicURL string `env:"PUBLIC_URL" yaml:"public_url"`
 486+
 487 	// MaxTimeout is the maximum number of seconds a connection can take.
 488 	MaxTimeout int `env:"MAX_TIMEOUT" yaml:"max_timeout"`
 489 
 490@@ -157,6 +160,7 @@ func (c *Config) Environ() []string {
 491 		fmt.Sprintf("SOFT_SERVE_SSH_MAX_TIMEOUT=%d", c.SSH.MaxTimeout),
 492 		fmt.Sprintf("SOFT_SERVE_SSH_IDLE_TIMEOUT=%d", c.SSH.IdleTimeout),
 493 		fmt.Sprintf("SOFT_SERVE_GIT_LISTEN_ADDR=%s", c.Git.ListenAddr),
 494+		fmt.Sprintf("SOFT_SERVE_GIT_PUBLIC_URL=%s", c.Git.PublicURL),
 495 		fmt.Sprintf("SOFT_SERVE_GIT_MAX_TIMEOUT=%d", c.Git.MaxTimeout),
 496 		fmt.Sprintf("SOFT_SERVE_GIT_IDLE_TIMEOUT=%d", c.Git.IdleTimeout),
 497 		fmt.Sprintf("SOFT_SERVE_GIT_MAX_CONNECTIONS=%d", c.Git.MaxConnections),
 498@@ -304,6 +308,7 @@ func DefaultConfig() *Config {
 499 		},
 500 		Git: GitConfig{
 501 			ListenAddr:     ":9418",
 502+			PublicURL:      "git://localhost",
 503 			MaxTimeout:     0,
 504 			IdleTimeout:    3,
 505 			MaxConnections: 32,
 506diff --git a/server/config/file.go b/server/config/file.go
 507index 6560dd15ca752dd80bcc3b1cb0a50559ae65aa51..27a3dcdf1f43b097956a79c06fc2db9f3d167fc9 100644
 508--- a/server/config/file.go
 509+++ b/server/config/file.go
 510@@ -50,6 +50,10 @@ git:
 511   # The address on which the Git daemon will listen.
 512   listen_addr: "{{ .Git.ListenAddr }}"
 513 
 514+  # The public URL of the Git daemon server.
 515+  # This is the address that will be used to clone repositories.
 516+  public_url: "{{ .Git.PublicURL }}"
 517+
 518   # The maximum number of seconds a connection can take.
 519   # A value of 0 means no timeout.
 520   max_timeout: {{ .Git.MaxTimeout }}
 521diff --git a/server/db/errors.go b/server/db/errors.go
 522index 9c19b028261f699da9101ea0b6a7b72ce4164a87..752835f43d62611b9711e6dc67a96a61ea7990ab 100644
 523--- a/server/db/errors.go
 524+++ b/server/db/errors.go
 525@@ -6,7 +6,7 @@ import (
 526 
 527 	"github.com/lib/pq"
 528 	sqlite "modernc.org/sqlite"
 529-	sqlite3 "modernc.org/sqlite/lib"
 530+	sqlitelib "modernc.org/sqlite/lib"
 531 )
 532 
 533 var (
 534@@ -28,9 +28,9 @@ func WrapError(err error) error {
 535 		// Handle sqlite constraint error.
 536 		if liteErr, ok := err.(*sqlite.Error); ok {
 537 			code := liteErr.Code()
 538-			if code == sqlite3.SQLITE_CONSTRAINT_PRIMARYKEY ||
 539-				code == sqlite3.SQLITE_CONSTRAINT_FOREIGNKEY ||
 540-				code == sqlite3.SQLITE_CONSTRAINT_UNIQUE {
 541+			if code == sqlitelib.SQLITE_CONSTRAINT_PRIMARYKEY ||
 542+				code == sqlitelib.SQLITE_CONSTRAINT_FOREIGNKEY ||
 543+				code == sqlitelib.SQLITE_CONSTRAINT_UNIQUE {
 544 				return ErrDuplicateKey
 545 			}
 546 		}
 547diff --git a/server/db/migrate/0002_webhooks.go b/server/db/migrate/0002_webhooks.go
 548new file mode 100644
 549index 0000000000000000000000000000000000000000..7ad37be61cc2d396a9ba1c57637c6ae461e1a93f
 550--- /dev/null
 551+++ b/server/db/migrate/0002_webhooks.go
 552@@ -0,0 +1,23 @@
 553+package migrate
 554+
 555+import (
 556+	"context"
 557+
 558+	"github.com/charmbracelet/soft-serve/server/db"
 559+)
 560+
 561+const (
 562+	webhooksName    = "webhooks"
 563+	webhooksVersion = 2
 564+)
 565+
 566+var webhooks = Migration{
 567+	Name:    webhooksName,
 568+	Version: webhooksVersion,
 569+	Migrate: func(ctx context.Context, tx *db.Tx) error {
 570+		return migrateUp(ctx, tx, webhooksVersion, webhooksName)
 571+	},
 572+	Rollback: func(ctx context.Context, tx *db.Tx) error {
 573+		return migrateDown(ctx, tx, webhooksVersion, webhooksName)
 574+	},
 575+}
 576diff --git a/server/db/migrate/0002_webhooks_postgres.down.sql b/server/db/migrate/0002_webhooks_postgres.down.sql
 577new file mode 100644
 578index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
 579--- /dev/null
 580+++ b/server/db/migrate/0002_webhooks_postgres.down.sql
 581diff --git a/server/db/migrate/0002_webhooks_postgres.up.sql b/server/db/migrate/0002_webhooks_postgres.up.sql
 582new file mode 100644
 583index 0000000000000000000000000000000000000000..dee09f7037458012747492265a44b951718dbf34
 584--- /dev/null
 585+++ b/server/db/migrate/0002_webhooks_postgres.up.sql
 586@@ -0,0 +1,46 @@
 587+CREATE TABLE IF NOT EXISTS webhooks (
 588+  id SERIAL PRIMARY KEY,
 589+  repo_id INTEGER NOT NULL,
 590+  url TEXT NOT NULL,
 591+  secret TEXT NOT NULL,
 592+  content_type INTEGER NOT NULL,
 593+  active BOOLEAN NOT NULL,
 594+  created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
 595+  updated_at TIMESTAMP NOT NULL,
 596+  UNIQUE (repo_id, url),
 597+  CONSTRAINT repo_id_fk
 598+  FOREIGN KEY(repo_id) REFERENCES repos(id)
 599+  ON DELETE CASCADE
 600+  ON UPDATE CASCADE
 601+);
 602+
 603+CREATE TABLE IF NOT EXISTS webhook_events (
 604+  id SERIAL PRIMARY KEY,
 605+  webhook_id INTEGER NOT NULL,
 606+  event INTEGER NOT NULL,
 607+  created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
 608+  UNIQUE (webhook_id, event),
 609+  CONSTRAINT webhook_id_fk
 610+  FOREIGN KEY(webhook_id) REFERENCES webhooks(id)
 611+  ON DELETE CASCADE
 612+  ON UPDATE CASCADE
 613+);
 614+
 615+CREATE TABLE IF NOT EXISTS webhook_deliveries (
 616+  id TEXT PRIMARY KEY,
 617+  webhook_id INTEGER NOT NULL,
 618+  event INTEGER NOT NULL,
 619+  request_url TEXT NOT NULL,
 620+  request_method TEXT NOT NULL,
 621+  request_error TEXT,
 622+  request_headers TEXT NOT NULL,
 623+  request_body TEXT NOT NULL,
 624+  response_status INTEGER NOT NULL,
 625+  response_headers TEXT NOT NULL,
 626+  response_body TEXT NOT NULL,
 627+  created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
 628+  CONSTRAINT webhook_id_fk
 629+  FOREIGN KEY(webhook_id) REFERENCES webhooks(id)
 630+  ON DELETE CASCADE
 631+  ON UPDATE CASCADE
 632+);
 633diff --git a/server/db/migrate/0002_webhooks_sqlite.down.sql b/server/db/migrate/0002_webhooks_sqlite.down.sql
 634new file mode 100644
 635index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
 636--- /dev/null
 637+++ b/server/db/migrate/0002_webhooks_sqlite.down.sql
 638diff --git a/server/db/migrate/0002_webhooks_sqlite.up.sql b/server/db/migrate/0002_webhooks_sqlite.up.sql
 639new file mode 100644
 640index 0000000000000000000000000000000000000000..5f2139c309e23d846d46ce6ea23fdea1d6646466
 641--- /dev/null
 642+++ b/server/db/migrate/0002_webhooks_sqlite.up.sql
 643@@ -0,0 +1,46 @@
 644+CREATE TABLE IF NOT EXISTS webhooks (
 645+  id INTEGER PRIMARY KEY AUTOINCREMENT,
 646+  repo_id INTEGER NOT NULL,
 647+  url TEXT NOT NULL,
 648+  secret TEXT NOT NULL,
 649+  content_type INTEGER NOT NULL,
 650+  active BOOLEAN NOT NULL,
 651+  created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
 652+  updated_at DATETIME NOT NULL,
 653+  UNIQUE (repo_id, url),
 654+  CONSTRAINT repo_id_fk
 655+  FOREIGN KEY(repo_id) REFERENCES repos(id)
 656+  ON DELETE CASCADE
 657+  ON UPDATE CASCADE
 658+);
 659+
 660+CREATE TABLE IF NOT EXISTS webhook_events (
 661+  id INTEGER PRIMARY KEY AUTOINCREMENT,
 662+  webhook_id INTEGER NOT NULL,
 663+  event INTEGER NOT NULL,
 664+  created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
 665+  UNIQUE (webhook_id, event),
 666+  CONSTRAINT webhook_id_fk
 667+  FOREIGN KEY(webhook_id) REFERENCES webhooks(id)
 668+  ON DELETE CASCADE
 669+  ON UPDATE CASCADE
 670+);
 671+
 672+CREATE TABLE IF NOT EXISTS webhook_deliveries (
 673+  id TEXT PRIMARY KEY,
 674+  webhook_id INTEGER NOT NULL,
 675+  event INTEGER NOT NULL,
 676+  request_url TEXT NOT NULL,
 677+  request_method TEXT NOT NULL,
 678+  request_error TEXT,
 679+  request_headers TEXT NOT NULL,
 680+  request_body TEXT NOT NULL,
 681+  response_status INTEGER NOT NULL,
 682+  response_headers TEXT NOT NULL,
 683+  response_body TEXT NOT NULL,
 684+  created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
 685+  CONSTRAINT webhook_id_fk
 686+  FOREIGN KEY(webhook_id) REFERENCES webhooks(id)
 687+  ON DELETE CASCADE
 688+  ON UPDATE CASCADE
 689+);
 690diff --git a/server/db/migrate/migrations.go b/server/db/migrate/migrations.go
 691index 88a9e434696ecdfe5ccb58d844d1f244d3bbfce5..890454ab147d431f5a03a835164e6230909ff5ff 100644
 692--- a/server/db/migrate/migrations.go
 693+++ b/server/db/migrate/migrations.go
 694@@ -16,6 +16,7 @@ var sqls embed.FS
 695 // Keep this in order of execution, oldest to newest.
 696 var migrations = []Migration{
 697 	createTables,
 698+	webhooks,
 699 }
 700 
 701 func execMigration(ctx context.Context, tx *db.Tx, version int, name string, down bool) error {
 702diff --git a/server/db/models/webhook.go b/server/db/models/webhook.go
 703new file mode 100644
 704index 0000000000000000000000000000000000000000..85667ceb88c38ca4b3e7433c633cd26947650d05
 705--- /dev/null
 706+++ b/server/db/models/webhook.go
 707@@ -0,0 +1,44 @@
 708+package models
 709+
 710+import (
 711+	"database/sql"
 712+	"time"
 713+
 714+	"github.com/google/uuid"
 715+)
 716+
 717+// Webhook is a repository webhook.
 718+type Webhook struct {
 719+	ID          int64     `db:"id"`
 720+	RepoID      int64     `db:"repo_id"`
 721+	URL         string    `db:"url"`
 722+	Secret      string    `db:"secret"`
 723+	ContentType int       `db:"content_type"`
 724+	Active      bool      `db:"active"`
 725+	CreatedAt   time.Time `db:"created_at"`
 726+	UpdatedAt   time.Time `db:"updated_at"`
 727+}
 728+
 729+// WebhookEvent is a webhook event.
 730+type WebhookEvent struct {
 731+	ID        int64     `db:"id"`
 732+	WebhookID int64     `db:"webhook_id"`
 733+	Event     int       `db:"event"`
 734+	CreatedAt time.Time `db:"created_at"`
 735+}
 736+
 737+// WebhookDelivery is a webhook delivery.
 738+type WebhookDelivery struct {
 739+	ID              uuid.UUID      `db:"id"`
 740+	WebhookID       int64          `db:"webhook_id"`
 741+	Event           int            `db:"event"`
 742+	RequestURL      string         `db:"request_url"`
 743+	RequestMethod   string         `db:"request_method"`
 744+	RequestError    sql.NullString `db:"request_error"`
 745+	RequestHeaders  string         `db:"request_headers"`
 746+	RequestBody     string         `db:"request_body"`
 747+	ResponseStatus  int            `db:"response_status"`
 748+	ResponseHeaders string         `db:"response_headers"`
 749+	ResponseBody    string         `db:"response_body"`
 750+	CreatedAt       time.Time      `db:"created_at"`
 751+}
 752diff --git a/server/proto/errors.go b/server/proto/errors.go
 753index cb453a8f06f5924b8f0ceec2b36bcfa206f6ccee..cc48b6f78741247d1eb48f4585490b21cf5f77c6 100644
 754--- a/server/proto/errors.go
 755+++ b/server/proto/errors.go
 756@@ -19,4 +19,6 @@ var (
 757 	ErrTokenNotFound = errors.New("token not found")
 758 	// ErrTokenExpired is returned when a token is expired.
 759 	ErrTokenExpired = errors.New("token expired")
 760+	// ErrCollaboratorNotFound is returned when a collaborator is not found.
 761+	ErrCollaboratorNotFound = errors.New("collaborator not found")
 762 )
 763diff --git a/server/proto/repo.go b/server/proto/repo.go
 764index ee6ccae29d174e00cbab536b7141073a80510618..a4b80db9864a66810a286ccbe558ead3361466b5 100644
 765--- a/server/proto/repo.go
 766+++ b/server/proto/repo.go
 767@@ -25,6 +25,8 @@ type Repository interface {
 768 	// UserID returns the ID of the user who owns the repository.
 769 	// It returns 0 if the repository is not owned by a user.
 770 	UserID() int64
 771+	// CreatedAt returns the time the repository was created.
 772+	CreatedAt() time.Time
 773 	// UpdatedAt returns the time the repository was last updated.
 774 	// If the repository has never been updated, it returns the time it was created.
 775 	UpdatedAt() time.Time
 776@@ -42,3 +44,18 @@ type RepositoryOptions struct {
 777 	LFS         bool
 778 	LFSEndpoint string
 779 }
 780+
 781+// RepositoryDefaultBranch returns the default branch of a repository.
 782+func RepositoryDefaultBranch(repo Repository) (string, error) {
 783+	r, err := repo.Open()
 784+	if err != nil {
 785+		return "", err
 786+	}
 787+
 788+	ref, err := r.HEAD()
 789+	if err != nil {
 790+		return "", err
 791+	}
 792+
 793+	return ref.Name().Short(), nil
 794+}
 795diff --git a/server/ssh/cmd/branch.go b/server/ssh/cmd/branch.go
 796index 7ed4603dbb022c07fa2b47b81f8bfd7ad69e08ae..051bc9c16d7cd00cc0099f18756ffe39584c8eed 100644
 797--- a/server/ssh/cmd/branch.go
 798+++ b/server/ssh/cmd/branch.go
 799@@ -6,6 +6,8 @@ import (
 800 
 801 	"github.com/charmbracelet/soft-serve/git"
 802 	"github.com/charmbracelet/soft-serve/server/backend"
 803+	"github.com/charmbracelet/soft-serve/server/proto"
 804+	"github.com/charmbracelet/soft-serve/server/webhook"
 805 	gitm "github.com/gogs/git-module"
 806 	"github.com/spf13/cobra"
 807 )
 808@@ -123,6 +125,15 @@ func branchDefaultCommand() *cobra.Command {
 809 				}); err != nil {
 810 					return err
 811 				}
 812+
 813+				// TODO: move this to backend?
 814+				user := proto.UserFromContext(ctx)
 815+				wh, err := webhook.NewRepositoryEvent(ctx, user, rr, webhook.RepositoryEventActionDefaultBranchChange)
 816+				if err != nil {
 817+					return err
 818+				}
 819+
 820+				return webhook.SendEvent(ctx, wh)
 821 			}
 822 
 823 			return nil
 824@@ -175,7 +186,21 @@ func branchDeleteCommand() *cobra.Command {
 825 				return fmt.Errorf("cannot delete the default branch")
 826 			}
 827 
 828-			return r.DeleteBranch(branch, gitm.DeleteBranchOptions{Force: true})
 829+			branchCommit, err := r.BranchCommit(branch)
 830+			if err != nil {
 831+				return err
 832+			}
 833+
 834+			if err := r.DeleteBranch(branch, gitm.DeleteBranchOptions{Force: true}); err != nil {
 835+				return err
 836+			}
 837+
 838+			wh, err := webhook.NewBranchTagEvent(ctx, proto.UserFromContext(ctx), rr, git.RefsHeads+branch, branchCommit.ID.String(), git.ZeroID)
 839+			if err != nil {
 840+				return err
 841+			}
 842+
 843+			return webhook.SendEvent(ctx, wh)
 844 		},
 845 	}
 846 
 847diff --git a/server/ssh/cmd/cmd.go b/server/ssh/cmd/cmd.go
 848index 08ac8f9b4312297fd704bc296117ba05c1e8091d..d543c3ad12f0ba6d76a4b97ac9bbb876cced7e51 100644
 849--- a/server/ssh/cmd/cmd.go
 850+++ b/server/ssh/cmd/cmd.go
 851@@ -137,9 +137,16 @@ func IsPublicKeyAdmin(cfg *config.Config, pk ssh.PublicKey) bool {
 852 	return false
 853 }
 854 
 855-func checkIfAdmin(cmd *cobra.Command, _ []string) error {
 856+func checkIfAdmin(cmd *cobra.Command, args []string) error {
 857+	var repo string
 858+	if len(args) > 0 {
 859+		repo = args[0]
 860+	}
 861+
 862 	ctx := cmd.Context()
 863 	cfg := config.FromContext(ctx)
 864+	be := backend.FromContext(ctx)
 865+	rn := utils.SanitizeRepo(repo)
 866 	pk := sshutils.PublicKeyFromContext(ctx)
 867 	if IsPublicKeyAdmin(cfg, pk) {
 868 		return nil
 869@@ -150,11 +157,16 @@ func checkIfAdmin(cmd *cobra.Command, _ []string) error {
 870 		return proto.ErrUnauthorized
 871 	}
 872 
 873-	if !user.IsAdmin() {
 874-		return proto.ErrUnauthorized
 875+	if user.IsAdmin() {
 876+		return nil
 877 	}
 878 
 879-	return nil
 880+	auth := be.AccessLevelForUser(cmd.Context(), rn, user)
 881+	if auth >= access.AdminAccess {
 882+		return nil
 883+	}
 884+
 885+	return proto.ErrUnauthorized
 886 }
 887 
 888 func checkIfCollab(cmd *cobra.Command, args []string) error {
 889diff --git a/server/ssh/cmd/repo.go b/server/ssh/cmd/repo.go
 890index 0b5ff3e5db40e4f672349174a01ea05daa7fcae2..0579a12ac739a0abd7d3d44fa181824d00285cb3 100644
 891--- a/server/ssh/cmd/repo.go
 892+++ b/server/ssh/cmd/repo.go
 893@@ -34,6 +34,7 @@ func RepoCommand() *cobra.Command {
 894 		renameCommand(),
 895 		tagCommand(),
 896 		treeCommand(),
 897+		webhookCommand(),
 898 	)
 899 
 900 	cmd.AddCommand(
 901diff --git a/server/ssh/cmd/tag.go b/server/ssh/cmd/tag.go
 902index 6b72087627c14411f021ed38bb5003f8032a4412..c15ce09f631c791b82c7260c49598ce5f8a82c31 100644
 903--- a/server/ssh/cmd/tag.go
 904+++ b/server/ssh/cmd/tag.go
 905@@ -3,7 +3,11 @@ package cmd
 906 import (
 907 	"strings"
 908 
 909+	"github.com/charmbracelet/log"
 910+	"github.com/charmbracelet/soft-serve/git"
 911 	"github.com/charmbracelet/soft-serve/server/backend"
 912+	"github.com/charmbracelet/soft-serve/server/proto"
 913+	"github.com/charmbracelet/soft-serve/server/webhook"
 914 	"github.com/spf13/cobra"
 915 )
 916 
 917@@ -72,10 +76,43 @@ func tagDeleteCommand() *cobra.Command {
 918 
 919 			r, err := rr.Open()
 920 			if err != nil {
 921+				log.Errorf("failed to open repo: %s", err)
 922 				return err
 923 			}
 924 
 925-			return r.DeleteTag(args[1])
 926+			tag := args[1]
 927+			tags, _ := r.Tags()
 928+			var exists bool
 929+			for _, t := range tags {
 930+				if tag == t {
 931+					exists = true
 932+					break
 933+				}
 934+			}
 935+
 936+			if !exists {
 937+				log.Errorf("failed to get tag: tag %s does not exist", tag)
 938+				return git.ErrReferenceNotExist
 939+			}
 940+
 941+			tagCommit, err := r.TagCommit(tag)
 942+			if err != nil {
 943+				log.Errorf("failed to get tag commit: %s", err)
 944+				return err
 945+			}
 946+
 947+			if err := r.DeleteTag(tag); err != nil {
 948+				log.Errorf("failed to delete tag: %s", err)
 949+				return err
 950+			}
 951+
 952+			wh, err := webhook.NewBranchTagEvent(ctx, proto.UserFromContext(ctx), rr, git.RefsTags+tag, tagCommit.ID.String(), git.ZeroID)
 953+			if err != nil {
 954+				log.Error("failed to create branch_tag webhook", "err", err)
 955+				return err
 956+			}
 957+
 958+			return webhook.SendEvent(ctx, wh)
 959 		},
 960 	}
 961 
 962diff --git a/server/ssh/cmd/webhooks.go b/server/ssh/cmd/webhooks.go
 963new file mode 100644
 964index 0000000000000000000000000000000000000000..53ee877f82d8f7872989a7f91dbd3574bee93faf
 965--- /dev/null
 966+++ b/server/ssh/cmd/webhooks.go
 967@@ -0,0 +1,406 @@
 968+package cmd
 969+
 970+import (
 971+	"fmt"
 972+	"strconv"
 973+	"strings"
 974+
 975+	"github.com/caarlos0/tablewriter"
 976+	"github.com/charmbracelet/soft-serve/server/backend"
 977+	"github.com/charmbracelet/soft-serve/server/webhook"
 978+	"github.com/dustin/go-humanize"
 979+	"github.com/google/uuid"
 980+	"github.com/spf13/cobra"
 981+)
 982+
 983+func webhookCommand() *cobra.Command {
 984+	cmd := &cobra.Command{
 985+		Use:     "webhook",
 986+		Aliases: []string{"webhooks"},
 987+		Short:   "Manage repository webhooks",
 988+	}
 989+
 990+	cmd.AddCommand(
 991+		webhookListCommand(),
 992+		webhookCreateCommand(),
 993+		webhookDeleteCommand(),
 994+		webhookUpdateCommand(),
 995+		webhookDeliveriesCommand(),
 996+	)
 997+
 998+	return cmd
 999+}
1000+
1001+var webhookEvents []string
1002+
1003+func init() {
1004+	events := webhook.Events()
1005+	webhookEvents = make([]string, len(events))
1006+	for i, e := range events {
1007+		webhookEvents[i] = e.String()
1008+	}
1009+}
1010+
1011+func webhookListCommand() *cobra.Command {
1012+	cmd := &cobra.Command{
1013+		Use:               "list REPOSITORY",
1014+		Short:             "List repository webhooks",
1015+		Args:              cobra.ExactArgs(1),
1016+		PersistentPreRunE: checkIfAdmin,
1017+		RunE: func(cmd *cobra.Command, args []string) error {
1018+			ctx := cmd.Context()
1019+			be := backend.FromContext(ctx)
1020+			repo, err := be.Repository(ctx, args[0])
1021+			if err != nil {
1022+				return err
1023+			}
1024+
1025+			webhooks, err := be.ListWebhooks(ctx, repo)
1026+			if err != nil {
1027+				return err
1028+			}
1029+
1030+			return tablewriter.Render(
1031+				cmd.OutOrStdout(),
1032+				webhooks,
1033+				[]string{"ID", "URL", "Events", "Active", "Created At", "Updated At"},
1034+				func(h webhook.Hook) ([]string, error) {
1035+					events := make([]string, len(h.Events))
1036+					for i, e := range h.Events {
1037+						events[i] = e.String()
1038+					}
1039+
1040+					row := []string{
1041+						strconv.FormatInt(h.ID, 10),
1042+						h.URL,
1043+						strings.Join(events, ","),
1044+						strconv.FormatBool(h.Active),
1045+						humanize.Time(h.CreatedAt),
1046+						humanize.Time(h.UpdatedAt),
1047+					}
1048+
1049+					return row, nil
1050+				},
1051+			)
1052+		},
1053+	}
1054+
1055+	return cmd
1056+}
1057+
1058+func webhookCreateCommand() *cobra.Command {
1059+	var events []string
1060+	var secret string
1061+	var active bool
1062+	var contentType string
1063+	cmd := &cobra.Command{
1064+		Use:               "create REPOSITORY URL",
1065+		Short:             "Create a repository webhook",
1066+		Args:              cobra.ExactArgs(2),
1067diff --git a/server/store/database/database.go b/server/store/database/database.go
1068index f05ee4fa079ae1a2a13fb750e8b6a48773ea1f12..ab6155e8e41175487d949e772b86dbdc38934669 100644
1069--- a/server/store/database/database.go
1070+++ b/server/store/database/database.go
1071@@ -21,6 +21,7 @@ type datastore struct {
1072 	*collabStore
1073 	*lfsStore
1074 	*accessTokenStore
1075+	*webhookStore
1076 }
1077 
1078 // New returns a new store.Store database.
1079diff --git a/server/store/database/webhooks.go b/server/store/database/webhooks.go
1080new file mode 100644
1081index 0000000000000000000000000000000000000000..abaa25851f67d2a1edc5b6050bf71683ccb54c27
1082--- /dev/null
1083+++ b/server/store/database/webhooks.go
1084@@ -0,0 +1,165 @@
1085+package database
1086+
1087+import (
1088+	"context"
1089+
1090+	"github.com/charmbracelet/soft-serve/server/db"
1091+	"github.com/charmbracelet/soft-serve/server/db/models"
1092+	"github.com/charmbracelet/soft-serve/server/store"
1093+	"github.com/google/uuid"
1094+	"github.com/jmoiron/sqlx"
1095+)
1096+
1097+type webhookStore struct{}
1098+
1099+var _ store.WebhookStore = (*webhookStore)(nil)
1100+
1101+// CreateWebhook implements store.WebhookStore.
1102+func (*webhookStore) CreateWebhook(ctx context.Context, h db.Handler, repoID int64, url string, secret string, contentType int, active bool) (int64, error) {
1103+	var id int64
1104+	query := h.Rebind(`INSERT INTO webhooks (repo_id, url, secret, content_type, active, updated_at)
1105+			VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP) RETURNING id;`)
1106+	err := h.GetContext(ctx, &id, query, repoID, url, secret, contentType, active)
1107+	if err != nil {
1108+		return 0, err
1109+	}
1110+
1111+	return id, nil
1112+}
1113+
1114+// CreateWebhookDelivery implements store.WebhookStore.
1115diff --git a/server/store/store.go b/server/store/store.go
1116index 7862cb598b39702aa6b815524dc91a34da59e843..41490cbf8725d8b4939b7b0a583dd077dd73de23 100644
1117--- a/server/store/store.go
1118+++ b/server/store/store.go
1119@@ -8,4 +8,5 @@ type Store interface {
1120 	SettingStore
1121 	LFSStore
1122 	AccessTokenStore
1123+	WebhookStore
1124 }
1125diff --git a/server/store/webhooks.go b/server/store/webhooks.go
1126new file mode 100644
1127index 0000000000000000000000000000000000000000..139753484141937345ef493c38f6e32a1653dc19
1128--- /dev/null
1129+++ b/server/store/webhooks.go
1130@@ -0,0 +1,48 @@
1131+package store
1132+
1133+import (
1134+	"context"
1135+
1136+	"github.com/charmbracelet/soft-serve/server/db"
1137+	"github.com/charmbracelet/soft-serve/server/db/models"
1138+	"github.com/google/uuid"
1139+)
1140+
1141+// WebhookStore is an interface for managing webhooks.
1142+type WebhookStore interface {
1143+	// GetWebhookByID returns a webhook by its ID.
1144+	GetWebhookByID(ctx context.Context, h db.Handler, repoID int64, id int64) (models.Webhook, error)
1145+	// GetWebhooksByRepoID returns all webhooks for a repository.
1146+	GetWebhooksByRepoID(ctx context.Context, h db.Handler, repoID int64) ([]models.Webhook, error)
1147+	// GetWebhooksByRepoIDWhereEvent returns all webhooks for a repository where event is in the events.
1148+	GetWebhooksByRepoIDWhereEvent(ctx context.Context, h db.Handler, repoID int64, events []int) ([]models.Webhook, error)
1149+	// CreateWebhook creates a webhook.
1150+	CreateWebhook(ctx context.Context, h db.Handler, repoID int64, url string, secret string, contentType int, active bool) (int64, error)
1151+	// UpdateWebhookByID updates a webhook by its ID.
1152+	UpdateWebhookByID(ctx context.Context, h db.Handler, repoID int64, id int64, url string, secret string, contentType int, active bool) error
1153+	// DeleteWebhookByID deletes a webhook by its ID.
1154+	DeleteWebhookByID(ctx context.Context, h db.Handler, id int64) error
1155+	// DeleteWebhookForRepoByID deletes a webhook for a repository by its ID.
1156+	DeleteWebhookForRepoByID(ctx context.Context, h db.Handler, repoID int64, id int64) error
1157+
1158+	// GetWebhookEventByID returns a webhook event by its ID.
1159+	GetWebhookEventByID(ctx context.Context, h db.Handler, id int64) (models.WebhookEvent, error)
1160+	// GetWebhookEventsByWebhookID returns all webhook events for a webhook.
1161+	GetWebhookEventsByWebhookID(ctx context.Context, h db.Handler, webhookID int64) ([]models.WebhookEvent, error)
1162+	// CreateWebhookEvents creates webhook events for a webhook.
1163+	CreateWebhookEvents(ctx context.Context, h db.Handler, webhookID int64, events []int) error
1164+	// DeleteWebhookEventsByWebhookID deletes all webhook events for a webhook.
1165+	DeleteWebhookEventsByID(ctx context.Context, h db.Handler, ids []int64) error
1166+
1167+	// GetWebhookDeliveryByID returns a webhook delivery by its ID.
1168+	GetWebhookDeliveryByID(ctx context.Context, h db.Handler, webhookID int64, id uuid.UUID) (models.WebhookDelivery, error)
1169+	// GetWebhookDeliveriesByWebhookID returns all webhook deliveries for a webhook.
1170+	GetWebhookDeliveriesByWebhookID(ctx context.Context, h db.Handler, webhookID int64) ([]models.WebhookDelivery, error)
1171+	// ListWebhookDeliveriesByWebhookID returns all webhook deliveries for a webhook.
1172+	// This only returns the delivery ID, response status, and event.
1173+	ListWebhookDeliveriesByWebhookID(ctx context.Context, h db.Handler, webhookID int64) ([]models.WebhookDelivery, error)
1174+	// CreateWebhookDelivery creates a webhook delivery.
1175diff --git a/server/version/version.go b/server/version/version.go
1176new file mode 100644
1177index 0000000000000000000000000000000000000000..22db4c3e5de255c44dad7555a3f000cf7248c79c
1178--- /dev/null
1179+++ b/server/version/version.go
1180@@ -0,0 +1,14 @@
1181+// Package version is used to store the version of the server during runtime.
1182+// The values are set during runtime in the main package.
1183+package version
1184+
1185+var (
1186+	// Version is the version of the server.
1187+	Version = ""
1188+
1189+	// CommitSHA is the commit SHA of the server.
1190+	CommitSHA = ""
1191+
1192+	// CommitDate is the commit date of the server.
1193+	CommitDate = ""
1194+)
1195diff --git a/server/web/goget.go b/server/web/goget.go
1196index 3e56f9db8cbcc0f06439c78abef46d9ea9c49fd0..ac0e54c696cdc2f99242d2b6aa4f807346d56df9 100644
1197--- a/server/web/goget.go
1198+++ b/server/web/goget.go
1199@@ -70,6 +70,7 @@ func (g GoGetHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
1200 			}
1201 
1202 			if repo == "" || repo == "." || repo == "/" {
1203+				renderNotFound(w, r)
1204 				return
1205 			}
1206 
1207diff --git a/server/webhook/branch_tag.go b/server/webhook/branch_tag.go
1208new file mode 100644
1209index 0000000000000000000000000000000000000000..88f42bdde15cdeabc4e8f36971b64e55315bdfcb
1210--- /dev/null
1211+++ b/server/webhook/branch_tag.go
1212@@ -0,0 +1,86 @@
1213+package webhook
1214+
1215+import (
1216+	"context"
1217+	"fmt"
1218+
1219+	"github.com/charmbracelet/soft-serve/git"
1220+	"github.com/charmbracelet/soft-serve/server/config"
1221+	"github.com/charmbracelet/soft-serve/server/db"
1222+	"github.com/charmbracelet/soft-serve/server/proto"
1223+	"github.com/charmbracelet/soft-serve/server/store"
1224+)
1225+
1226+// BranchTagEvent is a branch or tag event.
1227+type BranchTagEvent struct {
1228+	Common
1229+
1230+	// Ref is the branch or tag name.
1231+	Ref string `json:"ref" url:"ref"`
1232+	// Before is the previous commit SHA.
1233+	Before string `json:"before" url:"before"`
1234+	// After is the current commit SHA.
1235+	After string `json:"after" url:"after"`
1236+	// Created is whether the branch or tag was created.
1237+	Created bool `json:"created" url:"created"`
1238+	// Deleted is whether the branch or tag was deleted.
1239+	Deleted bool `json:"deleted" url:"deleted"`
1240+}
1241+
1242+// NewBranchTagEvent sends a branch or tag event.
1243+func NewBranchTagEvent(ctx context.Context, user proto.User, repo proto.Repository, ref, before, after string) (BranchTagEvent, error) {
1244+	var event Event
1245+	if git.IsZeroHash(before) {
1246+		event = EventBranchTagCreate
1247+	} else if git.IsZeroHash(after) {
1248+		event = EventBranchTagDelete
1249+	} else {
1250+		return BranchTagEvent{}, fmt.Errorf("invalid branch or tag event: before=%q after=%q", before, after)
1251+	}
1252+
1253+	payload := BranchTagEvent{
1254+		Ref:     ref,
1255+		Before:  before,
1256+		After:   after,
1257+		Created: git.IsZeroHash(before),
1258+		Deleted: git.IsZeroHash(after),
1259+		Common: Common{
1260+			EventType: event,
1261+			Repository: Repository{
1262+				ID:          repo.ID(),
1263+				Name:        repo.Name(),
1264+				Description: repo.Description(),
1265+				ProjectName: repo.ProjectName(),
1266+				Private:     repo.IsPrivate(),
1267+				CreatedAt:   repo.CreatedAt(),
1268+				UpdatedAt:   repo.UpdatedAt(),
1269+			},
1270+			Sender: User{
1271+				ID:       user.ID(),
1272+				Username: user.Username(),
1273+			},
1274+		},
1275+	}
1276+
1277+	cfg := config.FromContext(ctx)
1278+	payload.Repository.HTTPURL = repoURL(cfg.HTTP.PublicURL, repo.Name())
1279+	payload.Repository.SSHURL = repoURL(cfg.SSH.PublicURL, repo.Name())
1280+	payload.Repository.GitURL = repoURL(cfg.Git.PublicURL, repo.Name())
1281+
1282+	// Find repo owner.
1283+	dbx := db.FromContext(ctx)
1284+	datastore := store.FromContext(ctx)
1285+	owner, err := datastore.GetUserByID(ctx, dbx, repo.UserID())
1286+	if err != nil {
1287+		return BranchTagEvent{}, db.WrapError(err)
1288+	}
1289+
1290+	payload.Repository.Owner.ID = owner.ID
1291+	payload.Repository.Owner.Username = owner.Username
1292+	payload.Repository.DefaultBranch, err = proto.RepositoryDefaultBranch(repo)
1293+	if err != nil {
1294+		return BranchTagEvent{}, err
1295+	}
1296+
1297+	return payload, nil
1298+}
1299diff --git a/server/webhook/collaborator.go b/server/webhook/collaborator.go
1300new file mode 100644
1301index 0000000000000000000000000000000000000000..86f3e86a1cd0ad24c34a7a8979b6fba3e354fc81
1302--- /dev/null
1303+++ b/server/webhook/collaborator.go
1304@@ -0,0 +1,83 @@
1305+package webhook
1306+
1307+import (
1308+	"context"
1309+
1310+	"github.com/charmbracelet/soft-serve/server/access"
1311+	"github.com/charmbracelet/soft-serve/server/db"
1312+	"github.com/charmbracelet/soft-serve/server/proto"
1313+	"github.com/charmbracelet/soft-serve/server/store"
1314+)
1315+
1316+// CollaboratorEvent is a collaborator event.
1317+type CollaboratorEvent struct {
1318+	Common
1319+
1320+	// Action is the collaborator event action.
1321+	Action CollaboratorEventAction `json:"action" url:"action"`
1322+	// AccessLevel is the collaborator access level.
1323+	AccessLevel access.AccessLevel `json:"access_level" url:"access_level"`
1324+	// Collaborator is the collaborator.
1325+	Collaborator User `json:"collaborator" url:"collaborator"`
1326+}
1327+
1328+// CollaboratorEventAction is a collaborator event action.
1329+type CollaboratorEventAction string
1330+
1331+const (
1332+	// CollaboratorEventAdded is a collaborator added event.
1333+	CollaboratorEventAdded CollaboratorEventAction = "added"
1334+	// CollaboratorEventRemoved is a collaborator removed event.
1335+	CollaboratorEventRemoved CollaboratorEventAction = "removed"
1336+)
1337+
1338+// NewCollaboratorEvent sends a collaborator event.
1339+func NewCollaboratorEvent(ctx context.Context, user proto.User, repo proto.Repository, collabUsername string, action CollaboratorEventAction) (CollaboratorEvent, error) {
1340+	event := EventCollaborator
1341+
1342+	payload := CollaboratorEvent{
1343+		Action: action,
1344+		Common: Common{
1345+			EventType: event,
1346+			Repository: Repository{
1347+				ID:          repo.ID(),
1348+				Name:        repo.Name(),
1349+				Description: repo.Description(),
1350+				ProjectName: repo.ProjectName(),
1351+				Private:     repo.IsPrivate(),
1352+				CreatedAt:   repo.CreatedAt(),
1353+				UpdatedAt:   repo.UpdatedAt(),
1354+			},
1355+			Sender: User{
1356+				ID:       user.ID(),
1357+				Username: user.Username(),
1358+			},
1359+		},
1360+	}
1361+
1362+	// Find repo owner.
1363+	dbx := db.FromContext(ctx)
1364+	datastore := store.FromContext(ctx)
1365+	owner, err := datastore.GetUserByID(ctx, dbx, repo.UserID())
1366+	if err != nil {
1367+		return CollaboratorEvent{}, db.WrapError(err)
1368+	}
1369+
1370+	payload.Repository.Owner.ID = owner.ID
1371+	payload.Repository.Owner.Username = owner.Username
1372+	payload.Repository.DefaultBranch, err = proto.RepositoryDefaultBranch(repo)
1373+	if err != nil {
1374+		return CollaboratorEvent{}, err
1375+	}
1376+
1377+	collab, err := datastore.GetCollabByUsernameAndRepo(ctx, dbx, collabUsername, repo.Name())
1378+	if err != nil {
1379+		return CollaboratorEvent{}, err
1380+	}
1381+
1382+	payload.AccessLevel = collab.AccessLevel
1383+	payload.Collaborator.ID = collab.UserID
1384+	payload.Collaborator.Username = collabUsername
1385+
1386+	return payload, nil
1387+}
1388diff --git a/server/webhook/common.go b/server/webhook/common.go
1389new file mode 100644
1390index 0000000000000000000000000000000000000000..b805c24a85149ed762ed3002b04b77137006b23b
1391--- /dev/null
1392+++ b/server/webhook/common.go
1393@@ -0,0 +1,95 @@
1394+package webhook
1395+
1396+import "time"
1397+
1398+// EventPayload is a webhook event payload.
1399+type EventPayload interface {
1400+	// Event returns the event type.
1401+	Event() Event
1402+	// RepositoryID returns the repository ID.
1403+	RepositoryID() int64
1404+}
1405+
1406+// Common is a common payload.
1407+type Common struct {
1408+	// EventType is the event type.
1409+	EventType Event `json:"event" url:"event"`
1410+	// Repository is the repository payload.
1411+	Repository Repository `json:"repository" url:"repository"`
1412+	// Sender is the sender payload.
1413+	Sender User `json:"sender" url:"sender"`
1414+}
1415+
1416+// Event returns the event type.
1417+// Implements EventPayload.
1418+func (c Common) Event() Event {
1419+	return c.EventType
1420+}
1421+
1422+// RepositoryID returns the repository ID.
1423+// Implements EventPayload.
1424+func (c Common) RepositoryID() int64 {
1425+	return c.Repository.ID
1426+}
1427+
1428+// User represents a user in an event.
1429+type User struct {
1430+	// ID is the owner ID.
1431+	ID int64 `json:"id" url:"id"`
1432+	// Username is the owner username.
1433+	Username string `json:"username" url:"username"`
1434+}
1435+
1436+// Repository represents an event repository.
1437+type Repository struct {
1438+	// ID is the repository ID.
1439+	ID int64 `json:"id" url:"id"`
1440+	// Name is the repository name.
1441+	Name string `json:"name" url:"name"`
1442+	// ProjectName is the repository project name.
1443+	ProjectName string `json:"project_name" url:"project_name"`
1444+	// Description is the repository description.
1445+	Description string `json:"description" url:"description"`
1446+	// DefaultBranch is the repository default branch.
1447+	DefaultBranch string `json:"default_branch" url:"default_branch"`
1448+	// Private is whether the repository is private.
1449+	Private bool `json:"private" url:"private"`
1450+	// Owner is the repository owner.
1451+	Owner User `json:"owner" url:"owner"`
1452+	// HTTPURL is the repository HTTP URL.
1453+	HTTPURL string `json:"http_url" url:"http_url"`
1454+	// SSHURL is the repository SSH URL.
1455+	SSHURL string `json:"ssh_url" url:"ssh_url"`
1456+	// GitURL is the repository Git URL.
1457+	GitURL string `json:"git_url" url:"git_url"`
1458+	// CreatedAt is the repository creation time.
1459+	CreatedAt time.Time `json:"created_at" url:"created_at"`
1460+	// UpdatedAt is the repository last update time.
1461+	UpdatedAt time.Time `json:"updated_at" url:"updated_at"`
1462+}
1463+
1464+// Author is a commit author.
1465+type Author struct {
1466+	// Name is the author name.
1467+	Name string `json:"name" url:"name"`
1468+	// Email is the author email.
1469+	Email string `json:"email" url:"email"`
1470+	// Date is the author date.
1471+	Date time.Time `json:"date" url:"date"`
1472+}
1473+
1474+// Commit represents a Git commit.
1475+type Commit struct {
1476+	// ID is the commit ID.
1477+	ID string `json:"id" url:"id"`
1478+	// Message is the commit message.
1479+	Message string `json:"message" url:"message"`
1480+	// Title is the commit title.
1481+	Title string `json:"title" url:"title"`
1482+	// Author is the commit author.
1483+	Author Author `json:"author" url:"author"`
1484+	// Committer is the commit committer.
1485+	Committer Author `json:"committer" url:"committer"`
1486+	// Timestamp is the commit timestamp.
1487+	Timestamp time.Time `json:"timestamp" url:"timestamp"`
1488+}
1489diff --git a/server/webhook/content_type.go b/server/webhook/content_type.go
1490new file mode 100644
1491index 0000000000000000000000000000000000000000..31731d287bd008d2bf1c740b9f9d0f5d79497295
1492--- /dev/null
1493+++ b/server/webhook/content_type.go
1494@@ -0,0 +1,70 @@
1495+package webhook
1496+
1497+import (
1498+	"encoding"
1499+	"errors"
1500+	"strings"
1501+)
1502+
1503+// ContentType is the type of content that will be sent in a webhook request.
1504+type ContentType int8
1505+
1506+const (
1507+	// ContentTypeJSON is the JSON content type.
1508+	ContentTypeJSON ContentType = iota
1509+	// ContentTypeForm is the form content type.
1510+	ContentTypeForm
1511+)
1512+
1513+var contentTypeStrings = map[ContentType]string{
1514+	ContentTypeJSON: "application/json",
1515+	ContentTypeForm: "application/x-www-form-urlencoded",
1516+}
1517+
1518+// String returns the string representation of the content type.
1519+func (c ContentType) String() string {
1520+	return contentTypeStrings[c]
1521+}
1522+
1523+var stringContentType = map[string]ContentType{
1524+	"application/json":                  ContentTypeJSON,
1525+	"application/x-www-form-urlencoded": ContentTypeForm,
1526+}
1527+
1528+// ErrInvalidContentType is returned when the content type is invalid.
1529+var ErrInvalidContentType = errors.New("invalid content type")
1530+
1531+// ParseContentType parses a content type string and returns the content type.
1532+func ParseContentType(s string) (ContentType, error) {
1533+	for k, v := range stringContentType {
1534+		if strings.HasPrefix(s, k) {
1535+			return v, nil
1536+		}
1537+	}
1538+
1539+	return -1, ErrInvalidContentType
1540+}
1541+
1542+var _ encoding.TextMarshaler = ContentType(0)
1543+var _ encoding.TextUnmarshaler = (*ContentType)(nil)
1544+
1545+// UnmarshalText implements encoding.TextUnmarshaler.
1546+func (c *ContentType) UnmarshalText(text []byte) error {
1547+	ct, err := ParseContentType(string(text))
1548+	if err != nil {
1549+		return err
1550+	}
1551+
1552+	*c = ct
1553+	return nil
1554+}
1555+
1556+// MarshalText implements encoding.TextMarshaler.
1557+func (c ContentType) MarshalText() (text []byte, err error) {
1558+	ct := c.String()
1559+	if ct == "" {
1560+		return nil, ErrInvalidContentType
1561+	}
1562+
1563+	return []byte(ct), nil
1564+}
1565diff --git a/server/webhook/event.go b/server/webhook/event.go
1566new file mode 100644
1567index 0000000000000000000000000000000000000000..09edb9cab7b276d436ec46bde13774d157a1d51e
1568--- /dev/null
1569+++ b/server/webhook/event.go
1570@@ -0,0 +1,101 @@
1571+package webhook
1572+
1573+import (
1574+	"encoding"
1575+	"errors"
1576+)
1577+
1578+// Event is a webhook event.
1579+type Event int
1580+
1581+const (
1582+	// EventBranchTagCreate is a branch or tag create event.
1583+	EventBranchTagCreate Event = 1
1584+
1585+	// EventBranchTagDelete is a branch or tag delete event.
1586+	EventBranchTagDelete Event = 2
1587+
1588+	// EventCollaborator is a collaborator change event.
1589+	EventCollaborator Event = 3
1590+
1591+	// EventPush is a push event.
1592+	EventPush Event = 4
1593+
1594+	// EventRepository is a repository create, delete, rename event.
1595+	EventRepository Event = 5
1596+
1597+	// EventRepositoryVisibilityChange is a repository visibility change event.
1598+	EventRepositoryVisibilityChange Event = 6
1599+)
1600+
1601+// Events return all events.
1602+func Events() []Event {
1603+	return []Event{
1604+		EventBranchTagCreate,
1605+		EventBranchTagDelete,
1606+		EventCollaborator,
1607+		EventPush,
1608+		EventRepository,
1609+		EventRepositoryVisibilityChange,
1610+	}
1611+}
1612+
1613+var eventStrings = map[Event]string{
1614+	EventBranchTagCreate:            "branch_tag_create",
1615+	EventBranchTagDelete:            "branch_tag_delete",
1616+	EventCollaborator:               "collaborator",
1617+	EventPush:                       "push",
1618+	EventRepository:                 "repository",
1619+	EventRepositoryVisibilityChange: "repository_visibility_change",
1620+}
1621+
1622+// String returns the string representation of the event.
1623+func (e Event) String() string {
1624+	return eventStrings[e]
1625+}
1626+
1627+var stringEvent = map[string]Event{
1628+	"branch_tag_create":            EventBranchTagCreate,
1629+	"branch_tag_delete":            EventBranchTagDelete,
1630+	"collaborator":                 EventCollaborator,
1631+	"push":                         EventPush,
1632+	"repository":                   EventRepository,
1633+	"repository_visibility_change": EventRepositoryVisibilityChange,
1634+}
1635+
1636+// ErrInvalidEvent is returned when the event is invalid.
1637+var ErrInvalidEvent = errors.New("invalid event")
1638+
1639+// ParseEvent parses an event string and returns the event.
1640+func ParseEvent(s string) (Event, error) {
1641+	e, ok := stringEvent[s]
1642+	if !ok {
1643+		return -1, ErrInvalidEvent
1644+	}
1645+
1646+	return e, nil
1647+}
1648+
1649+var _ encoding.TextMarshaler = Event(0)
1650+var _ encoding.TextUnmarshaler = (*Event)(nil)
1651+
1652+// UnmarshalText implements encoding.TextUnmarshaler.
1653+func (e *Event) UnmarshalText(text []byte) error {
1654+	ev, err := ParseEvent(string(text))
1655+	if err != nil {
1656+		return err
1657+	}
1658+
1659+	*e = ev
1660+	return nil
1661+}
1662+
1663+// MarshalText implements encoding.TextMarshaler.
1664+func (e Event) MarshalText() (text []byte, err error) {
1665+	ev := e.String()
1666+	if ev == "" {
1667+		return nil, ErrInvalidEvent
1668+	}
1669+
1670diff --git a/server/webhook/push.go b/server/webhook/push.go
1671new file mode 100644
1672index 0000000000000000000000000000000000000000..4642aeb810e7a1f522c36cfbf2eaf45c5bc3e205
1673--- /dev/null
1674+++ b/server/webhook/push.go
1675@@ -0,0 +1,117 @@
1676+package webhook
1677+
1678+import (
1679+	"context"
1680+	"fmt"
1681+
1682+	"github.com/charmbracelet/soft-serve/git"
1683+	"github.com/charmbracelet/soft-serve/server/config"
1684+	"github.com/charmbracelet/soft-serve/server/db"
1685+	"github.com/charmbracelet/soft-serve/server/proto"
1686+	"github.com/charmbracelet/soft-serve/server/store"
1687+	gitm "github.com/gogs/git-module"
1688+)
1689+
1690+// PushEvent is a push event.
1691+type PushEvent struct {
1692+	Common
1693+
1694+	// Ref is the branch or tag name.
1695+	Ref string `json:"ref" url:"ref"`
1696+	// Before is the previous commit SHA.
1697+	Before string `json:"before" url:"before"`
1698+	// After is the current commit SHA.
1699+	After string `json:"after" url:"after"`
1700+	// Commits is the list of commits.
1701+	Commits []Commit `json:"commits" url:"commits"`
1702+}
1703+
1704+// NewPushEvent sends a push event.
1705+func NewPushEvent(ctx context.Context, user proto.User, repo proto.Repository, ref, before, after string) (PushEvent, error) {
1706+	event := EventPush
1707+
1708+	payload := PushEvent{
1709+		Ref:    ref,
1710+		Before: before,
1711+		After:  after,
1712+		Common: Common{
1713+			EventType: event,
1714+			Repository: Repository{
1715+				ID:          repo.ID(),
1716+				Name:        repo.Name(),
1717+				Description: repo.Description(),
1718+				ProjectName: repo.ProjectName(),
1719+				Private:     repo.IsPrivate(),
1720+				CreatedAt:   repo.CreatedAt(),
1721+				UpdatedAt:   repo.UpdatedAt(),
1722+			},
1723+			Sender: User{
1724+				ID:       user.ID(),
1725+				Username: user.Username(),
1726+			},
1727+		},
1728+	}
1729+
1730+	cfg := config.FromContext(ctx)
1731+	payload.Repository.HTTPURL = repoURL(cfg.HTTP.PublicURL, repo.Name())
1732+	payload.Repository.SSHURL = repoURL(cfg.SSH.PublicURL, repo.Name())
1733+	payload.Repository.GitURL = repoURL(cfg.Git.PublicURL, repo.Name())
1734+
1735+	// Find repo owner.
1736+	dbx := db.FromContext(ctx)
1737+	datastore := store.FromContext(ctx)
1738+	owner, err := datastore.GetUserByID(ctx, dbx, repo.UserID())
1739+	if err != nil {
1740+		return PushEvent{}, db.WrapError(err)
1741+	}
1742+
1743+	payload.Repository.Owner.ID = owner.ID
1744+	payload.Repository.Owner.Username = owner.Username
1745+
1746+	// Find commits.
1747+	r, err := repo.Open()
1748+	if err != nil {
1749+		return PushEvent{}, err
1750+	}
1751+
1752+	payload.Repository.DefaultBranch, err = proto.RepositoryDefaultBranch(repo)
1753+	if err != nil {
1754+		return PushEvent{}, err
1755+	}
1756+
1757+	rev := after
1758+	if !git.IsZeroHash(before) {
1759+		rev = fmt.Sprintf("%s..%s", before, after)
1760+	}
1761+
1762+	commits, err := r.Log(rev, gitm.LogOptions{
1763+		// XXX: limit to 20 commits for now
1764+		// TODO: implement a commits api
1765+		MaxCount: 20,
1766+	})
1767+	if err != nil {
1768+		return PushEvent{}, err
1769+	}
1770+
1771+	payload.Commits = make([]Commit, len(commits))
1772+	for i, c := range commits {
1773+		payload.Commits[i] = Commit{
1774+			ID:      c.ID.String(),
1775diff --git a/server/webhook/repository.go b/server/webhook/repository.go
1776new file mode 100644
1777index 0000000000000000000000000000000000000000..19cb7230405ea01d455839a766849099c7b69f1d
1778--- /dev/null
1779+++ b/server/webhook/repository.go
1780@@ -0,0 +1,82 @@
1781+package webhook
1782+
1783+import (
1784+	"context"
1785+
1786+	"github.com/charmbracelet/soft-serve/server/config"
1787+	"github.com/charmbracelet/soft-serve/server/db"
1788+	"github.com/charmbracelet/soft-serve/server/proto"
1789+	"github.com/charmbracelet/soft-serve/server/store"
1790+)
1791+
1792+// RepositoryEvent is a repository payload.
1793+type RepositoryEvent struct {
1794+	Common
1795+
1796+	// Action is the repository event action.
1797+	Action RepositoryEventAction `json:"action" url:"action"`
1798+}
1799+
1800+// RepositoryEventAction is a repository event action.
1801+type RepositoryEventAction string
1802+
1803+const (
1804+	// RepositoryEventActionDelete is a repository deleted event.
1805+	RepositoryEventActionDelete RepositoryEventAction = "delete"
1806+	// RepositoryEventActionRename is a repository renamed event.
1807+	RepositoryEventActionRename RepositoryEventAction = "rename"
1808+	// RepositoryEventActionVisibilityChange is a repository visibility changed event.
1809+	RepositoryEventActionVisibilityChange RepositoryEventAction = "visibility_change"
1810+	// RepositoryEventActionDefaultBranchChange is a repository default branch changed event.
1811+	RepositoryEventActionDefaultBranchChange RepositoryEventAction = "default_branch_change"
1812+)
1813+
1814+// NewRepositoryEvent sends a repository event.
1815+func NewRepositoryEvent(ctx context.Context, user proto.User, repo proto.Repository, action RepositoryEventAction) (RepositoryEvent, error) {
1816+	var event Event
1817+	switch action {
1818+	case RepositoryEventActionVisibilityChange:
1819+		event = EventRepositoryVisibilityChange
1820+	default:
1821+		event = EventRepository
1822+	}
1823+
1824+	payload := RepositoryEvent{
1825+		Action: action,
1826+		Common: Common{
1827+			EventType: event,
1828+			Repository: Repository{
1829+				ID:          repo.ID(),
1830+				Name:        repo.Name(),
1831+				Description: repo.Description(),
1832+				ProjectName: repo.ProjectName(),
1833+				Private:     repo.IsPrivate(),
1834+				CreatedAt:   repo.CreatedAt(),
1835+				UpdatedAt:   repo.UpdatedAt(),
1836+			},
1837+			Sender: User{
1838+				ID:       user.ID(),
1839+				Username: user.Username(),
1840+			},
1841+		},
1842+	}
1843+
1844+	cfg := config.FromContext(ctx)
1845+	payload.Repository.HTTPURL = repoURL(cfg.HTTP.PublicURL, repo.Name())
1846+	payload.Repository.SSHURL = repoURL(cfg.SSH.PublicURL, repo.Name())
1847+	payload.Repository.GitURL = repoURL(cfg.Git.PublicURL, repo.Name())
1848+
1849+	// Find repo owner.
1850+	dbx := db.FromContext(ctx)
1851+	datastore := store.FromContext(ctx)
1852+	owner, err := datastore.GetUserByID(ctx, dbx, repo.UserID())
1853+	if err != nil {
1854+		return RepositoryEvent{}, db.WrapError(err)
1855+	}
1856+
1857+	payload.Repository.Owner.ID = owner.ID
1858+	payload.Repository.Owner.Username = owner.Username
1859+	payload.Repository.DefaultBranch, _ = proto.RepositoryDefaultBranch(repo)
1860+
1861+	return payload, nil
1862+}
1863diff --git a/server/webhook/webhook.go b/server/webhook/webhook.go
1864new file mode 100644
1865index 0000000000000000000000000000000000000000..0429056c2745b145473ac8730cd51bcabb6b9e1d
1866--- /dev/null
1867+++ b/server/webhook/webhook.go
1868@@ -0,0 +1,144 @@
1869+package webhook
1870+
1871+import (
1872+	"bytes"
1873+	"context"
1874+	"crypto/hmac"
1875+	"crypto/sha256"
1876+	"encoding/hex"
1877+	"encoding/json"
1878+	"fmt"
1879+	"io"
1880+	"net/http"
1881+
1882+	"github.com/charmbracelet/soft-serve/server/db"
1883+	"github.com/charmbracelet/soft-serve/server/db/models"
1884+	"github.com/charmbracelet/soft-serve/server/store"
1885+	"github.com/charmbracelet/soft-serve/server/utils"
1886+	"github.com/charmbracelet/soft-serve/server/version"
1887+	"github.com/google/go-querystring/query"
1888+	"github.com/google/uuid"
1889+)
1890+
1891+// Hook is a repository webhook.
1892+type Hook struct {
1893+	models.Webhook
1894+	ContentType ContentType
1895+	Events      []Event
1896+}
1897+
1898+// Delivery is a webhook delivery.
1899+type Delivery struct {
1900+	models.WebhookDelivery
1901+	Event Event
1902+}
1903+
1904+// do sends a webhook.
1905+// Caller must close the returned body.
1906+func do(ctx context.Context, url string, method string, headers http.Header, body io.Reader) (*http.Response, error) {
1907+	req, err := http.NewRequestWithContext(ctx, method, url, body)
1908+	if err != nil {
1909+		return nil, err
1910+	}
1911+
1912+	req.Header = headers
1913+	res, err := http.DefaultClient.Do(req)
1914+	if err != nil {
1915+		return nil, err
1916+	}
1917+
1918+	return res, nil
1919+}
1920+
1921+// SendWebhook sends a webhook event.
1922+func SendWebhook(ctx context.Context, w models.Webhook, event Event, payload interface{}) error {
1923+	var buf bytes.Buffer
1924+	dbx := db.FromContext(ctx)
1925+	datastore := store.FromContext(ctx)
1926+
1927+	contentType := ContentType(w.ContentType)
1928+	switch contentType {
1929+	case ContentTypeJSON:
1930+		if err := json.NewEncoder(&buf).Encode(payload); err != nil {
1931+			return err
1932+		}
1933+	case ContentTypeForm:
1934+		v, err := query.Values(payload)
1935+		if err != nil {
1936+			return err
1937+		}
1938+		buf.WriteString(v.Encode()) // nolint: errcheck
1939+	default:
1940+		return ErrInvalidContentType
1941+	}
1942+
1943+	headers := http.Header{}
1944+	headers.Add("Content-Type", contentType.String())
1945+	headers.Add("User-Agent", "SoftServe/"+version.Version)
1946+	headers.Add("X-SoftServe-Event", event.String())
1947+
1948+	id, err := uuid.NewUUID()
1949+	if err != nil {
1950+		return err
1951+	}
1952+
1953+	headers.Add("X-SoftServe-Delivery", id.String())
1954+
1955+	reqBody := buf.String()
1956+	if w.Secret != "" {
1957+		sig := hmac.New(sha256.New, []byte(w.Secret))
1958+		sig.Write([]byte(reqBody)) // nolint: errcheck
1959+		headers.Add("X-SoftServe-Signature", "sha256="+hex.EncodeToString(sig.Sum(nil)))
1960+	}
1961+
1962+	res, reqErr := do(ctx, w.URL, http.MethodPost, headers, &buf)
1963+	var reqHeaders string
1964+	for k, v := range headers {
1965+		reqHeaders += k + ": " + v[0] + "\n"
1966+	}
1967+
1968diff --git a/testscript/testdata/http.txtar b/testscript/testdata/http.txtar
1969index 68bec65d584f0e032d1a78f6e8a3f128ff5447a0..f2af369af97c6658c0bc9cffc02983ef61ee4ce1 100644
1970--- a/testscript/testdata/http.txtar
1971+++ b/testscript/testdata/http.txtar
1972@@ -84,6 +84,10 @@ cmpenv stdout goget.txt
1973 curl -XPOST http://localhost:$HTTP_PORT/repo2/subpackage?go-get=1
1974 stdout '404.*'
1975 
1976+# go-get not found (invalid repo)
1977+curl -XPOST http://localhost:$HTTP_PORT/repo299/subpackage?go-get=1
1978+stdout '404.*'
1979+
1980 # set private
1981 soft repo private repo2 true
1982 
1983diff --git a/testscript/testdata/repo-webhooks.txtar b/testscript/testdata/repo-webhooks.txtar
1984new file mode 100644
1985index 0000000000000000000000000000000000000000..bee002e5ca9298d2a30b4b61cb7c224f98410b53
1986--- /dev/null
1987+++ b/testscript/testdata/repo-webhooks.txtar
1988@@ -0,0 +1,27 @@
1989+# vi: set ft=conf
1990+
1991+# create a repo
1992+soft repo create repo-123
1993+stderr 'Created repository repo-123.*'
1994+stdout ssh://localhost:$SSH_PORT/repo-123.git
1995+
1996+# create webhook
1997+soft repo webhook create repo-123 https://webhook.site/794fa12b-08d4-4362-a0a9-a6f995f22e17 -e branch_tag_create -e branch_tag_delete -e collaborator -e push -e repository -e repository_visibility_change
1998+
1999+# list webhooks
2000+soft repo webhook list repo-123
2001+stdout '1.*https://webhook.site/794fa12b-08d4-4362-a0a9-a6f995f22e17.*'
2002+
2003+# clone repo
2004+git clone ssh://localhost:$SSH_PORT/repo-123 repo-123
2005+
2006+# create files
2007+mkfile ./repo-123/README.md 'foobar'
2008+git -C repo-123 add -A
2009+git -C repo-123 commit -m 'first'
2010+git -C repo-123 push origin HEAD
2011+
2012+# list webhook deliveries
2013+# TODO: enable this test when githooks tests are fixed
2014+# soft repo webhook deliver list repo-123 1
2015+# stdout '.*https://webhook.site/.*'