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.
- Enter the endpoint URL. It must start with
https://and be reachable from the internet. - Pick a format: signed JSON for your own code, or Slack for a Slack incoming webhook URL.
- Choose the events. Tick none to get every event, including ones we add later.
- 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.
- 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-idto ignore repeats. - Order isn't guaranteed. A retried event can land after a newer one. Use
created_atwhen 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 as10.0.0.0/8and192.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
3xxresponse 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).