Skip to main content
This page covers the receiver side: how to write code that accepts SimpleFunctions webhook deliveries, verifies them, and handles retries safely.

Headers

Every delivery includes:
X-SF-Signature: t=<unix_ts>,v1=<hex_hmac>
X-SF-Endpoint-Id: we_...
X-SF-Delivery-Id: del_...
X-SF-Event: alert.fired
Content-Type: application/json

Receiver implementations

import express from 'express'
import crypto from 'crypto'

const app = express()
const SECRET = process.env.SF_WEBHOOK_SECRET!
const seen = new Set<string>()  // replace with Redis in prod

app.post('/sf-hook', express.raw({ type: 'application/json' }), (req, res) => {
  const sig = req.header('x-sf-signature') ?? ''
  const deliveryId = req.header('x-sf-delivery-id') ?? ''
  if (!verify(SECRET, sig, req.body)) return res.status(401).send('bad sig')
  if (seen.has(deliveryId)) return res.status(200).send('ok (dedupe)')
  seen.add(deliveryId)

  const event = JSON.parse(req.body.toString('utf8'))
  console.log(event.type, event.data)
  res.status(200).send('ok')
})

function verify(secret: string, sigHeader: string, body: Buffer): boolean {
  const [tPart, v1Part] = sigHeader.split(',')
  const t = tPart?.split('=')[1]
  const v1 = v1Part?.split('=')[1]
  if (!t || !v1) return false
  const expected = crypto.createHmac('sha256', secret).update(`${t}.${body.toString()}`).digest('hex')
  if (Math.abs(Date.now()/1000 - Number(t)) > 300) return false
  return crypto.timingSafeEqual(Buffer.from(v1, 'hex'), Buffer.from(expected, 'hex'))
}
import hmac, hashlib, time, os
from flask import Flask, request, abort

app = Flask(__name__)
SECRET = os.environ['SF_WEBHOOK_SECRET'].encode()
seen = set()

@app.post('/sf-hook')
def hook():
    sig = request.headers.get('x-sf-signature', '')
    delivery_id = request.headers.get('x-sf-delivery-id', '')
    body = request.get_data()
    if not verify(sig, body): abort(401)
    if delivery_id in seen: return ('ok (dedupe)', 200)
    seen.add(delivery_id)
    event = request.get_json()
    print(event['type'], event['data'])
    return 'ok'

def verify(sig_header: str, body: bytes) -> bool:
    parts = dict(p.split('=', 1) for p in sig_header.split(','))
    t, v1 = parts.get('t'), parts.get('v1')
    if not t or not v1: return False
    if abs(time.time() - int(t)) > 300: return False
    expected = hmac.new(SECRET, f'{t}.{body.decode()}'.encode(), hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, v1)
package main

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "fmt"
    "io"
    "net/http"
    "os"
    "strconv"
    "strings"
    "sync"
    "time"
)

var (
    secret = []byte(os.Getenv("SF_WEBHOOK_SECRET"))
    seen   = sync.Map{}
)

func handle(w http.ResponseWriter, r *http.Request) {
    body, _ := io.ReadAll(r.Body)
    if !verify(r.Header.Get("X-SF-Signature"), body) {
        w.WriteHeader(401); return
    }
    deliveryID := r.Header.Get("X-SF-Delivery-Id")
    if _, dupe := seen.LoadOrStore(deliveryID, true); dupe {
        fmt.Fprintln(w, "ok (dedupe)"); return
    }
    fmt.Fprintln(w, "ok")
}

func verify(sigHeader string, body []byte) bool {
    parts := map[string]string{}
    for _, p := range strings.Split(sigHeader, ",") {
        kv := strings.SplitN(p, "=", 2)
        if len(kv) == 2 { parts[kv[0]] = kv[1] }
    }
    t, v1 := parts["t"], parts["v1"]
    if t == "" || v1 == "" { return false }
    ts, _ := strconv.ParseInt(t, 10, 64)
    if abs(time.Now().Unix()-ts) > 300 { return false }
    mac := hmac.New(sha256.New, secret)
    mac.Write([]byte(fmt.Sprintf("%s.%s", t, string(body))))
    expected := hex.EncodeToString(mac.Sum(nil))
    return hmac.Equal([]byte(expected), []byte(v1))
}

func abs(n int64) int64 { if n < 0 { return -n }; return n }

Replay

If your endpoint went down, SimpleFunctions persists failed deliveries in alert_deliveries. Replay:
sf deliveries --json --status failed
# pick a delivery id
curl -X POST -H "Authorization: Bearer $SF_API_KEY" \
  https://simplefunctions.dev/api/alert-deliveries/<delivery-id>/retry

Idempotency

Always idempotency-key on X-SF-Delivery-Id. SimpleFunctions retries on 5xx and timeouts up to 5 attempts; same delivery id is reused.

Auto-pause

Endpoints that 5xx more than 50% across a 24h window get auto-paused. Re-enable:
sf webhooks resume <endpoint-id>

See also

Webhook events

Payload schemas per event type.

Watch + alerts API

Endpoints for managing watched objects, rules, endpoints.