Webhooks

Webhooks tell your own systems the moment something happens on velixir: a deploy goes live, a build fails, an agent asks for approval, your balance runs low. velixir sends an HTTPS request to a URL you choose, signed so you can be sure it came from us, or posts a one-line message straight into a Slack channel.

Use them to post deploys to chat, open an incident when a deploy fails, kick off end-to-end tests once a release is live, or page someone when an agent is waiting on a decision.

Add an endpoint

In the dashboard, open Team settings and find Webhooks. Only team Owners and Admins can see and manage endpoints and what was sent to them: an endpoint URL (a Slack one, say) works like a password, so other members only see how many there are.

  1. Enter the endpoint URL. It must start with https:// and be reachable from the internet.
  2. Pick a format: signed JSON for your own code, or Slack for a Slack incoming webhook URL.
  3. Choose the events. Tick none to get every event, including ones we add later.
  4. Optionally limit it to one app. Team-wide events, such as billing, aren't sent to an endpoint limited to an app. If that app is deleted, the endpoint is kept but switched off; pick another app (or every app) and switch it back on.
  5. Copy the signing secret (whsec_...). It's shown once. Lost it? Rotate it for a new one.

Press Send test event to check your endpoint answers. A team can have up to 10 endpoints.

From the terminal:

velixir webhooks add https://example.com/hooks/velixir --events deploy.succeeded,deploy.failed
velixir webhooks add https://hooks.slack.com/services/T000/B000/XXXX --slack
velixir webhooks list
velixir webhooks test <id>
velixir webhooks remove <id>

The API is under /api/v1/webhooks (see the API reference) and needs an account-wide API key of a team Owner or Admin. A read-only key sees each endpoint's host but not its full URL or the bodies sent. With an agent key, adding an endpoint, pointing one at a new URL, switching one off, changing its events or app, removing one or rotating its secret waits for a person to approve it; for a new endpoint, the approver sees its signing secret.

Events

Event When
build.succeeded A release finished building
build.failed A release failed to build
deploy.succeeded A deploy went live
deploy.failed A deploy failed
deploy.rolled_back A deploy was rolled back
release.failed A release command (migrations) failed, so the deploy stopped
cron.failed A scheduled job exited with an error
app.crashed A running app or worker crashed (out of memory, failed health check, or exited)
app.crash_looping An app or worker keeps crashing on start and is being restarted with back-off
app.recovered An app or worker that was crash looping is running again
app.slept An idle Free app went to sleep
app.woke A sleeping app woke up for a request
app.deleted An app was deleted
database.deleted A database was deleted
cache.deleted A cache was deleted
rewind.ready A rewind preview is ready to check
rewind.failed A rewind preview, or applying a rewind, failed
rewind.applied A rewind was applied to the live app
rewind.undone An applied rewind was undone
approval.requested An agent key asked to do something that needs a person's OK
approval.approved Someone approved an agent's request
approval.denied Someone denied an agent's request
billing.budget_warning Spend reached 80% of the monthly limit
billing.budget_reached Spend reached the monthly limit
billing.budget_paused Apps were paused at a hard spend limit
billing.budget_resumed Apps paused by the spend limit came back
billing.low_balance The prepaid balance is running low
billing.account_suspended The account was suspended for an unpaid balance
billing.account_reinstated The account was reinstated
webhook.test You pressed Send test event (or ran velixir webhooks test)

Each event is sent once per endpoint that wants it, usually within a few seconds of it happening.

What we send

A POST with a JSON body and these headers:

Header Value
webhook-id The event's id, such as evt_3f2a.... The same on every retry and redelivery, so you can ignore duplicates.
webhook-timestamp When this attempt was sent, in Unix seconds
webhook-signature v1, followed by the signature (see below)
user-agent velixir-webhooks/1
content-type application/json

The body:

{
  "id": "evt_3f2a6c0e9b1d4f7a8c5e2b9d0a1f3c4e",
  "type": "deploy.succeeded",
  "created_at": "2026-10-31T09:41:07.512Z",
  "team": { "id": "7d1c...", "name": "Acme" },
  "data": {
    "description": "Deploy to production is live",
    "app": {
      "id": "0b6e...",
      "name": "api",
      "slug": "api",
      "url": "https://api.velixir.run",
      "dashboard_url": "https://velixir.net/Apps/Details/0b6e..."
    },
    "project": { "id": "41aa...", "name": "Shop" },
    "deployment": {
      "id": "9e02...",
      "slot": "production",
      "status": "healthy",
      "release_version": 42,
      "message": null
    },
    "release": {
      "id": "c7d3...",
      "version": 42,
      "commit_sha": "1f9c2e7",
      "commit_message": "Fix checkout rounding"
    },
    "slot": "production"
  }
}

data carries whatever fits the event: app, project, deployment and release for builds and deploys, the numbers behind a billing event (such as percent, spent_eur and limit_eur), and approval_url and summary for approvals. New fields may appear at any time, so ignore the ones you don't know. type and the field names above won't change.

Any 2xx response counts as delivered. Answer quickly (within 10 seconds) and do slow work afterwards.

Verify the signature

Signatures follow the open Standard Webhooks spec, so its libraries work as they are. To check by hand, compute an HMAC-SHA256 of {webhook-id}.{webhook-timestamp}.{raw body}, keyed with the base64-decoded part of your secret after whsec_, and compare its base64 form with the value after v1,. Use the raw body exactly as received, before any JSON parsing, and reject timestamps more than five minutes old so a captured request can't be replayed.

Node.js (Express):

import crypto from "node:crypto";
import express from "express";

const secret = process.env.VELIXIR_WEBHOOK_SECRET; // whsec_...

function verified(req) {
  const id = req.get("webhook-id");
  const timestamp = req.get("webhook-timestamp");
  const header = req.get("webhook-signature") ?? "";
  if (!id || Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;

  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
  const expected = crypto.createHmac("sha256", key)
    .update(`${id}.${timestamp}.${req.body.toString("utf8")}`)
    .digest();
  return header.split(" ").some((part) => {
    const [version, signature] = part.split(",");
    const given = Buffer.from(signature ?? "", "base64");
    return version === "v1" && given.length === expected.length && crypto.timingSafeEqual(given, expected);
  });
}

const app = express();
app.post("/hooks/velixir", express.raw({ type: "application/json" }), (req, res) => {
  if (!verified(req)) return res.sendStatus(401);
  const event = JSON.parse(req.body);
  if (event.type === "deploy.failed") {
    // open an incident, post to chat...
  }
  res.sendStatus(204);
});

Python (Flask):

import base64, hashlib, hmac, os, time
from flask import Flask, request, abort

SECRET = os.environ["VELIXIR_WEBHOOK_SECRET"]  # whsec_...
app = Flask(__name__)

def verified(req) -> bool:
    msg_id = req.headers.get("webhook-id", "")
    timestamp = req.headers.get("webhook-timestamp", "0")
    header = req.headers.get("webhook-signature", "")
    if not msg_id or abs(time.time() - int(timestamp)) > 300:
        return False
    key = base64.b64decode(SECRET.removeprefix("whsec_"))
    signed = f"{msg_id}.{timestamp}.".encode() + req.get_data()
    expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
    return any(
        part.startswith("v1,") and hmac.compare_digest(part[3:], expected)
        for part in header.split(" ")
    )

@app.post("/hooks/velixir")
def receive():
    if not verified(request):
        abort(401)
    event = request.get_json()
    print(event["type"], event["data"].get("description"))
    return "", 204

Rotating the secret (on the endpoint's page, or POST /api/v1/webhooks/{id}/rotate-secret) replaces it at once: the old secret stops working straight away, so have your receiver ready for the new one.

Retries

If your endpoint doesn't answer with a 2xx within 10 seconds, velixir tries again after 30 seconds, 2 minutes, 10 minutes, 30 minutes, then 2, 6 and 12 hours: eight attempts over about 21 hours. After that the delivery is marked as given up.

  • Duplicates are possible. A delivery can arrive more than once (a timeout after you'd already processed it, say). Use webhook-id to ignore repeats.
  • Order isn't guaranteed. A retried event can land after a newer one. Use created_at when order matters.
  • Redeliver any delivery from the endpoint's page in the dashboard: it sends the same body again, newly signed, with the same webhook-id. A delivery that's still being retried can't be redelivered until it has succeeded or been given up on.
  • Every delivery is kept for 30 days on the endpoint's page, with the body we sent, your response code, the first 1 KB of your response (left out when it's an HTML page) and how long it took.

If 20 deliveries in a row are given up on, velixir switches the endpoint off and emails the team's Owners and Admins. Fix the receiver, switch it back on, and send a test event. Events that happen while an endpoint is off aren't sent later.

Slack

Create an incoming webhook in Slack for the channel you want, add its https://hooks.slack.com/... URL as an endpoint, and pick the Slack format. Each event arrives as one line with a link to the dashboard, for example:

velixir: deploy of api to production succeeded (v42) · Open in velixir

Pick only the events the channel cares about: deploy.succeeded, deploy.failed and build.failed make a good deploys channel.

Network and security

  • Public HTTPS only. Endpoint URLs must use https:// on a public address. velixir refuses URLs, and addresses a name resolves to, on private networks (such as 10.0.0.0/8 and 192.168.0.0/16), loopback, link-local (including cloud metadata addresses), carrier-grade NAT and other reserved ranges. The check runs again on every request, so a name that later points somewhere private stops receiving events.
  • No redirects. A 3xx response counts as a failure; use the final URL.
  • Don't filter by IP. Requests come from velixir's EU data centres, but the addresses can change. Check the signature instead: it proves the request came from velixir and wasn't altered.
  • Keep the secret secret. Store it like a password (an environment variable marked secret works well on velixir itself).