MidstreamDocs
Administering a workspace

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.

EventFires when
capture.createdA new capture arrived from a CI test run
capture.updatedAn existing capture's snapshot or metadata changed
instance.readyAn instance finished deploying and is serving traffic
instance.deploy-failedA deploy failed
project.createdA 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.

HeaderValue
Content-Typeapplication/json
User-AgentMidstream-Webhooks/1.0
X-Midstream-EventThe event type, e.g. capture.created
X-Midstream-Signaturet=<unix seconds>,v1=<hex hmac> — see below
X-Midstream-DeliveryId of this delivery
X-Midstream-Webhook-IdId 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 same event value, and every retry of a delivery repeats it. This is the field to deduplicate on.
  • type — the event type, same as the X-Midstream-Event header.
  • createdAt — when the event happened, not when this attempt was sent.
  • data — event-specific, described below.

What is in data

EventFields
capture.createdcaptureId, slug, name, projectId
capture.updatedcaptureId, slug, name, projectId
instance.readyinstanceId, captureId, slug, projectSlug, machineId
instance.deploy-failedinstanceId, captureId, errorCode, errorCategory, machineId
project.createdprojectId, 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=8f4c1d0a9b6e2f37c5a4d8e1b0f92c3a6d7e5b418c2f9a0d3e6b7c14f5a2d908
  • t is the Unix timestamp, in seconds, of the moment we signed this attempt.
  • v1 is HMAC-SHA256, hex-encoded, over the string <t>.<raw body>, keyed with your endpoint's signing secret — the whole secret including the whsec_ 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", 200

Retries, 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:

  1. Make your receiver accept either of two secrets, from two environment variables.
  2. Rotate in Midstream and put the new secret in the second variable.
  3. Confirm deliveries are arriving and verifying.
  4. 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.