Webhooks

Receive real-time notifications when tasks change state. HumanRail delivers webhook events to your configured callback URL with HMAC-SHA256 signatures for verification.

Event types

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
Note

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.

Event payload

All webhook events share the same envelope structure:

JSON
{
  "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
  }
}

Signature verification

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.

Signature format

The signature header has this format:

text
X-Escalation-Signature: t=1705312200,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd

Where:

Verification steps

  1. Extract t and v1 from the header
  2. Construct the signed payload: {timestamp}.{raw_request_body}
  3. Compute HMAC-SHA256 of the signed payload using your webhook secret
  4. Compare the computed signature with v1 using constant-time comparison
  5. Optionally, reject events where t is more than 5 minutes old to prevent replay attacks

Code examples

TypeScript (Express)
import 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);
  }
);
Python (FastAPI)
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}
Go
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)
}

Retry policy

HumanRail automatically retries webhook deliveries that fail (non-2xx response or timeout). The retry schedule uses exponential backoff:

Attempt Delay Cumulative time
1 (initial)Immediate0s
230 seconds30s
32 minutes~2.5 min
410 minutes~12.5 min
530 minutes~42.5 min
61 hour~1h 42min
74 hours~5h 42min
8 (final)8 hours~13h 42min

After 8 failed attempts, the webhook is marked as failed. Key details:

Best practice

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.

Testing webhooks locally

Using the test endpoint

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.

bash
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"
  }'

Using a tunnel for local development

To receive webhooks on your local machine, use a tunneling service to expose your local server to the internet:

bash
# 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"}'

Using the HumanRail CLI

The HumanRail CLI includes a built-in webhook listener for local development:

bash
# 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

Security best practices