Guides
Alerts and webhooks
Get told by email, or by a signed webhook to your own URL, when an agent goes offline, your allowance runs low, or a key changes.
There are two ways to be told that something happened without watching the dashboard. Email alerts go to the address on your account when an agent has been offline for a while, when you are using up your monthly calls, and when an API key is about to expire. Webhooks post a signed JSON message to a URL you own, so you can page yourself, post to chat, or feed your own audit log. This page covers both, and shows how to receive and verify a webhook with code in Node.js, Go and Python.
Email alerts
Alerts go to the email address on your account. There are three kinds, each switchable on its own under Settings, Email notifications. All three are on by default.
| Alert (Settings label) | Sent when | How often |
|---|---|---|
| Agent offline | A project that had an agent connected has had none for 10 minutes | Once per outage |
| Monthly allowance | The month's calls reach 80% of your plan's allowance, and again at 100% | Once each, per month |
| Key expiry | An API key that has an expiry date is 14 days from expiring, and again at 3 days | Once each, per key |
Agent offline. The subject is Your agent for "<project>" is offline. The message names the project, says how many minutes it has been without an agent, and links to the project page. The 10 minutes is a grace period so that an ordinary restart or deploy does not email you. You get one email per outage: when the agent comes back and goes away again, that is a new outage. An agent you have disabled on the Agents page does not count as offline, because it is still connected.
Monthly allowance. The subject is You have used 80% of your monthly calls at 80%, and You have used your <plan> plan's monthly calls at 100%. The message gives the used and limit counts. The check runs every 10 minutes, so an alert can trail the real crossing by that long, and if you jump straight past 80% to 100% you get the 100% email only. The wording says calls will be refused at the limit. That is true on the Free plan only; paid plans keep working and are counted. See Plans and limits.
Key expiry. The subject is API key "<label>" expires in <n> days. It names the key and the project and links to the project page, so you can create a replacement and update whatever uses the old key before it stops. A key created with fewer than 3 days left gets only the 3-day email. Keys with no expiry never trigger it.
Alerts are sent once and are not retried: if your mail provider rejects one, it is lost. Sign-up, password-reset and email-change messages are a different thing and cannot be switched off. If emails never arrive, see Troubleshooting.
Webhooks
Manage webhooks on the Webhooks page (under Build in the sidebar). To add one:
- Enter an endpoint URL.
- Tick the events you want. Tick none to receive all of them.
- Press Add webhook.
The signing secret is shown once, in a banner. Copy it now. A lost secret cannot be shown again: delete the webhook and make a new one.
You can have 5 webhooks per account. Adding a sixth fails with At most 5 webhooks per account.. Webhooks belong to the account, not to a project: an event from any project goes to every webhook that wants it, and the payload says which project.
Events
| Event | Sent when | data |
|---|---|---|
agent.connected | A project's first agent comes online (none to one) | { "project": { "id", "name" } } |
agent.disconnected | A project's last agent goes away (one to none) | { "project": { "id", "name" } } |
key.created | An API key is created | { "project": { "id", "name" }, "key": { "id", "label", "kind" } } |
key.revoked | An API key is revoked | { "project": { "id", "name" }, "keyId" } |
allowance.warning | The month reaches 80% of your calls | { "used", "limit", "plan", "percent": 80 } |
allowance.exceeded | The month reaches 100% of your calls | { "used", "limit", "plan", "percent": 100 } |
ping | You press Send test | { "message": "This is a test event." } |
You cannot subscribe to ping. It is only the test. Rotating a project's keys does not send key.created or key.revoked. Creating and revoking single keys does.
The webhook has no grace period, unlike the email. agent.disconnected fires the moment the last agent goes, so an agent that restarts sends a disconnected followed by a connected. If you page someone from it, add a short delay on your side.
What arrives
Every delivery is an HTTP POST with a JSON body:
{
"id": "0b6c3f0e-5f0a-4c0e-9d0c-3d0c9a1e8c11",
"type": "agent.disconnected",
"at": "2026-10-19T10:00:00.000Z",
"data": { "project": { "id": "b3f6d9d7-3b0a-4d13-930e-fe2aa66ee586", "name": "orders-api" } }
}
and these headers:
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | spacefix-webhooks/1 |
X-Webhook-Event | The event type, for example agent.disconnected |
X-Webhook-Delivery | The same value as the body's id. Use it to skip repeats |
X-Webhook-Signature | t=<unix seconds>,v1=<hex> |
The signature
v1 is the lowercase hex HMAC-SHA256 of the string <t>.<raw request body>, keyed with your webhook's secret (the whole whsec_... string, prefix included). t is the time of that attempt in Unix seconds. To verify a delivery:
- Parse
tandv1from the header. - Reject the request if
tis too far from your clock. We do not state a tolerance. Five minutes is a sensible choice and is what the code below uses. - Compute the HMAC over
t, a dot, and the exact bytes of the body, as received. Do not parse and re-serialise the JSON first, because that changes the bytes. - Compare it with
v1in constant time.
Receive and verify a webhook
Each example verifies the signature and then reads the event. Set WEBHOOK_SECRET to the whsec_... value, and answer with a 2xx quickly: do slow work after you respond. These were each run against signatures made with the algorithm above, and each accepted a valid signature and rejected a tampered body, a stale timestamp, a malformed header, a missing header and a re-serialised body.
import express from 'express';
import { createHmac, timingSafeEqual } from 'node:crypto';
const SECRET = process.env.WEBHOOK_SECRET!; // the whsec_... value shown once when you created the webhook
const TOLERANCE_SECONDS = 300;
/** True when `header` (X-Webhook-Signature) is a valid signature of `rawBody` made with SECRET. */
export function verify(rawBody: string, header: string | undefined): boolean {
const fields = Object.fromEntries(
(header ?? '').split(',').map(part => part.trim().split('=', 2))
);
const t = Number(fields.t);
if (!Number.isInteger(t) || !fields.v1) return false;
if (Math.abs(Date.now() / 1000 - t) > TOLERANCE_SECONDS) return false; // stale or replayed
const expected = createHmac('sha256', SECRET).update(`${t}.${rawBody}`).digest();
const given = Buffer.from(fields.v1, 'hex');
return given.length === expected.length && timingSafeEqual(given, expected);
}
const app = express();
// The signature covers the exact bytes we sent, so read the body as text, not as parsed JSON.
app.post('/hooks/mcp', express.text({ type: 'application/json' }), (req, res) => {
if (!verify(req.body, req.get('x-webhook-signature'))) return res.sendStatus(401);
const event = JSON.parse(req.body); // { id, type, at, data }
// A delivery can repeat (a retry reuses the id), so skip an id you have already handled.
console.log(event.type, event.id, event.data);
res.sendStatus(204); // any 2xx means delivered
});
app.listen(3300);
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"math"
"net/http"
"os"
"strconv"
"strings"
"time"
)
var secret = os.Getenv("WEBHOOK_SECRET") // the whsec_... value shown once when you created the webhook
const toleranceSeconds = 300
// verify reports whether header (X-Webhook-Signature) is a valid signature of body made with secret.
func verify(body []byte, header string) bool {
var ts, sig string
for _, part := range strings.Split(header, ",") {
k, v, ok := strings.Cut(strings.TrimSpace(part), "=")
if !ok {
continue
}
switch k {
case "t":
ts = v
case "v1":
sig = v
}
}
t, err := strconv.ParseInt(ts, 10, 64)
if err != nil || sig == "" {
return false
}
if math.Abs(float64(time.Now().Unix()-t)) > toleranceSeconds {
return false // stale or replayed
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(ts + "." + string(body)))
given, err := hex.DecodeString(sig)
if err != nil {
return false
}
return hmac.Equal(given, mac.Sum(nil)) // constant time
}
type event struct {
ID string `json:"id"`
Type string `json:"type"`
At string `json:"at"`
Data json.RawMessage `json:"data"`
}
func handler(w http.ResponseWriter, r *http.Request) {
// The signature covers the exact bytes we sent, so verify before parsing.
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20))
if err != nil || !verify(body, r.Header.Get("X-Webhook-Signature")) {
http.Error(w, "bad signature", http.StatusUnauthorized)
return
}
var ev event
if err := json.Unmarshal(body, &ev); err != nil {
http.Error(w, "bad body", http.StatusBadRequest)
return
}
// A delivery can repeat (a retry reuses the id), so skip an id you have already handled.
fmt.Println(ev.Type, ev.ID, string(ev.Data))
w.WriteHeader(http.StatusNoContent) // any 2xx means delivered
}
func main() {
http.HandleFunc("/hooks/mcp", handler)
http.ListenAndServe(":3300", nil)
}
import hashlib
import hmac
import json
import os
import time
from flask import Flask, abort, request
SECRET = os.environ["WEBHOOK_SECRET"] # the whsec_... value shown once when you created the webhook
TOLERANCE_SECONDS = 300
def verify(raw_body: bytes, header: str | None) -> bool:
"""True when `header` (X-Webhook-Signature) is a valid signature of `raw_body` made with SECRET."""
fields = dict(part.strip().split("=", 1) for part in (header or "").split(",") if "=" in part)
try:
t = int(fields["t"])
given = fields["v1"]
except (KeyError, ValueError):
return False
if abs(time.time() - t) > TOLERANCE_SECONDS:
return False # stale or replayed
expected = hmac.new(SECRET.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, given)
app = Flask(__name__)
@app.post("/hooks/mcp")
def hook():
# The signature covers the exact bytes we sent, so use get_data(), not get_json().
raw = request.get_data()
if not verify(raw, request.headers.get("X-Webhook-Signature")):
abort(401)
event = json.loads(raw) # {"id", "type", "at", "data"}
# A delivery can repeat (a retry reuses the id), so skip an id you have already handled.
print(event["type"], event["id"], event["data"])
return "", 204 # any 2xx means delivered
if __name__ == "__main__":
app.run(port=3300)
To test an endpoint before you depend on it, put a public HTTPS URL in front of it (a tunnel or a staging host: private and localhost addresses are refused) and press Send test. Check it with a bad signature too: your endpoint should answer 401, and the page will show the delivery as failed.
Delivery, retries and switch-off
- A response with a 2xx status means delivered. We wait up to 5 seconds for an answer.
- A failed delivery is retried twice more, after 10 seconds and then after 2 minutes. We retry after a timeout, a connection error, a
5xx, a429, or a redirect (redirects are never followed, so they count as failures). Any other4xxis not retried, because it would answer the same way again. - Retries repeat the same
idand body with a fresh timestamp and signature. Make your handler idempotent by skipping anidyou have seen. - Pending retries are held in memory. If the gateway restarts, retries that were waiting are lost.
- Order is not guaranteed.
- After 20 deliveries in a row fail (each delivery counts once, after its retries), the webhook is switched off. The page shows
switched off after 20 failed deliveriesand the button becomes Re-enable, which resets the counter. A single success resets the counter too. While a webhook is off, nothing is sent to it, and events that happen in that time are not replayed. - The page shows each webhook's last delivery:
ok, orfailed (<status>), orfailed: <error>, with when. There is no per-delivery history.
Send test
Send test posts a ping event now, with a single attempt and no retries, and tells you the outcome: success with the HTTP status, or the error (for example getaddrinfo ENOTFOUND example.invalid) and how long it took. It is recorded as the webhook's last delivery and counts towards the 20-failure switch-off, so fix a failing endpoint rather than pressing it repeatedly.
What URLs are accepted
We make the request from inside our own network, so we refuse URLs that could be used to reach private systems. A URL is checked when you save it, and again, on the address it resolves to, every time we connect.
| Rule | Message |
|---|---|
Must be https:// | The URL must start with https://. |
| Must parse, at most 500 characters | Not a valid URL., The URL is too long. |
| No username or password in the URL | Put credentials in the signature check, not the URL. |
No localhost, *.localhost or *.internal host | That host is not reachable from the internet. |
No private, loopback, link-local or reserved address: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 127.0.0.0/8, 169.254.0.0/16, 100.64.0.0/10, 0.0.0.0/8, multicast and reserved ranges, and the IPv6 equivalents | That address is not reachable from the internet. |
| A name that resolves to a private address is refused when we connect | Shown as the delivery error Resolved to a private address. |
| Redirects are not followed | The 3xx is a failed delivery |
Ports are not restricted.
Things worth knowing
- Email and webhooks are independent. Turning off the Monthly allowance email does not stop the allowance webhooks, and the reverse.
- Allowance events are sent once per level per account per month, from the same 10-minute check as the email.
- Not built yet: a per-delivery log, per-project webhook endpoints, and webhook events for plan changes.
Next steps
- Troubleshooting: when a delivery fails or your signature check never passes.
- Plans and limits: what the allowance is and what happens at 80% and 100%.
- Security: the Activity log records webhook creation and deletion.