Skip to content

Products

Compliance Officer Service Expert-led compliance, end to end Compliance Portal Share security documents securely Open-source platform Deploy Probo on your own infrastructure

Resources

Probo stories How teams get compliant with Probo Blog Ideas and guidance from the Probo team Guides & tools Practical compliance guides and free tools Love from Customers What customers say about working with Probo Changelog Latest product updates Download Get the Probo Agent

Company

About The people and vision powering Probo Careers Join the team building Probo Brand assets Official logos and visual resources Security Review our security and compliance posture
Overview Understand Probo and its core concepts Product Explore Probo's GRC capabilities Developers Explore GraphQL, CLI, MCP, n8n, and webhooks Deployment Probo Cloud, self-hosting, and configuration

Explore

GitHub Explore our open-source compliance tools

Webhooks Quickstart

Stand up a Probo webhook receiver, create a subscription, trigger a test event, and confirm delivery with complete Go, Python, and TypeScript handlers.

View as Markdown

This guide takes you from an empty HTTPS endpoint to a confirmed Probo delivery. The handlers verify the signature on the raw body, parse the envelope, ignore duplicate eventId values, enqueue work, and return 2xx quickly.

You need:

  • A Probo organization where you can manage webhook subscriptions.
  • A public HTTPS URL. Probo does not deliver to http:// endpoints.
  • The whsec_ signing secret shown when you create the subscription.

If you are developing on localhost, put a TLS tunnel in front of your process (for example Cloudflare Tunnel or ngrok) and use that https:// URL as the subscription endpoint.

Store the signing secret in PROBO_WEBHOOK_SECRET. Pick a language and start the server, then expose it on HTTPS.

package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"strconv"
"sync"
"time"
)
type envelope struct {
EventID string `json:"eventId"`
SubscriptionID string `json:"subscriptionId"`
OrganizationID string `json:"organizationId"`
EventType string `json:"eventType"`
CreatedAt time.Time `json:"createdAt"`
Data json.RawMessage `json:"data"`
}
func main() {
secret := os.Getenv("PROBO_WEBHOOK_SECRET")
if secret == "" {
log.Fatal("PROBO_WEBHOOK_SECRET is required")
}
seen := newIDSet()
jobs := make(chan envelope, 64)
go func() {
for event := range jobs {
log.Printf("processing %s %s", event.EventType, event.EventID)
}
}()
http.HandleFunc("/webhooks/probo", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
body, err := verifyWebhook(r, secret)
if err != nil {
http.Error(w, "unauthorized", http.StatusForbidden)
return
}
var event envelope
if err := json.Unmarshal(body, &event); err != nil || event.EventID == "" {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
if seen.has(event.EventID) {
w.WriteHeader(http.StatusOK)
return
}
select {
case jobs <- event:
seen.record(event.EventID)
w.WriteHeader(http.StatusOK)
default:
w.Header().Set("Retry-After", "30")
http.Error(w, "try again", http.StatusTooManyRequests)
}
})
log.Fatal(http.ListenAndServe(":8080", nil))
}
func verifyWebhook(r *http.Request, signingSecret string) ([]byte, error) {
body, err := io.ReadAll(r.Body)
if err != nil {
return nil, err
}
timestamp := r.Header.Get("X-Probo-Webhook-Timestamp")
signature := r.Header.Get("X-Probo-Webhook-Signature")
if timestamp == "" || signature == "" {
return nil, fmt.Errorf("missing signature headers")
}
mac := hmac.New(sha256.New, []byte(signingSecret))
mac.Write([]byte(timestamp))
mac.Write([]byte(":"))
mac.Write(body)
received, err := hex.DecodeString(signature)
if err != nil || !hmac.Equal(mac.Sum(nil), received) {
return nil, fmt.Errorf("invalid signature")
}
signedAt, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil {
return nil, fmt.Errorf("invalid timestamp")
}
delta := time.Now().Unix() - signedAt
if delta > 300 || delta < -300 {
return nil, fmt.Errorf("stale timestamp")
}
return body, nil
}
type idSet struct {
mu sync.Mutex
m map[string]struct{}
}
func newIDSet() *idSet {
return &idSet{m: make(map[string]struct{})}
}
func (s *idSet) has(id string) bool {
s.mu.Lock()
defer s.mu.Unlock()
_, ok := s.m[id]
return ok
}
func (s *idSet) record(id string) {
s.mu.Lock()
defer s.mu.Unlock()
s.m[id] = struct{}{}
}
Terminal window
export PROBO_WEBHOOK_SECRET='whsec_...'
go run .

Replace the in-memory eventId set with a unique index in your database before you handle production traffic. Replace the in-process queue with your job system.

These examples log the event. In production, keep slow work off the request path so you can return 2xx within 15 seconds. See Delivery and recovery.

Subscribe only to the events you will handle. Copy the signing secret immediately — Probo shows the whsec_ value when you create the subscription, and the CLI does not print it later.

  1. Open Settings > Webhooks.
  2. Enter your public HTTPS URL, including the path (for example https://hooks.example.com/webhooks/probo).
  3. Select a small event set, such as User created.
  4. Save the subscription and store the whsec_ secret as PROBO_WEBHOOK_SECRET.
  5. Restart the receiver if it started before the secret was set.

You can also create subscriptions with createWebhookSubscription or the n8n Probo Trigger. Those clients need the v1:webhook OAuth scope.

Make a matching change in Probo. For a USER_CREATED subscription, invite or create a user in the same organization.

Then check that Probo accepted the delivery:

Terminal window
prb webhook event list <webhook-subscription-id>

The list shows each delivery ID, status (PENDING, SUCCEEDED, or FAILED), and creation time. You can also open Settings > Webhooks and inspect delivery history there.

On the receiver, you should see a log line for the new eventId. A second POST with the same ID is a retry — the handler returns 200 without enqueueing again.

If the status stays PENDING or becomes FAILED, see Delivery and recovery. Common first-run failures are HTTP instead of HTTPS, a body that was parsed before verification, and the wrong signing secret.