diff --git a/repositories/events/events.go b/repositories/events/events.go
new file mode 100644
index 0000000000000000000000000000000000000000..560f01319da127ad7919545738d962b3495f840a
--- /dev/null
+++ b/repositories/events/events.go
@@ -0,0 +1,18 @@
+package events
+
+const (
+	PushEvent string = "push"
+)
+
+var (
+	exists = struct{}{}
+
+	validEvents = map[string]struct{}{
+		PushEvent: exists,
+	}
+)
+
+func IsValidEvent(event string) bool {
+	_, ok := validEvents[event]
+	return ok
+}
diff --git a/repositories/hooks/store.go b/repositories/hooks/store.go
new file mode 100644
index 0000000000000000000000000000000000000000..9c8d197670b2096cfc892eebf86c04317a778c1a
--- /dev/null
+++ b/repositories/hooks/store.go
@@ -0,0 +1,145 @@
+package hooks
+
+import (
+	"encoding/json"
+	"io"
+	"io/ioutil"
+	"log"
+	"sort"
+
+	"github.com/reviewboard/rb-gateway/repositories/events"
+)
+
+// A collection of webhooks, mapped to by their `Id`.
+type WebhookStore map[string]*Webhook
+
+// Load a collection of webhooks from the given reader.
+//
+// The store is expected to be unmarshalled from JSON.
+//
+// `repositories` must be a set of all repository names.
+//
+// If a webhook references a non-extant repository, that repository will be
+// stripped from the loaded webhook. Likewise, if a webhook references an
+// invalid event that too will be stripped.
+//
+// As a side effect, the `Events` and `Repos` fields of each hook will be
+// sorted.
+func LoadStore(r io.Reader, repositories map[string]struct{}) (WebhookStore, error) {
+	content, err := ioutil.ReadAll(r)
+	if err != nil {
+		return nil, err
+	}
+
+	rawStore := []*Webhook{}
+	if err = json.Unmarshal(content, &rawStore); err != nil {
+		return nil, err
+	}
+
+	store := make(WebhookStore)
+
+	for _, hook := range rawStore {
+		if validateHook(hook, repositories) {
+			store[hook.Id] = hook
+		}
+	}
+
+	return store, nil
+}
+
+// Save the store to a writer.
+//
+// The store will be marshalled as JSON.
+func (s WebhookStore) Save(w io.Writer) error {
+	rawStore := make([]Webhook, 0, len(s))
+
+	for _, hook := range s {
+		rawStore = append(rawStore, *hook)
+	}
+
+	content, err := json.MarshalIndent(rawStore, "", "  ")
+	if err != nil {
+		return err
+	}
+
+	_, err = w.Write([]byte(content))
+	return err
+}
+
+// Validate a hook, stripping invalid fields.
+//
+// If an invalid event or repository is specified, it will be stripped from the
+// hook.
+//
+// As a side effect, the `Events` and `Repos` fields of each hook will be
+// sorted.
+func validateHook(hook *Webhook, repos map[string]struct{}) bool {
+	validEvents := make([]string, 0, len(hook.Events))
+	validRepos := make([]string, 0, len(hook.Repos))
+
+	for _, event := range hook.Events {
+		if events.IsValidEvent(event) {
+			validEvents = append(validEvents, event)
+		} else {
+			log.Printf(`Unknown event type "%s" in hook "%s"; skipping event.`,
+				event, hook.Id)
+		}
+	}
+
+	for _, repo := range hook.Repos {
+		if _, ok := repos[repo]; ok {
+			validRepos = append(validRepos, repo)
+		} else {
+			log.Printf(`Unknown repo "%s" in hook "%s"; skipping event.`,
+				repo, hook.Id)
+		}
+	}
+
+	if len(validEvents) == 0 {
+		log.Printf(`Hook "%s" has no valid events; skipping hook.`, hook.Id)
+		return false
+	} else if len(validRepos) == 0 {
+		log.Printf(`Hook "%s" has no valid repositories; skipping hook.`, hook.Id)
+		return false
+	}
+
+	sort.Strings(validEvents)
+	hook.Events = validEvents
+
+	sort.Strings(validRepos)
+	hook.Repos = validRepos
+
+	return true
+}
+
+// Iterate over all the webhooks that match the specified event and repository.
+//
+// `f` will be called for each repository. Errors will not stop iteration from
+// continuing. `f` will only be called for webhooks that are enabled for the
+// given event and repository name.
+//
+// All errors will be returned as a slice (which will be `nil` if there were no errors).
+func (store WebhookStore) ForEach(event, repoName string, f func(h Webhook) error) []error {
+	errs := []error{}
+
+	for _, hook := range store {
+		if hook.Enabled && contains(hook.Repos, repoName) && contains(hook.Events, event) {
+			err := f(*hook)
+			if err != nil {
+				errs = append(errs, err)
+			}
+		}
+	}
+
+	if len(errs) != 0 {
+		return errs
+	} else {
+		return nil
+	}
+}
+
+// Check if `haystack` contains `needle`.
+func contains(haystack []string, needle string) bool {
+	index := sort.SearchStrings(haystack, needle)
+	return index != len(haystack) && haystack[index] == needle
+}
diff --git a/repositories/hooks/store_test.go b/repositories/hooks/store_test.go
new file mode 100644
index 0000000000000000000000000000000000000000..27b01d7e1d5be33b29ff38fe5ea60a6b9b182c82
--- /dev/null
+++ b/repositories/hooks/store_test.go
@@ -0,0 +1,107 @@
+package hooks_test
+
+import (
+	"strings"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+
+	"github.com/reviewboard/rb-gateway/repositories/hooks"
+)
+
+func TestLoadStore(t *testing.T) {
+	assert := assert.New(t)
+
+	reader := strings.NewReader(`[
+		{
+			"id": "webhook-1",
+			"url": "http://example.com",
+			"secret": "top-secret",
+			"enabled": true,
+			"events": ["push", "invalid-1"],
+			"repos": ["repo-1"]
+		},
+		{
+			"id": "webhook-2",
+			"url": "http://example.com",
+			"secret": "top-secret",
+			"enabled": false,
+			"events": ["push"],
+			"repos": ["repo-2", "invalid-1", "repo-1"]
+		},
+		{
+			"id": "webhook-3",
+			"url": "http://example.com",
+			"secret": "top-secret",
+			"enabled": true,
+			"events": ["invalid-1", "invalid-2"],
+			"repos": ["invalid-1", "invalid-2"]
+		}
+	]`)
+
+	repos := map[string]struct{}{
+		"repo-1": struct{}{},
+		"repo-2": struct{}{},
+	}
+
+	store, err := hooks.LoadStore(reader, repos)
+	assert.Nil(err)
+	assert.NotNil(store)
+
+	expected := []hooks.Webhook{
+		{
+			Id:      "webhook-1",
+			Url:     "http://example.com",
+			Secret:  "top-secret",
+			Enabled: true,
+			Events:  []string{"push"},
+			Repos:   []string{"repo-1"},
+		},
+		{
+			Id:      "webhook-2",
+			Url:     "http://example.com",
+			Secret:  "top-secret",
+			Enabled: false,
+			Events:  []string{"push"},
+			Repos:   []string{"repo-1", "repo-2"},
+		},
+	}
+
+	assert.Equal(2, len(store))
+	for _, hook := range expected {
+		assert.Contains(store, hook.Id)
+
+		parsed := store[hook.Id]
+
+		assert.Equal(hook.Id, parsed.Id)
+		assert.Equal(hook.Url, parsed.Url)
+		assert.Equal(hook.Secret, parsed.Secret)
+		assert.Equal(hook.Enabled, parsed.Enabled)
+		assert.Equal(hook.Events, parsed.Events)
+		assert.Equal(hook.Repos, parsed.Repos)
+	}
+
+	var buf strings.Builder
+	assert.Nil(store.Save(&buf))
+
+	reader = strings.NewReader(buf.String())
+
+	store, err = hooks.LoadStore(reader, repos)
+	assert.Nil(err)
+	assert.NotNil(store)
+
+	assert.Equal(2, len(store))
+	for _, hook := range expected {
+		assert.Contains(store, hook.Id)
+
+		parsed := store[hook.Id]
+
+		assert.Equal(hook.Id, parsed.Id)
+		assert.Equal(hook.Url, parsed.Url)
+		assert.Equal(hook.Secret, parsed.Secret)
+		assert.Equal(hook.Enabled, parsed.Enabled)
+		assert.Equal(hook.Events, parsed.Events)
+		assert.Equal(hook.Repos, parsed.Repos)
+	}
+
+}
diff --git a/repositories/hooks/webhook.go b/repositories/hooks/webhook.go
new file mode 100644
index 0000000000000000000000000000000000000000..a66268b4d3d6ebdd5af8c68813365276c73e9ff1
--- /dev/null
+++ b/repositories/hooks/webhook.go
@@ -0,0 +1,21 @@
+package hooks
+
+type Webhook struct {
+	// A unique ID for the webhook.
+	Id string `json:"id"`
+
+	// The URL that the webhook will request.
+	Url string `json:"url"`
+
+	// A secret used for generating an HMAC-SHA1 signature for the payload.
+	Secret string `json:"secret"`
+
+	// Whether or not the webhook is enabled.
+	Enabled bool `json:"enabled"`
+
+	// A sorted list of events that this webhook applies to.
+	Events []string `json:"events"`
+
+	// A sorted list of repository names that this webhook applies to.
+	Repos []string `json:"repos"`
+}
