Webhooks
The os/webhook package provides a generic inbound webhook server for receiving external events — GitHub deliveries, payment provider callbacks, CI notifications, or any other system that pushes JSON over HTTP. It routes incoming requests to registered handlers by event type and can gate access with a shared secret.
The snippets on this page assume these imports:
import (
"context"
"fmt"
"log"
"net/http"
"github.com/spawn08/chronos/os/webhook"
)
Creating a server
webhook.NewServer takes an optional shared secret. Pass "" to disable secret validation.
srv := webhook.NewServer("my-webhook-secret")
Internally, NewServer builds its own *http.ServeMux and registers both /webhook and /webhook/ (so subpaths like /webhook/github also route to the handler).
Registering handlers
Register a Handler for a specific event type with On. Multiple handlers can be registered for the same event type — all of them run for each matching event.
type Handler func(ctx context.Context, event Event) error
type Event struct {
Source string `json:"source"`
Type string `json:"type"`
Body json.RawMessage `json:"body"`
Headers map[string]string `json:"headers,omitempty"`
}
srv.On("deployment", func(ctx context.Context, e webhook.Event) error {
log.Printf("deployment event from %s: %s", e.Source, string(e.Body))
return nil
})
// "*" registers a wildcard handler that runs for every event type,
// in addition to any type-specific handlers.
srv.On("*", func(ctx context.Context, e webhook.Event) error {
log.Printf("event received: type=%s source=%s", e.Type, e.Source)
return nil
})
If a handler returns an error, the server responds 500 Internal Server Error and includes the aggregated handler errors in the response body. If every handler for the event succeeds, the server responds 200 OK with {"status":"ok"}.
How events are built
For each incoming POST request, the server reads up to 1 MiB of the body and builds an Event:
Typecomes from theX-Event-Typerequest header. If the header is absent, the type defaults to"generic".Sourcecomes from theX-Event-Sourcerequest header.Bodyis the raw request body, exposed asjson.RawMessageso handlers can unmarshal it into whatever shape they expect.Headerscontains every header from the incoming request.
Only POST requests are accepted; any other method gets 405 Method Not Allowed.
Mounting the server
Server.Handler() returns an http.Handler you can mount directly, or run standalone:
func main() {
srv := webhook.NewServer("my-webhook-secret")
srv.On("payment.succeeded", func(ctx context.Context, e webhook.Event) error {
fmt.Println("payment succeeded:", string(e.Body))
return nil
})
log.Fatal(http.ListenAndServe(":8090", srv.Handler()))
}
Because Handler() returns a plain http.Handler, it can also be mounted under an existing mux alongside other routes (for example, on a subpath of your own http.ServeMux or a ChronosOS-embedded server — see The ChronosOS Server).
Securing the endpoint
Passing a non-empty secret to NewServer enables validation on every request: the server compares the incoming X-Webhook-Secret header against the configured secret and responds 401 Unauthorized if it doesn't match.
srv := webhook.NewServer("my-webhook-secret")
curl -X POST http://localhost:8090/webhook \
-H "X-Event-Type: deployment" \
-H "X-Event-Source: ci" \
-H "X-Webhook-Secret: my-webhook-secret" \
-d '{"status":"ok"}'
Notes on this mechanism:
- The check is a direct header comparison against the configured secret — not an HMAC-signed payload. If the sender you're integrating with supports signing (e.g. an
X-Hub-Signaturestyle header), verify that signature yourself inside your handler before trustingevent.Body; the raw headers are available onevent.Headersfor exactly this purpose. - Always terminate the webhook endpoint behind TLS in production so the shared secret isn't sent in plaintext.
- Leave the secret as
""only for local development or trusted internal networks.
Complete example
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"github.com/spawn08/chronos/os/webhook"
)
type deployPayload struct {
Service string `json:"service"`
Status string `json:"status"`
}
func main() {
srv := webhook.NewServer("my-webhook-secret")
srv.On("deployment", func(ctx context.Context, e webhook.Event) error {
var p deployPayload
if err := json.Unmarshal(e.Body, &p); err != nil {
return fmt.Errorf("invalid deployment payload: %w", err)
}
log.Printf("deployment for %s is now %s", p.Service, p.Status)
return nil
})
srv.On("*", func(ctx context.Context, e webhook.Event) error {
log.Printf("webhook received: type=%s source=%s", e.Type, e.Source)
return nil
})
log.Println("listening on :8090")
log.Fatal(http.ListenAndServe(":8090", srv.Handler()))
}