Add webhook endpoints to the org API
Go · Go · intermediate · modification
First half of outgoing webhooks: admins register an endpoint and get its signing secret once, and anyone in the org can see the configured endpoints in settings. `Store` keeps the queries in one place: `ListForOrg` will be shared with the delivery worker, which needs the secret to sign payloads, so there is a single source of truth for an org's endpoints. URLs are validated (https only, no credentials), the request body is capped at 4 KiB, and secrets come from `crypto/rand`. Tested with curl: created two endpoints as an admin, got 403 as a viewer, listed them as a viewer.
Go 1.22 service on the standard library (`net/http` method-pattern `ServeMux`, `log/slog`, `database/sql` with the pgx stdlib driver, PostgreSQL 16). `main` wraps the mux in the auth middleware, so every handler runs for a signed-in org member; `auth.FromContext(ctx)` returns an `auth.Member` with `OrgID` and `Role` (`RoleAdmin`, `RoleMember` or `RoleViewer`). The `webhook_endpoints(id bigserial primary key, org_id bigint not null, url text not null, signing_secret text not null, created_at timestamptz not null default now())` table shipped in last week's migration; the database is encrypted at rest, and app-level encryption of secrets is a separate project. Package `webhooks` already has `writeJSON(w http.ResponseWriter, status int, v any)` in `respond.go`, which sets the JSON content type, writes the status and encodes `v`. The delivery worker lands in the next PR, behind a disabled flag; it will call `Store.ListForOrg` for each event, and it resolves each URL and refuses private, loopback and link-local addresses at send time. Customers verify the `X-Signature` HMAC to trust that an event came from us; orgs routinely add contractors and auditors as viewers.
Requirements
- `POST /api/webhooks` with `{"url": "..."}` registers a webhook endpoint for the caller's org. Only org admins may do it (others get 403 `{"error": "forbidden"}`). The URL must be an absolute `https` URL with a host, no user info, at most 2048 bytes (else 400 `{"error": "invalid_url"}`); a body that is not a single JSON value with a string `url` field (trailing data included), or exceeds 4 KiB, is 400 `{"error": "invalid_body"}`; a missing, null or empty `url` counts as an invalid URL. No de-duplication and no per-org cap in this PR.
- Each endpoint gets a fresh random signing secret (`whsec_` + 32 random bytes, hex). The 201 response returns the endpoint (`id`, `url`, `createdAt`) together with `signingSecret`: this is the only time the secret is ever shown, so the admin copies it into their receiver now.
- `GET /api/webhooks` returns `{"webhooks": [{"id", "url", "createdAt"}]}` for the caller's org, in id order, to any org member (admins, members and read-only viewers), so everyone can see where events are sent. Receivers authenticate our requests by `X-Signature`, not by keeping the URL secret. An org with none gets an empty list.
- The secret is stored in `webhook_endpoints.signing_secret` because the delivery worker signs every payload with it (`X-Signature: HMAC-SHA256(secret, body)`); after creation it is never returned by any endpoint.
- Database errors answer 500 `{"error": "internal"}` and are logged; no internals reach the client. Delete and secret rotation come in a later PR.
Files touched
- cmd/api/routes.go
- internal/webhooks/store.go
- internal/webhooks/handlers.go
--- cmd/api/routes.go
"example.com/hooks/internal/billing"
"example.com/hooks/internal/projects"
+ "example.com/hooks/internal/webhooks"
)
billing.Register(mux, db)
projects.Register(mux, db)
+ webhooks.Register(mux, db)
}
--- internal/webhooks/store.go
+package webhooks
+
+import (
+ "context"
+ "database/sql"
+)
+
+type Store struct {
+ DB *sql.DB
+}
+
+func (s *Store) Create(ctx context.Context, orgID int64, url, secret string) (Endpoint, error) {
+ ep := Endpoint{URL: url, SigningSecret: secret}
+ err := s.DB.QueryRowContext(ctx,
+ `INSERT INTO webhook_endpoints (org_id, url, signing_secret)
+ VALUES ($1, $2, $3) RETURNING id, created_at`,
+ orgID, url, secret,
+ ).Scan(&ep.ID, &ep.CreatedAt)