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
| Event | Fires when |
|---|---|
run.completed | A run finishes and every test passed |
run.failed | A 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
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | SmoothDeploy-Webhook |
X-SmoothDeploy-Event | run.completed or run.failed |
X-SmoothDeploy-Delivery | Delivery ID. Unique per attempt group; identical across retries of the same delivery |
X-SmoothDeploy-Signature | Hex 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" }
}| Field | Notes |
|---|---|
run.status | completed or failed |
run.url | Deep link to the run in the console |
run.trigger | Who 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.type | test, directory, or tag: what the run was started on |
run.stats | Test counts for the run |
run.tests | One entry per test. Both error fields are null for passing tests |
custom | Your 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.failedOpen 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:
| Attempt | Delay after previous attempt |
|---|---|
| 1 | immediate |
| 2 | 1 second |
| 3 | 5 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 "", 200request.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 nolocalhost. 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
3xxresponse 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.