Developer docs

Webhooks and notifications

Get deployment and custom-domain events in Slack, Discord or your own HTTPS endpoint, and verify every webhook's signature.

On this page

Zeloxa Host can tell other systems when something happens to your sites:

EventWhen it is sent
deployment.readyA deployment finished and is now live.
deployment.errorA deployment could not be stored or released.
domain.verifiedA custom domain passed verification and now serves its site.
domain.failedA 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.submissionA visitor sent one of your sites' forms and it was not filed as spam.
integration.testYou 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 POST to 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

HeaderValue
Content-Typeapplication/json
User-AgentZeloxa-Webhooks/1.0 (+https://zeloxalabs.com)
X-Zeloxa-EventThe event type, e.g. deployment.ready.
X-Zeloxa-DeliveryThe event id (a UUID). The same value as id in the body.
X-Zeloxa-TimestampWhen the request was sent, in Unix seconds.
X-Zeloxa-Signaturesha256= 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"
    }
  }
}
  • id is unique per event. Use it to ignore duplicates (see Replays and duplicates).
  • createdAt is when the event happened, as an ISO 8601 timestamp.
  • data.site is always present. data.deployment is present for deployment events, data.domain for domain.verified and domain.failed, and data.form and data.submission for form.submission.
  • deployment.source is one of upload (dashboard), cli, github or redeploy. gitBranch, gitCommit and gitMessage are null when 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.reason is one of timeout (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) or certificate (the HTTPS certificate's validation record was not found in time). New reasons may be added; treat an unknown one like timeout.
  • domain.message is 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.verified as 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.fields holds 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.email is a valid address taken from a field named like email (email, e-mail, _replyto, your-email, …), lower-cased, or null.
  • 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:

  1. 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.
  2. Compute HMAC-SHA256 over those bytes, using the whole secret string (including the whsec_ prefix) as the key, UTF-8 encoded.
  3. Hex-encode it, prefix it with sha256=, and compare it with the X-Zeloxa-Signature header in constant time.
  4. 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 "", 200

Edge 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 createdAt is 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 id of 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 its id — 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 2xx and do slow work afterwards (a queue, a background job).
  • Success is 2xx. Anything else counts as a failed delivery.
  • Redirects are not followed. A 3xx response 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 createdAt and the deployment number if 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). No user: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 from discordapp.com, ptb.discord.com or canary.discord.com are accepted and saved as discord.com).
  • Up to 2048 characters, and up to 20 integrations per organization.

Slack setup

  1. Go to api.slack.com/apps and choose Create New App → From scratch (or open an app you already have). Pick the workspace.
  2. Under Features → Incoming Webhooks, switch Activate Incoming Webhooks on.
  3. Choose Add New Webhook to Workspace, pick the channel, and allow it.
  4. Copy the webhook URL (https://hooks.slack.com/services/T…/B…/…).
  5. In Zeloxa, open Hosting → Integrations, choose Slack, paste the URL, pick the sites and events, and choose Connect Slack.
  6. 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

  1. In Discord, open Server Settings → Integrations → Webhooks (you need the Manage Webhooks permission).
  2. Choose New Webhook, give it a name, and pick the channel.
  3. Choose Copy Webhook URL (https://discord.com/api/webhooks/…/…).
  4. In Zeloxa, open Hosting → Integrations, choose Discord, paste the URL, pick the sites and events, and choose Connect Discord.
  5. 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.

Ready to try it?

Create an API token in the dashboard. You choose what it can do, and it is shown once.

Get an API token

Something unclear or missing? Tell us.