Webhooks
Receive Midstream events at your own endpoint — the event catalog, payload shape, signature verification, retries, and redelivery.
A webhook endpoint is an HTTPS URL of yours that Midstream posts to when something happens in your workspace. Use them to open a ticket when a deploy fails, post to a channel when a scene is captured, or keep your own record of what CI produced.
Endpoints live in workspace settings under Webhooks.
Creating an endpoint
You give Midstream three things: a URL, at least one event type, and an optional description (up to 280 characters) to remind you what it is for.
Rules on the URL:
- It must be
https://. Plain HTTP is rejected. - It must be reachable from the public internet. URLs pointing at
localhost, private ranges, link-local, or otherwise reserved addresses are refused. This is checked when you save and again immediately before every delivery, because a hostname's address can change after you save it.
An endpoint can be disabled without deleting it. A disabled endpoint keeps its configuration and its delivery history, and receives nothing. Anything already queued for it when you disable it is marked failed rather than sent.
When you create the endpoint, Midstream shows you a signing secret starting
with whsec_. Copy it then — like an API key, it is shown once. Afterwards the
page displays only a masked form of it.
The event catalog
Subscribe an endpoint to as many of these as you like. The value in the table is
what arrives in the type field and the X-Midstream-Event header.
| Event | Fires when |
|---|---|
capture.created | A new capture arrived from a CI test run |
capture.updated | An existing capture's snapshot or metadata changed |
instance.ready | An instance finished deploying and is serving traffic |
instance.deploy-failed | A deploy failed |
project.created | A project was created in the workspace |
An unknown event type is rejected when you save the endpoint, so a typo fails loudly instead of silently subscribing you to nothing.
The request
Every delivery is a POST with a JSON body.
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | Midstream-Webhooks/1.0 |
X-Midstream-Event | The event type, e.g. capture.created |
X-Midstream-Signature | t=<unix seconds>,v1=<hex hmac> — see below |
X-Midstream-Delivery | Id of this delivery |
X-Midstream-Webhook-Id | Id of the endpoint being delivered to |
The body is always the same envelope:
{
"id": "whd-Yx0Zt4mVQ1sK9pR3nB7dLc2f",
"event": "whevt-k3mq7v2xn8ab",
"type": "capture.created",
"createdAt": "2026-08-04T10:12:31.004Z",
"data": {
"captureId": "capture-9fj2mq7v1xna",
"slug": "cart-with-one-item",
"name": "Cart with one item",
"projectId": "project-2q7v9fj1mxna"
}
}id— this delivery.event— the thing that happened. Every endpoint subscribed to it gets a delivery carrying the sameeventvalue, and every retry of a delivery repeats it. This is the field to deduplicate on.type— the event type, same as theX-Midstream-Eventheader.createdAt— when the event happened, not when this attempt was sent.data— event-specific, described below.
What is in data
| Event | Fields |
|---|---|
capture.created | captureId, slug, name, projectId |
capture.updated | captureId, slug, name, projectId |
instance.ready | instanceId, captureId, slug, projectSlug, machineId |
instance.deploy-failed | instanceId, captureId, errorCode, errorCategory, machineId |
project.created | projectId, slug, name |
slug on the capture events is the scene's slug — the stable name you gave it
in your test. errorCode and errorCategory are the same values shown on the
failed deploy in the dashboard; the error catalog explains what each one means.
machineId identifies the container serving the instance; treat it as opaque.
Fields get added to data over time. Ignore ones you do not recognize rather
than failing on them.
Verifying the signature
Anyone can POST JSON at your URL. The signature is how you know a request came from us and has not been altered.
X-Midstream-Signature looks like this:
t=1785838351,v1=8f4c1d0a9b6e2f37c5a4d8e1b0f92c3a6d7e5b418c2f9a0d3e6b7c14f5a2d908tis the Unix timestamp, in seconds, of the moment we signed this attempt.v1is HMAC-SHA256, hex-encoded, over the string<t>.<raw body>, keyed with your endpoint's signing secret — the whole secret including thewhsec_prefix.
To verify: read t, concatenate it with a literal . and the raw request
body, compute the HMAC with your secret, and compare against v1 in constant
time. Also reject anything with a stale t, so a captured request cannot be
replayed at you later.
The one thing that will waste your afternoon: sign the bytes you received. Parsing the JSON and re-serializing it changes key order and whitespace, and the signature will never match.
Node
import crypto from "node:crypto";
import express from "express";
const SECRET = process.env.MIDSTREAM_WEBHOOK_SECRET;
const TOLERANCE_SECONDS = 300;
export function verifyMidstreamSignature(rawBody, header, secret) {
const parts = new Map(
header.split(",").map((part) => {
const i = part.indexOf("=");
return [part.slice(0, i).trim(), part.slice(i + 1).trim()];
}),
);
const timestamp = parts.get("t");
const signature = parts.get("v1");
if (!timestamp || !signature) return false;
const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
if (!Number.isFinite(age) || age > TOLERANCE_SECONDS) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
const a = Buffer.from(expected, "utf8");
const b = Buffer.from(signature, "utf8");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
const app = express();
// express.raw keeps the body as bytes. express.json() would not.
app.post(
"/webhooks/midstream",
express.raw({ type: "application/json" }),
(req, res) => {
const header = req.get("X-Midstream-Signature") ?? "";
if (!verifyMidstreamSignature(req.body.toString("utf8"), header, SECRET)) {
return res.status(400).send("bad signature");
}
const payload = JSON.parse(req.body.toString("utf8"));
console.log(payload.type, payload.data);
res.status(200).send("ok");
},
);
app.listen(3000);Python
import hashlib
import hmac
import os
import time
from flask import Flask, request
SECRET = os.environ["MIDSTREAM_WEBHOOK_SECRET"].encode("utf-8")
TOLERANCE_SECONDS = 300
app = Flask(__name__)
def verify_midstream_signature(raw_body: bytes, header: str) -> bool:
parts = {}
for part in header.split(","):
key, _, value = part.strip().partition("=")
parts[key] = value
timestamp = parts.get("t")
signature = parts.get("v1")
if not timestamp or not signature:
return False
try:
age = abs(time.time() - int(timestamp))
except ValueError:
return False
if age > TOLERANCE_SECONDS:
return False
expected = hmac.new(
SECRET,
timestamp.encode("utf-8") + b"." + raw_body,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, signature)
@app.post("/webhooks/midstream")
def midstream_webhook():
header = request.headers.get("X-Midstream-Signature", "")
if not verify_midstream_signature(request.get_data(), header):
return "bad signature", 400
payload = request.get_json()
print(payload["type"], payload["data"])
return "ok", 200Retries, and what a failure looks like
A delivery succeeds when your endpoint answers with a 2xx status within 10 seconds. Anything else — a 4xx, a 5xx, a timeout, a connection refused — is a failure.
Midstream makes up to 5 attempts in total, spaced further apart each time.
After the fifth the delivery is marked FAILED and left alone.
Some failures are terminal and stop the retries early, because no amount of retrying would fix them:
- The endpoint was disabled or deleted after the delivery was queued.
- The URL now resolves to a private or reserved address.
Each endpoint keeps a delivery log — open View deliveries on the endpoint.
Each row shows the event type, the status (PENDING, SUCCESS, FAILED), how
many attempts have been made, the HTTP status your endpoint returned, the error
if there was one, and when it was last tried. We keep a truncated copy of your
response body too, which is usually where the real reason is.
Because retries exist, your endpoint will sometimes see the same event twice.
Deduplicate on the event field and make your handler safe to run twice.
Answer fast. Ten seconds is the whole budget, so acknowledge with a 2xx and do the real work afterwards rather than inside the request.
Redelivering
Any past delivery can be sent again with Redeliver. It replays the original body byte for byte, so a receiver that was down, or one you have just fixed, can be brought up to date without you having to reconstruct anything.
A redelivery is a new delivery with its own attempts and its own row in the log.
It carries a new X-Midstream-Delivery header, and the payload — being an exact
replay — still names the original delivery in its id field. If you need to
tell attempts apart, use the header.
Rotating the signing secret
Rotate secret on an endpoint issues a new one and shows it to you once. The change takes effect immediately: the next delivery is signed with the new secret and nothing is signed with the old one again.
There is one secret per endpoint, so plan for a moment where your receiver and Midstream might disagree:
- Make your receiver accept either of two secrets, from two environment variables.
- Rotate in Midstream and put the new secret in the second variable.
- Confirm deliveries are arriving and verifying.
- Drop the old secret.
If a short gap is acceptable, rotating and updating your receiver within the same minute works too — the failed deliveries in between can be redelivered afterwards.
Not to be confused with notification webhooks
There are two things in Midstream with "webhook" in the name.
These — outbound event webhooks — are what this page describes. They are configured per workspace, subscribe to event types, and post signed payloads to your systems. They work today.
The webhook notification channel is a different thing: a way to have your personal notifications delivered somewhere other than email. It is listed in notification settings and is not built yet. See Notifications.
If you want events in your own systems, you want this page.