Completion webhooks

Notify external systems when a run finishes. HMAC-SHA256 signed, retry-on-5xx, delivery log per trigger.

Each trigger can carry an optional webhookUrl and webhookSecret. After every terminal Run transition (succeeded / failed / timed_out), pipedai-api POSTs the run summary to that URL with an HMAC-SHA256 signature header. Receivers verify the signature constant-time and act on the result — Slack notifications, downstream queue triggers, billing events, anything.

Configuration

Open the trigger editor and scroll to Completion webhook. Two fields:

  • URL — where to POST. Reachable from the API server (currently deployed at api-beta.pipedai.app).
  • Signing secret — at least 16 characters. Click Generate to produce a fresh 32-byte hex string in your browser (crypto.getRandomValues), or paste your own. Stored encrypted server-side and never returned in any read response — when editing an existing trigger, the field is empty by default; type a new value to rotate, or leave blank to keep the existing secret.

Payload

json{
  "runId": "<uuid>",
  "triggerId": "<uuid>",
  "status": "succeeded | failed | timed_out",
  "durationMs": 1234,
  "shortMessage": "first 500 chars of stdout | null",
  "errorMessage": "stderr on failure | null",
  "costUsd": "0.012 | null",
  "faultClass": "infra | client | none",
  "endedAt": "ISO8601 | null"
}

Signature header

X-Pipedai-Signature: sha256=<hex>

The hex value is the HMAC-SHA256 of the exact request body bytes with webhookSecret as the key. The sha256= prefix matches Stripe / GitHub conventions so existing libraries that handle one work with the other.

Verify constant-time
Always compare signatures with a constant-time function (Node crypto.timingSafeEqual, Python hmac.compare_digest, Ruby Rack::Utils.secure_compare). String equality leaks timing information that lets an attacker brute-force the signature.

Verification — Node.js / Express

tsimport crypto from 'crypto';
import express from 'express';

const app = express();
const SECRET = process.env.PIPEDAI_WEBHOOK_SECRET!;

// IMPORTANT: read the raw body BEFORE express.json() consumes it. The
// HMAC is computed over the exact bytes pipedai-api sent.
app.post('/pipedai-webhook', express.raw({ type: 'application/json' }), (req, res) => {
  const signature = req.header('x-pipedai-signature') ?? '';
  const expected =
    'sha256=' +
    crypto.createHmac('sha256', SECRET).update(req.body).digest('hex');

  // Use timingSafeEqual to avoid timing leaks. Bail fast on length mismatch
  // since timingSafeEqual throws on unequal-length buffers.
  if (
    signature.length !== expected.length ||
    !crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
  ) {
    return res.status(401).send('Invalid signature');
  }

  const event = JSON.parse(req.body.toString('utf8'));
  console.log('PipedAI run', event.runId, 'finished:', event.status);
  // … your handler here
  res.sendStatus(204);
});

Verification — Python / FastAPI

pythonimport hmac, hashlib, os
from fastapi import FastAPI, Request, HTTPException

app = FastAPI()
SECRET = os.environ['PIPEDAI_WEBHOOK_SECRET'].encode()

@app.post('/pipedai-webhook')
async def pipedai_webhook(request: Request):
    body = await request.body()  # raw bytes, NOT request.json()
    signature = request.headers.get('x-pipedai-signature', '')
    expected = 'sha256=' + hmac.new(SECRET, body, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(signature, expected):
        raise HTTPException(401, 'Invalid signature')

    event = await request.json()
    print(f"PipedAI run {event['runId']} finished: {event['status']}")
    # … your handler here
    return {'ok': True}

Verification — Ruby / Rails

ruby# app/controllers/pipedai_webhooks_controller.rb
require 'openssl'

class PipedaiWebhooksController < ApplicationController
  skip_before_action :verify_authenticity_token

  def create
    body = request.raw_post  # raw bytes, NOT params
    signature = request.headers['X-Pipedai-Signature'].to_s
    expected = 'sha256=' + OpenSSL::HMAC.hexdigest('SHA256', ENV['PIPEDAI_WEBHOOK_SECRET'], body)

    unless ActiveSupport::SecurityUtils.secure_compare(signature, expected)
      return head :unauthorized
    end

    event = JSON.parse(body)
    Rails.logger.info "PipedAI run #{event['runId']} finished: #{event['status']}"
    # … your handler here
    head :no_content
  end
end

Retry policy

  • 2xx → success, recorded as delivered
  • 5xx / 408 / 429 / network error → retried up to 3 attempts with exponential backoff (1s / 4s / 16s)
  • 4xx other than 408 / 429 → permanent failure, no retry. Caller webhook is misconfigured; the run is recorded as failed in the delivery log

Each terminal outcome produces one WebhookDelivery row with the final attempt's outcome (status code, truncated response body, attempt count, error message). The trigger editor surfaces the most recent 5 deliveries inline, and the full history is accessible via GET /api/v1/triggers/:id/webhook-deliveries.

Idempotency

The retry path can deliver the same run more than once. Receivers should treat runId as the idempotency key — process each runId at most once on your side. PipedAI does NOT include an explicit idempotency-key header; runId is unique and stable.

Operational notes

  • Webhooks fire on every terminal transition, including failed runs and the new runs created by the auto-retry path. A single trigger fire that fails twice and succeeds on retry produces three webhook calls (one per terminal Run record).
  • Rotation: changing the secret in the editor takes effect on the next firing. In-flight runs use whatever secret was current when they fired.
  • Delivery is fire-and-forget — the worker's /complete RPC isn't blocked on your endpoint. A slow/failing webhook never slows pipeline execution.
  • Verify with the troubleshooting guide if deliveries aren't landing — most issues are firewall rules or response codes outside the retryable set.