SmoothDeploy Docs
Organization

Webhooks

Receive a signed HTTP POST when a test run completes or fails

Webhooks notify your systems when a test run finishes. SmoothDeploy sends a signed JSON POST to each endpoint you register, so CI gates, chat notifications, and dashboards can react without polling.

To start runs from outside SmoothDeploy, use an API key instead. Webhooks are outbound only.

Manage Webhooks

Go to Organization → Webhooks. Each webhook has:

  • Endpoint URL — must be https://. See URL rules.
  • Events — which events are delivered. At least one.
  • Enabled — disabled webhooks keep their history but receive nothing.
  • Signing secret — shown once when the webhook is created or rotated. Copy it into your endpoint's secret store; you cannot retrieve it again.

Members can view webhooks and deliveries. Creating, editing, rotating, retrying, and deleting require admin or owner. See Roles and Permissions.

Events

EventFires when
run.completedA run finishes and every test passed
run.failedA run finishes with at least one failed test, or the runner itself failed

Cancelled runs do not emit an event.

Request

POST <your endpoint URL>

Headers

HeaderValue
Content-Typeapplication/json
User-AgentSmoothDeploy-Webhook
X-SmoothDeploy-Eventrun.completed or run.failed
X-SmoothDeploy-DeliveryDelivery ID. Unique per attempt group; identical across retries of the same delivery
X-SmoothDeploy-SignatureHex HMAC-SHA256 of the raw request body, keyed by the webhook's signing secret

Body

{
  "event": "run.failed",
  "deliveryId": "019a2b3c-4d5e-7f80-9123-456789abcdef",
  "timestamp": "2026-10-06T19:24:52.493Z",
  "run": {
    "id": "019a2b3c-0000-7f80-9123-0000000000aa",
    "name": "Checkout smoke",
    "status": "failed",
    "url": "https://app.smoothdeploy.com/test-runs/019a2b3c-0000-7f80-9123-0000000000aa",
    "startedAt": "2026-10-06T19:20:01.000Z",
    "finishedAt": "2026-10-06T19:24:52.000Z",
    "durationMs": 291000,
    "trigger": { "type": "schedule", "schedule": "Nightly" },
    "project": { "id": "019a2b3c-0000-7f80-9123-0000000000bb", "name": "Web shop" },
    "branch": { "id": "019a2b3c-0000-7f80-9123-0000000000cc", "name": "main" },
    "target": { "type": "directory", "id": "019a2b3c-0000-7f80-9123-0000000000dd", "name": "checkout" },
    "stats": { "passed": 2, "failed": 1, "skipped": 0, "total": 3 },
    "tests": [
      { "name": "Add to cart", "status": "passed", "durationMs": 8120, "errorMessage": null, "errorLocation": null },
      { "name": "Apply coupon", "status": "passed", "durationMs": 6430, "errorMessage": null, "errorLocation": null },
      {
        "name": "Pay with card",
        "status": "failed",
        "durationMs": 30000,
        "errorMessage": "Timeout 30000ms exceeded waiting for text \"Order confirmed\"",
        "errorLocation": "pay-with-card.spec.ts Line 42, Column 7"
      }
    ]
  },
  "custom": { "team": "payments", "channel": "#alerts" }
}
FieldNotes
run.statuscompleted or failed
run.urlDeep link to the run in the console
run.triggerWho started the run. { "type": "schedule", "schedule": "<schedule name>" }, { "type": "apiKey", "name": "<API key name>" }, or { "type": "manual", "email": "<user email>" }. The user email is personal data sent to your endpoint
run.target.typetest, directory, or tag: what the run was started on
run.statsTest counts for the run
run.testsOne entry per test. Both error fields are null for passing tests
customYour webhook's custom data, sent unchanged. Omitted when the webhook has none

Custom Data

Each webhook can carry a Custom data JSON object, up to 4 KB. It is sent as custom in every request for that webhook. Use it to route or label events on your side, for example a team name, a chat channel, or an environment tag.

Payload Preview

The add and edit dialogs have a Payload preview section. Expand it to see an example body for the first selected event with your custom data merged in. The preview updates as you type.

The same example is available from the API:

GET /v1/webhooks/sample-payload?event=run.failed

Open a webhook's Deliveries list and click the view button on any row to see the exact body that was sent, plus its status, attempt count, last response code, and last error.

Respond with 200

Your endpoint must return HTTP 200 within 10 seconds. Any other status, including other 2xx codes, redirects, and timeouts, counts as a failed attempt. Return 200 as soon as you have accepted the payload; do heavy work asynchronously.

Retry Policy

Each delivery gets up to three attempts:

AttemptDelay after previous attempt
1immediate
21 second
35 seconds

After the third failed attempt the delivery is marked failed. Open the webhook's Deliveries list in the console to see status, attempt count, and the last response code, and press Retry to run the three-attempt cycle again. Retries reuse the same deliveryId, so handle duplicates idempotently. Retry is refused with 409 while a delivery is still pending.

Delivery history is kept for your plan's retention window.

Verifying the Signature

Compute HMAC-SHA256 over the exact raw bytes of the request body with your signing secret, hex-encode it, and compare it to X-SmoothDeploy-Signature using a constant-time comparison. Do not parse and re-serialise the JSON before hashing.

Node.js

import { createHmac, timingSafeEqual } from "node:crypto";
import express from "express";

const SECRET = process.env.SMOOTHDEPLOY_WEBHOOK_SECRET;

const app = express();

app.post(
  "/hooks/smoothdeploy",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const expected = createHmac("sha256", SECRET).update(req.body).digest("hex");
    const received = req.get("X-SmoothDeploy-Signature") ?? "";
    const ok =
      expected.length === received.length &&
      timingSafeEqual(Buffer.from(expected), Buffer.from(received));
    if (!ok) return res.sendStatus(401);

    const payload = JSON.parse(req.body);
    console.log(payload.event, payload.run.url);
    res.sendStatus(200);
  },
);

app.listen(3000);

express.raw keeps the body as bytes. A JSON body parser would re-encode it and break the signature.

Python

import hashlib
import hmac
import json
import os

from flask import Flask, abort, request

SECRET = os.environ["SMOOTHDEPLOY_WEBHOOK_SECRET"].encode()

app = Flask(__name__)


@app.post("/hooks/smoothdeploy")
def smoothdeploy():
    expected = hmac.new(SECRET, request.get_data(), hashlib.sha256).hexdigest()
    received = request.headers.get("X-SmoothDeploy-Signature", "")
    if not hmac.compare_digest(expected, received):
        abort(401)

    payload = json.loads(request.get_data())
    print(payload["event"], payload["run"]["url"])
    return "", 200

request.get_data() returns the raw bytes. Use it for both the HMAC and the JSON parse.

URL Rules

The endpoint URL is validated when you create or edit a webhook:

  • https:// only.
  • No credentials in the URL (https://user:pass@... is rejected).
  • No private, loopback, link-local, or carrier-grade NAT addresses (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 127.0.0.0/8, 169.254.0.0/16, 100.64.0.0/10, ::1, fc00::/7, fe80::/10, IPv4-mapped IPv6) and no localhost. Hostnames are resolved and rejected when any resolved address falls in those ranges or the hostname does not resolve, both when you save the webhook and before every delivery attempt.
  • Redirects are not followed. A 3xx response is a failed attempt.

Rotating the Secret

Rotate secret generates a new signing secret and shows it once. Deliveries sent after rotation are signed with the new secret immediately, so update your endpoint first if you cannot tolerate a brief window of signature failures. Failed deliveries from that window can be retried from the Deliveries list.

Next Steps