Receive real-time notifications when tasks change state. HumanRail delivers webhook events to your configured callback URL with HMAC-SHA256 signatures for verification.
HumanRail sends the following webhook events. Each event payload contains the full task object at the time the event was emitted.
| Event | Description | When |
|---|---|---|
task.posted |
Task was created and is waiting for assignment. | Immediately after POST /tasks |
task.assigned |
Task was assigned to a worker. | A worker from the matching pool accepted the task |
task.submitted |
Worker submitted a response (not yet verified). | Worker completed the task form |
task.verified |
Worker's output passed verification. This is the event you should act on. | Verification pipeline approved the output |
task.failed |
Task failed verification after all retry attempts. | Verification pipeline rejected the output |
task.cancelled |
Task was cancelled via the API. | After POST /tasks/{id}/cancel |
task.expired |
Task was not completed within the SLA window. | SLA deadline passed without verification |
For most integrations, you only need to handle task.verified,
task.failed, and task.expired. The task.verified
event is the only one that includes the verified output field.
All webhook events share the same envelope structure:
{
"id": "evt_01abc123def456",
"type": "task.verified",
"createdAt": "2025-01-15T10:30:00Z",
"data": {
"id": "01abc123-def4-5678-9abc-def012345678",
"status": "verified",
"taskType": "refund_eligibility",
"riskTier": "medium",
"output": {
"eligible": true,
"reason_code": "approved",
"notes": "Damage confirmed via photo evidence."
},
"payoutResult": {
"id": "pay_01xyz789",
"amount": 0.45,
"currency": "USD",
"rail": "lightning",
"paidAt": "2025-01-15T10:30:01Z"
},
// ... full task fields
}
}
Every webhook request includes an X-Escalation-Signature header.
You must verify this signature to confirm the request came from HumanRail and
was not tampered with.
The signature header has this format:
X-Escalation-Signature: t=1705312200,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
Where:
t is the Unix timestamp (seconds) when the event was sentv1 is the hex-encoded HMAC-SHA256 signaturet and v1 from the header{timestamp}.{raw_request_body}v1 using constant-time comparisont is more than 5 minutes old to prevent replay attacksimport crypto from "node:crypto";
import express from "express";
const WEBHOOK_SECRET = process.env.HUMANRAIL_WEBHOOK_SECRET!;
const TOLERANCE_SECONDS = 300; // 5 minutes
function verifySignature(payload: Buffer, header: string): boolean {
const parts = Object.fromEntries(
header.split(",").map((p) => p.split("=", 2))
);
const timestamp = parseInt(parts.t, 10);
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - timestamp) > TOLERANCE_SECONDS) {
return false; // too old, possible replay
}
const signedPayload = `${timestamp}.${payload.toString("utf-8")}`;
const expected = crypto
.createHmac("sha256", WEBHOOK_SECRET)
.update(signedPayload)
.digest("hex");
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(parts.v1)
);
}
const app = express();
app.post(
"/webhooks/humanrail",
express.raw({ type: "application/json" }),
(req, res) => {
const sig = req.headers["x-escalation-signature"] as string;
if (!verifySignature(req.body, sig)) {
return res.status(401).json({ error: "Invalid signature" });
}
const event = JSON.parse(req.body);
console.log("Received:", event.type, event.data.id);
res.sendStatus(200);
}
);
import hashlib
import hmac
import time
from fastapi import FastAPI, Request, HTTPException
WEBHOOK_SECRET = os.environ["HUMANRAIL_WEBHOOK_SECRET"]
TOLERANCE_SECONDS = 300 # 5 minutes
def verify_signature(payload: bytes, header: str) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
timestamp = int(parts["t"])
if abs(time.time() - timestamp) > TOLERANCE_SECONDS:
return False # too old, possible replay
signed_payload = f"{timestamp}.{payload.decode('utf-8')}"
expected = hmac.new(
WEBHOOK_SECRET.encode("utf-8"),
signed_payload.encode("utf-8"),
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, parts["v1"])
app = FastAPI()
@app.post("/webhooks/humanrail")
async def handle_webhook(request: Request):
body = await request.body()
signature = request.headers.get("x-escalation-signature", "")
if not verify_signature(body, signature):
raise HTTPException(status_code=401, detail="Invalid signature")
event = await request.json()
print(f"Received: {event['type']} {event['data']['id']}")
return {"ok": True}
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"math"
"net/http"
"strconv"
"strings"
"time"
)
const toleranceSeconds = 300 // 5 minutes
func verifySignature(payload []byte, header, secret string) bool {
parts := make(map[string]string)
for _, segment := range strings.Split(header, ",") {
kv := strings.SplitN(segment, "=", 2)
if len(kv) == 2 {
parts[kv[0]] = kv[1]
}
}
timestamp, _ := strconv.ParseInt(parts["t"], 10, 64)
now := time.Now().Unix()
if math.Abs(float64(now-timestamp)) > toleranceSeconds {
return false
}
signedPayload := fmt.Sprintf("%d.%s", timestamp, payload)
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(signedPayload))
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(parts["v1"]))
}
func webhookHandler(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
sig := r.Header.Get("X-Escalation-Signature")
if !verifySignature(body, sig, webhookSecret) {
http.Error(w, "Invalid signature", http.StatusUnauthorized)
return
}
// Process the event...
w.WriteHeader(http.StatusOK)
}
HumanRail automatically retries webhook deliveries that fail (non-2xx response or timeout). The retry schedule uses exponential backoff:
| Attempt | Delay | Cumulative time |
|---|---|---|
| 1 (initial) | Immediate | 0s |
| 2 | 30 seconds | 30s |
| 3 | 2 minutes | ~2.5 min |
| 4 | 10 minutes | ~12.5 min |
| 5 | 30 minutes | ~42.5 min |
| 6 | 1 hour | ~1h 42min |
| 7 | 4 hours | ~5h 42min |
| 8 (final) | 8 hours | ~13h 42min |
After 8 failed attempts, the webhook is marked as failed. Key details:
X-Escalation-Signature header with a fresh timestamp
Respond with 200 OK immediately and process the event asynchronously.
This prevents timeouts and ensures reliable delivery. Use the event's id
field for idempotent processing in case of retries.
HumanRail provides a POST /webhooks/test endpoint that sends a
synthetic event to your callback URL. This is useful for verifying your handler
works correctly before going live.
curl -X POST https://api.humanrail.dev/v1/webhooks/test \
-H "Authorization: Bearer ek_test_your_key" \
-H "Content-Type: application/json" \
-d '{
"eventType": "task.verified",
"callbackUrl": "https://your-app.com/webhooks/humanrail"
}'
To receive webhooks on your local machine, use a tunneling service to expose your local server to the internet:
# Start your local webhook handler
node server.js # listening on port 3000
# In another terminal, start a tunnel (using ngrok as an example)
ngrok http 3000
# Forwarding: https://abc123.ngrok.io -> http://localhost:3000
# Use the ngrok URL as your callbackUrl
curl -X POST https://api.humanrail.dev/v1/webhooks/test \
-H "Authorization: Bearer ek_test_your_key" \
-H "Content-Type: application/json" \
-d '{"eventType": "task.verified", "callbackUrl": "https://abc123.ngrok.io/webhooks/humanrail"}'
The HumanRail CLI includes a built-in webhook listener for local development:
# Install the CLI
npm install -g @humanrail/cli
# Start listening for webhook events (forwards to localhost:3000)
humanrail webhooks listen --forward-to http://localhost:3000/webhooks/humanrail
# In another terminal, trigger a test event
humanrail webhooks trigger --event task.verified
id to deduplicate. Retries may deliver the same event more than once.