Documentation
Webhooks
Webhooks send Halo's audit and sign-in events to your own HTTPS endpoint as they happen, so a SIEM, a chat channel or an internal tool can react to them. Each request is signed with a secret only you and Halo know.
Security administrators manage webhook endpoints; every administrator can read them and their delivery logs.
Events
Every event has the same envelope:
{
"id": "aud_01JA2X5Q6R7S8T9V0W1X2Y3Z4A",
"type": "user.invite",
"time": "2026-10-04T10:00:10.123456Z",
"data": {
"id": "aud_01JA2X5Q6R7S8T9V0W1X2Y3Z4A",
"time": "2026-10-04T10:00:10.123456+00:00",
"actorId": "usr_01J9Z8Q4M2N5P7R9T1V3X5Z7B9",
"action": "user.invite",
"summary": "Invited Sam Lee <sam@example.com>",
"targetType": "user",
"targetId": "usr_01JA2X5Q6R7S8T9V0W1X2Y3Z4B",
"target": "Sam Lee",
"ip": "203.0.113.7"
}
}
There are two sources:
- Audit events. The type is the audit action, such as
user.invite,group.member.add,application.secret.rotate,policy.update,scim.user.suspendoraccess_request.approve.datais the audit event:id,time,actorId(null for changes Halo made itself),action,summary,targetType,targetId,targetandip. - Sign-in events. The type is
sign_in.success,sign_in.failureorsign_in.interrupted.datais the sign-in event:id,time,userId,email,appId,result,method,ip,location,device,riskandreason.
The Audit log in the console shows every action name Halo writes. GET /api/v1/webhooks/event-types lists the families, such as user.*, sign_in.* and policy.*.
Add an endpoint
- In the console, open Webhooks and choose Add endpoint.
- Enter the URL. It must use https; plain http works only for
localhost, names under.localhostand loopback addresses while you test. - Choose the events, between 1 and 50 entries. An entry is an exact type such as
user.suspend, a family such asuser.*, which matches every type that starts withuser., or*for everything. - Save, and copy the Signing secret. It starts with
whsec_and Halo shows it only now. - Send a test event from the endpoint's page. Halo sends a
webhook.testevent right away and shows the result.
An endpoint receives only events that happen after it was created. Pausing an endpoint stops deliveries: events that happen while it is paused are never sent, and deliveries that were already waiting resume when you turn it back on. Deleting an endpoint deletes its delivery log.
The secret cannot be changed. To replace it, add a new endpoint, move your receiver to the new secret, and delete the old endpoint.
Delivery
Halo looks for new events every 10 seconds and sends events that are at least 5 seconds old. Each delivery is an HTTP POST with:
| Header | Value |
|---|---|
Content-Type |
application/json |
User-Agent |
Halo-Webhooks |
Halo-Signature |
t=<unix time>,v1=<hex HMAC-SHA256> |
- Any
2xxanswer within 10 seconds counts as delivered. Halo does not follow redirects, so a3xxanswer is a failure. - After a failure, Halo retries after 30 seconds, then waits twice as long before each further attempt: 1, 2, 4, 8, 16 and 32 minutes. After 8 attempts, about an hour after the first, the delivery is marked failed.
- Each event is queued once per endpoint. A retry sends the same body, with the same
id, and a new signature. - A retried delivery can arrive after newer events. Use
timeto order events andidto ignore ones you already processed.
The endpoint's page in the console lists its 50 most recent deliveries with their status, number of attempts, the HTTP status your endpoint answered, the error and the exact body sent.
Verify the signature
The Halo-Signature header has two parts:
t, the Unix time in seconds when Halo sent this attempt;v1, the hex-encoded HMAC-SHA256 oft, a dot, and the raw request body, keyed with the whole signing secret, includingwhsec_.
To verify a request:
- Read the raw body before parsing it. Re-encoding the JSON changes the bytes and breaks the signature.
- Split the header on
,and each part on=. - Reject the request when
tis more than 5 minutes away from your clock. - Compute the HMAC over
t + "." + bodyand compare it withv1in constant time. - Answer
2xxquickly, and do slow work afterwards.
A complete receiver in Go:
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"log"
"net/http"
"os"
"strconv"
"strings"
"time"
)
type event struct {
ID string `json:"id"`
Type string `json:"type"`
Time time.Time `json:"time"`
Data json.RawMessage `json:"data"`
}
func verify(secret []byte, header string, body []byte) bool {
var t, sig string
for _, part := range strings.Split(header, ",") {
key, value, _ := strings.Cut(part, "=")
switch key {
case "t":
t = value
case "v1":
sig = value
}
}
unix, err := strconv.ParseInt(t, 10, 64)
if err != nil || time.Since(time.Unix(unix, 0)).Abs() > 5*time.Minute {
return false
}
mac := hmac.New(sha256.New, secret)
mac.Write([]byte(t + "."))
mac.Write(body)
want := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(want), []byte(sig))
}
func main() {
secret := []byte(os.Getenv("HALO_WEBHOOK_SECRET"))
http.HandleFunc("POST /halo", func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20))
if err != nil || !verify(secret, r.Header.Get("Halo-Signature"), body) {
http.Error(w, "invalid signature", http.StatusUnauthorized)
return
}
var e event
if err := json.Unmarshal(body, &e); err != nil {
http.Error(w, "invalid body", http.StatusBadRequest)
return
}
log.Printf("%s %s %s", e.Time.Format(time.RFC3339), e.Type, e.ID)
w.WriteHeader(http.StatusNoContent)
})
log.Fatal(http.ListenAndServe(":9100", nil))
}
Run it with the secret from step 4 of Add an endpoint:
HALO_WEBHOOK_SECRET=whsec_… go run .
Halo stores the secret encrypted with HALO_SECRET_KEY. If that key changes, Halo can no longer sign deliveries for existing endpoints; add new endpoints.
API
Endpoints are at /api/v1/webhooks, with /api/v1/webhooks/{id}/test to send a test event; see the API.