Zeloxa Host can tell other systems when something happens to your sites:
| Event | When it is sent |
|---|---|
deployment.ready | A deployment finished and is now live. |
deployment.error | A deployment could not be stored or released. |
domain.verified | A custom domain passed verification and now serves its site. |
domain.failed | A custom domain needs your attention: still unverified 72 hours after it was added, or refused (blocked, its DNS record moved away, or its certificate could not be validated). Sent at most once per domain every 30 days. |
form.submission | A visitor sent one of your sites' forms and it was not filed as spam. |
integration.test | You pressed Send test on one integration. Never sent on its own. |
You choose where events go under Hosting → Integrations in the dashboard:
- Slack — a message in a channel, through a Slack incoming webhook.
- Discord — a message in a channel, through a Discord webhook.
- Webhook — a signed JSON
POSTto any public HTTPS endpoint you run.
Each integration subscribes to one or more events (deployment ready and deployment failed by default) and either every site in the organization or one site. You can pause an integration without removing it.
Email alerts are separate and per person: under Organization → Notifications each member chooses whether they get an email when a production deployment fails (at most one per project every 30 minutes), when it recovers, and when a custom domain goes live or fails. Owners and admins get these by default; members opt in. Preview deployments never email.
Integrations are managed in the dashboard only. API tokens and AI agents cannot create, change or read them, so a leaked token cannot redirect your notifications.
Automation apps such as Zapier and Make connect differently: with an API token that has the Automations access level, they subscribe to one event at a time through the API's event hooks and receive a flat payload made for mapping into other apps. Those subscriptions are listed under Hosting → Integrations → Connected apps, where you can remove them. See Connect Zeloxa to Zapier or Make.
Webhook requests
Every delivery is an HTTP POST with a JSON body.
Headers
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | Zeloxa-Webhooks/1.0 (+https://zeloxalabs.com) |
X-Zeloxa-Event | The event type, e.g. deployment.ready. |
X-Zeloxa-Delivery | The event id (a UUID). The same value as id in the body. |
X-Zeloxa-Timestamp | When the request was sent, in Unix seconds. |
X-Zeloxa-Signature | sha256= followed by the hex HMAC-SHA256 of the raw body. Webhooks only. |
Slack and Discord deliveries carry the same X-Zeloxa-* headers, minus the
signature: those services authenticate with the secret URL itself.
Body
{
"id": "5a0b1c2d-3e4f-4a5b-8c6d-7e8f9a0b1c2d",
"type": "deployment.ready",
"createdAt": "2026-10-02T10:00:00.000Z",
"data": {
"site": {
"id": "1f2e3d4c-5b6a-4978-8a9b-0c1d2e3f4a5b",
"name": "Marketing site",
"slug": "marketing",
"url": "https://marketing.zeloxa.app"
},
"deployment": {
"id": "9a8b7c6d-5e4f-4a3b-9c2d-1e0f9a8b7c6d",
"number": 12,
"status": "ready",
"source": "cli",
"errorMessage": null,
"gitBranch": "main",
"gitCommit": "3f9c1e2a7b4d5c6e8f9a0b1c2d3e4f5a6b7c8d9e",
"gitMessage": "Update pricing page"
}
}
}idis unique per event. Use it to ignore duplicates (see Replays and duplicates).createdAtis when the event happened, as an ISO 8601 timestamp.data.siteis always present.data.deploymentis present for deployment events,data.domainfordomain.verifiedanddomain.failed, anddata.formanddata.submissionforform.submission.deployment.sourceis one ofupload(dashboard),cli,githuborredeploy.gitBranch,gitCommitandgitMessagearenullwhen the deployment did not come from Git (or the commit details are not known).- New fields may be added to the body at any time. Ignore fields you do not recognise rather than rejecting the request.
deployment.error
{
"id": "0d9c8b7a-6f5e-4d3c-9b2a-1f0e9d8c7b6a",
"type": "deployment.error",
"createdAt": "2026-10-02T10:05:00.000Z",
"data": {
"site": { "id": "1f2e…", "name": "Marketing site", "slug": "marketing", "url": "https://marketing.zeloxa.app" },
"deployment": {
"id": "7b6a5f4e-…",
"number": 13,
"status": "error",
"source": "upload",
"errorMessage": "Could not store the uploaded files.",
"gitBranch": null,
"gitCommit": null,
"gitMessage": null
}
}
}domain.verified
{
"id": "c1d2e3f4-a5b6-4c7d-8e9f-0a1b2c3d4e5f",
"type": "domain.verified",
"createdAt": "2026-10-02T11:00:00.000Z",
"data": {
"site": { "id": "1f2e…", "name": "Marketing site", "slug": "marketing", "url": "https://marketing.zeloxa.app" },
"domain": { "id": "4e5f6a7b-…", "hostname": "www.example.com" }
}
}domain.failed
{
"id": "e2f3a4b5-c6d7-4e8f-9a0b-1c2d3e4f5a6b",
"type": "domain.failed",
"createdAt": "2026-10-05T11:00:00.000Z",
"data": {
"site": { "id": "1f2e…", "name": "Marketing site", "slug": "marketing", "url": "https://marketing.zeloxa.app" },
"domain": {
"id": "4e5f6a7b-…",
"hostname": "www.example.com",
"reason": "timeout",
"message": "Add the DNS records shown on the project's Domains page at your DNS provider, exactly as listed, then press Re-check. If you no longer want this domain, remove it."
}
}
}domain.reasonis one oftimeout(still unverified 72 hours after it was added),blocked(refused; contact support),moved(the DNS record that pointed the domain at your site was changed or removed) orcertificate(the HTTPS certificate's validation record was not found in time). New reasons may be added; treat an unknown one liketimeout.domain.messageis a human-readable next step, safe to show to people.- The domain keeps its place in your project. Fix the DNS records and press
Re-check (or call the re-check endpoint); a later success sends
domain.verifiedas usual.
form.submission
{
"id": "0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d",
"type": "form.submission",
"createdAt": "2026-10-03T10:04:11.902Z",
"data": {
"site": { "id": "1f2e…", "name": "Marketing site", "slug": "marketing", "url": "https://marketing.zeloxa.app" },
"form": { "id": "2b3c4d5e-…", "name": "Contact" },
"submission": {
"id": "7e6d5c4b-…",
"createdAt": "2026-10-03T10:04:11.902Z",
"fields": {
"name": "Ada Lovelace",
"email": "ada@example.com",
"message": "Hello! I would like a quote."
},
"email": "ada@example.com"
}
}
}- Sent only for submissions that land in the inbox. Spam (the spam-trap field, link-stuffed messages, a visitor over their daily limit) never sends it, and neither do submissions refused for rate or monthly limits.
submission.fieldsholds every field the visitor sent, name → text, in the order sent. Control fields (the spam trap, the anti-bot token) are not included. A name sent more than once (a group of checkboxes) appears once, with its values joined by,.submission.emailis a valid address taken from a field named likeemail(email,e-mail,_replyto,your-email, …), lower-cased, ornull.- The visitor's IP address and browser are never sent. The submission is also in the form's inbox in the dashboard as usual.
- Form fields are whatever a visitor typed. Treat them as untrusted input in your receiver: escape them before showing them, and never run them.
integration.test
Same shape, with data.site set to the integration's site (or your newest
site when the integration covers all sites). Treat it as a connectivity check:
verify the signature, answer 200, do nothing else.
Verifying signatures
When you connect a webhook, the dashboard shows its signing secret once.
It looks like whsec_ followed by 43 characters. Store it with your
endpoint's configuration (an environment variable or secret manager).
To check a request came from Zeloxa:
- Read the raw request body — the exact bytes received, before any JSON parsing. Parsing and re-serialising changes whitespace and key order, and the signature will not match.
- Compute HMAC-SHA256 over those bytes, using the whole secret string
(including the
whsec_prefix) as the key, UTF-8 encoded. - Hex-encode it, prefix it with
sha256=, and compare it with theX-Zeloxa-Signatureheader in constant time. - Reject the request (any
4xx) if they differ.
Node.js
import express from "express";
import { createHmac, timingSafeEqual } from "node:crypto";
const secret = process.env.ZELOXA_WEBHOOK_SECRET; // whsec_…
function isFromZeloxa(rawBody, signatureHeader) {
const expected = "sha256=" + createHmac("sha256", secret).update(rawBody).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(signatureHeader ?? "");
// timingSafeEqual throws on different lengths, so check that first.
return a.length === b.length && timingSafeEqual(a, b);
}
const app = express();
// express.raw keeps the body as a Buffer — do not use express.json() here.
app.post("/hooks/zeloxa", express.raw({ type: "application/json" }), (req, res) => {
if (!isFromZeloxa(req.body, req.get("x-zeloxa-signature"))) {
return res.status(401).end();
}
const event = JSON.parse(req.body.toString("utf8"));
// …dedupe on event.id, check event.createdAt, then queue the work.
res.status(200).end();
});Python
from __future__ import annotations # lets `str | None` run on Python 3.8 and 3.9
import hashlib
import hmac
import json
import os
from flask import Flask, abort, request
SECRET = os.environ["ZELOXA_WEBHOOK_SECRET"] # whsec_…
app = Flask(__name__)
def is_from_zeloxa(raw_body: bytes, signature_header: str | None) -> bool:
expected = "sha256=" + hmac.new(SECRET.encode(), raw_body, hashlib.sha256).hexdigest()
# Compare bytes: compare_digest raises TypeError on a str with non-ASCII
# characters, which would turn a forged header into a 500.
return hmac.compare_digest(expected.encode(), (signature_header or "").encode())
@app.post("/hooks/zeloxa")
def zeloxa_webhook():
raw = request.get_data() # the exact bytes received
if not is_from_zeloxa(raw, request.headers.get("X-Zeloxa-Signature")):
abort(401)
event = json.loads(raw)
# …dedupe on event["id"], check event["createdAt"], then queue the work.
return "", 200Edge and WebCrypto runtimes (Workers, Deno, Bun)
crypto.subtle.verify compares in constant time for you.
export default {
async fetch(request, env) {
const raw = await request.arrayBuffer();
if (!(await isFromZeloxa(raw, request.headers.get("x-zeloxa-signature"), env.ZELOXA_WEBHOOK_SECRET))) {
return new Response("invalid signature", { status: 401 });
}
const event = JSON.parse(new TextDecoder().decode(raw));
// …dedupe on event.id, check event.createdAt, then do the work.
return new Response(null, { status: 200 });
},
};
async function isFromZeloxa(rawBody, signatureHeader, secret) {
const match = /^sha256=([0-9a-f]{64})$/i.exec(signatureHeader ?? "");
if (!match) return false;
const key = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["verify"]
);
const signature = new Uint8Array(match[1].match(/../g).map((h) => parseInt(h, 16)));
return crypto.subtle.verify("HMAC", key, signature, rawBody);
}Rotating the secret
Rotate secret on the integration issues a new secret and shows it once. The old secret stops working immediately — there is no overlap period. To rotate without failed deliveries: pause the integration, rotate, update your endpoint with the new secret, then resume it. Rotate straight away if you think the secret has leaked.
Replays and duplicates
The signature covers the body only. That makes the body's id and
createdAt authenticated; the X-Zeloxa-Delivery and X-Zeloxa-Timestamp
headers repeat them for convenience (for logging or routing before you parse),
but they are not signed, so base decisions on the body values.
- Replays. Someone who captures a signed request could send it to you
again later. Reject events whose
createdAtis more than 5 minutes old (allow for some clock skew). Deliveries are sent within moments of the event, so a genuine one is never that old today. - Duplicates. Store the
idof every event you have processed (for at least as long as your age window) and ignore any id you have already seen. Zeloxa does not retry today, but when retries arrive a retried event will keep itsid— dedupe now and you will not need to change anything later.
Delivery behaviour
Be precise about what to rely on:
- One attempt. Each event is sent once to each matching integration. If your endpoint is down, that event is not delivered later. There are no retries yet.
- 5 second timeout. Your endpoint must respond within 5 seconds. Answer
quickly with any
2xxand do slow work afterwards (a queue, a background job). - Success is
2xx. Anything else counts as a failed delivery. - Redirects are not followed. A
3xxresponse is a failed delivery. Use the final URL of your endpoint, including the right scheme and trailing slash. - No ordering guarantee. Integrations are notified concurrently, and two
events close together can arrive in either order. Use
createdAtand the deploymentnumberif order matters. - Your response body is ignored.
- The dashboard shows each integration's last delivery: the HTTP status your endpoint returned, or "no response" when it timed out, the connection failed, or the URL could not be used.
Which URLs are accepted
https://only, on the standard port (443). Nouser:password@in the URL.- Generic webhooks must point at a public endpoint. Private, loopback,
link-local and other reserved IP addresses (IPv4 and IPv6) are refused, as
are names that only resolve inside a private network (
localhost,*.local,*.internal,*.lan,*.home.arpa,*.corp, single-word hosts) and Zeloxa's own domains. - Slack URLs must be
https://hooks.slack.com/services/…. - Discord URLs must be
https://discord.com/api/webhooks/…(links copied fromdiscordapp.com,ptb.discord.comorcanary.discord.comare accepted and saved asdiscord.com). - Up to 2048 characters, and up to 20 integrations per organization.
Slack setup
- Go to api.slack.com/apps and choose Create New App → From scratch (or open an app you already have). Pick the workspace.
- Under Features → Incoming Webhooks, switch Activate Incoming Webhooks on.
- Choose Add New Webhook to Workspace, pick the channel, and allow it.
- Copy the webhook URL (
https://hooks.slack.com/services/T…/B…/…). - In Zeloxa, open Hosting → Integrations, choose Slack, paste the URL, pick the sites and events, and choose Connect Slack.
- Press Send test. A "Test notification from Zeloxa" message should appear in the channel.
Treat the URL like a password: anyone who has it can post to that channel. After you save it, the dashboard only ever shows a masked version. If it leaks, remove the webhook in Slack and connect a new one.
Messages show what happened, the site (linked), the deployment number, its
source, branch, commit and the first line of the commit message when known, and
for failures the error message. A form submission shows the form, the sender's
email when there is one, and a compact list of the fields — the first 10, each
cut to 200 characters on one line, with a count of any left out; the full
submission is in the dashboard. Text that comes
from your project — site names, branches, commit messages, error messages — is
escaped and sent with Slack's automatic formatting turned off, so it can never
mention @channel, @here or a person, or hide a link behind different text. Link previews are turned off, so a URL inside an error message is never
fetched by Slack.
Discord setup
- In Discord, open Server Settings → Integrations → Webhooks (you need the Manage Webhooks permission).
- Choose New Webhook, give it a name, and pick the channel.
- Choose Copy Webhook URL (
https://discord.com/api/webhooks/…/…). - In Zeloxa, open Hosting → Integrations, choose Discord, paste the URL, pick the sites and events, and choose Connect Discord.
- Press Send test and check the channel.
Messages are posted as "Zeloxa" with an embed per event. Mentions are disabled on every message, and Markdown in text that comes from your project — including everything a visitor types into a form — is neutralised. Form submissions are shown as a compact list of fields, like in Slack.
Testing locally
Deliveries only go to public HTTPS endpoints, so a server on localhost cannot
receive them directly. Expose it through a tunnel that gives you a public HTTPS
URL, connect that URL as a webhook, and use Send test. Tests are limited
to one every few seconds per integration.
