Skip to content

Receiving webhooks

Anyone who learns your receiver’s address could send it a message that looks like it came from SpeculaPro. So every webhook carries a signature: a short code computed from the message and a secret that only SpeculaPro and your system know. Your receiver computes the same code itself; if they match, the message is genuine and wasn’t changed on the way.

A receiver must do five things:

  1. Check the signature, against the raw bytes it received.
  2. Reject old messages, so a message someone recorded can’t be replayed later.
  3. Ignore duplicates, because the same event can arrive more than once.
  4. Answer quickly, with a 2xx status, and do the real work afterwards.
  5. Keep the secret secret.

SpeculaPro signs according to the Standard Webhooks specification, so any library that implements it works. The examples below use only each language’s standard library, so there’s nothing extra to install or keep up to date. They were tested against messages signed by SpeculaPro’s own signing code, including during a secret rotation.

Each request has three headers:

Header Example Meaning
webhook-id 01a1208c-0c9a-73e4-89c9-5a8a6e1cf30a The event’s ID
webhook-timestamp 1791557494 When it was signed, in seconds since 1 January 1970
webhook-signature v1,5XZVYc6LFmzXOu7iGiFL83jgw7+MQUPo1tTCN/v01to= One or more signatures, separated by spaces

To check it:

  1. Take the secret, which starts whsec_. Remove whsec_ and base64-decode the rest: that is the key.

  2. Build the text that was signed: the webhook-id, a full stop, the webhook-timestamp, a full stop, then the raw request body, exactly as received:

    01a1208c-0c9a-73e4-89c9-5a8a6e1cf30a.1791557494.{"id": "01a1208c-…", …}
  3. Compute HMAC-SHA256 of that text with the key, and base64-encode the result.

  4. Split webhook-signature on spaces. Each part looks like v1,<base64>. The message is genuine if any v1 part equals your result.

  5. Compare in constant time (see below), and check the timestamp is within 5 minutes of now.

Each example is a complete small receiver. It reads the secret from the environment variable SPECULAPRO_WEBHOOK_SECRET (and, optionally, SPECULAPRO_WEBHOOK_SECRET_PREVIOUS), listens for POST /speculapro, checks the message, answers 204 and processes the event afterwards. Adapt the last part to your system.

Go 1.22 or later; standard library only.

package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"errors"
"io"
"log"
"net/http"
"os"
"strconv"
"strings"
"sync"
"time"
)
// Reject requests signed more than 5 minutes before or after now.
const tolerance = 5 * time.Minute
// verify checks a SpeculaPro webhook. It returns nil if any signature in the
// webhook-signature header matches any of your secrets.
func verify(secrets []string, h http.Header, body []byte, now time.Time) error {
id, ts, sigs := h.Get("webhook-id"), h.Get("webhook-timestamp"), h.Get("webhook-signature")
if id == "" || ts == "" || sigs == "" {
return errors.New("missing webhook headers")
}
sec, err := strconv.ParseInt(ts, 10, 64)
if err != nil {
return errors.New("invalid webhook-timestamp")
}
if d := now.Sub(time.Unix(sec, 0)); d > tolerance || d < -tolerance {
return errors.New("webhook-timestamp too old or too new")
}
signed := []byte(id + "." + ts + "." + string(body))
for _, secret := range secrets {
key, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(secret, "whsec_"))
if err != nil {
return errors.New("secret is not a whsec_ value")
}
mac := hmac.New(sha256.New, key)
mac.Write(signed)
want := mac.Sum(nil)
for _, part := range strings.Fields(sigs) {
version, b64, ok := strings.Cut(part, ",")
if !ok || version != "v1" {
continue
}
got, err := base64.StdEncoding.DecodeString(b64)
if err == nil && hmac.Equal(got, want) { // constant-time
return nil
}
}
}
return errors.New("no valid signature")
}
var (
mu sync.Mutex
seen = map[string]bool{} // use a database table with a unique key in production
)
func handler(secrets []string, work chan<- []byte) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20)) // the raw bytes, before any JSON parsing
if err != nil {
http.Error(w, "body too large", http.StatusRequestEntityTooLarge)
return
}
if err := verify(secrets, r.Header, body, time.Now()); err != nil {
http.Error(w, err.Error(), http.StatusUnauthorized)
return
}
id := r.Header.Get("webhook-id")
mu.Lock()
dup := seen[id]
seen[id] = true
mu.Unlock()
if !dup {
work <- body // process after answering
}
w.WriteHeader(http.StatusNoContent)
}
}
func main() {
secrets := []string{os.Getenv("SPECULAPRO_WEBHOOK_SECRET")}
if prev := os.Getenv("SPECULAPRO_WEBHOOK_SECRET_PREVIOUS"); prev != "" {
secrets = append(secrets, prev)
}
work := make(chan []byte, 1000)
go func() {
for body := range work {
log.Printf("event: %s", body) // your business logic here
}
}()
http.Handle("POST /speculapro", handler(secrets, work))
log.Fatal(http.ListenAndServe(":8000", nil))
}

The signature covers the body byte for byte. If your framework parses the JSON and you re-serialise it, the spacing or field order changes and the signature no longer matches, even though the content is the same. Read the raw body first, check it, and only then parse it. In Express, for example, use express.raw({ type: "application/json" }) on the webhook route instead of express.json().

SpeculaPro may send fields in a different order, or with different spacing, from the examples in the Webhooks API. That doesn’t matter as long as you check the bytes you received.

An ordinary string comparison stops at the first character that differs, so it takes slightly longer the more characters match. An attacker sending many guesses can measure that and work out a valid signature bit by bit. Constant-time comparison always takes the same time. Use your language’s:

Language Function
Go hmac.Equal
Node.js crypto.timingSafeEqual (both buffers must be the same length, so check that first)
Python hmac.compare_digest

Each attempt is signed with the time it was sent. Reject any message whose webhook-timestamp is more than 5 minutes before or after your clock. That stops someone who recorded a genuine message from replaying it later, because the timestamp is part of what’s signed and can’t be changed. Keep your receiver’s clock synchronised (NTP).

Retries and resends are signed again at the time they’re sent, so they pass this check.

The same event can reach you more than once: when your receiver accepted it but its answer was lost or late, when an administrator selects Resend, or, rarely, when a SpeculaPro worker restarts mid delivery. Every copy has the same webhook-id.

Record each webhook-id you have processed, for example in a database table with a unique key, and skip any you have seen. Keep them for at least as long as SpeculaPro retries (about 28 hours); a few days is safer.

Events can also arrive out of order, because one can be retried while a later one goes straight through. Don’t let an older event overwrite newer information: compare created_at, or fetch the current state from SpeculaPro.

SpeculaPro waits 10 seconds for an answer, then counts the attempt as failed and retries. Answer with 200, 202 or 204 as soon as the signature checks out and the event is safely recorded, then do the real work (opening a ticket, calling your core banking system) afterwards, from a queue.

Answer SpeculaPro does
Any 2xx Counts it as delivered
3xx Counts it as failed: redirects aren’t followed. Use the final address
4xx or 5xx Counts it as failed and retries, on the schedule
Nothing within 10 seconds Counts it as failed and retries

Answer 401 to a message that fails the checks. SpeculaPro will retry it, which is harmless; a real message never fails them unless the secret is wrong. If your receiver keeps rejecting genuine messages, check that it has the current secret.

Keep error responses short and free of secrets: the first 200 characters of your answer are shown in the delivery log, where administrators can read them.

When an administrator rotates the secret, SpeculaPro signs every message with both the new and the old secret for an overlap period (24 hours from the screen), so webhook-signature has two parts:

v1,5XZVYc6LFmzXOu7iGiFL83jgw7+MQUPo1tTCN/v01to= v1,p9nLEXT+aNhDZz9RjoZRza3kF7LBWATjRCf6kDyKB3Y=

Your receiver accepts a message if any signature matches any secret it holds, so:

  1. During the overlap, your receiver keeps working with the old secret.
  2. Put the new secret in place, as SPECULAPRO_WEBHOOK_SECRET in the examples, before the overlap ends.
  3. If you can’t switch every receiver instance at once, keep the old one as SPECULAPRO_WEBHOOK_SECRET_PREVIOUS until they have all switched, then remove it.
  • Store it in your secret manager, never in source code or a ticket.
  • Use one secret per endpoint, so a leak affects one system.
  • If it might have leaked, rotate it with no overlap. See Rotate the signing secret.
  1. Deploy the receiver and give it the secret.
  2. In Settings → Webhooks, select the endpoint and then Send test event.
  3. Check Recent deliveries: a green tick and HTTP 204 means it worked. A red cross shows your receiver’s answer or the connection error.

The test event’s type is webhook.test. Make sure your receiver accepts it (answers 2xx) even though it doesn’t act on it.