Automation
Notifications
NGCP can tell you when something happens: a server crashed, a backup failed, a node went offline. Notifications go to webhooks of three kinds:
| Kind | Sends |
|---|---|
| Generic | A signed JSON POST to your URL, for your own tools and automation. |
| Discord | A message to a Discord channel through a Discord webhook URL. |
| An e-mail to up to 20 addresses through your SMTP server. |
Webhooks are managed by administrators (permission webhooks:write) on the Notifications
page (left menu), which has four tabs: Webhooks, Delivery log, E-mail server and
Message texts (the last two need the settings permissions).
Events
| Event | When |
|---|---|
service.crashed |
The agent reports a service as crashed. |
service.install_finished |
An install, reinstall or update finished. |
service.install_failed |
An install, reinstall or update failed. |
backup.completed / backup.failed |
A manual or scheduled backup finished or failed. |
restore.completed / restore.failed |
A restore finished or failed. |
schedule.failed |
A scheduled task failed (skipped runs do not count). |
schedule.notify |
A scheduled notify task ran; carries its message. |
node.offline |
A connected node’s agent has been gone for one minute. (A node that is already down when the controller starts is not reported.) |
node.online |
A node that was reported offline is back. |
A webhook subscribes to a list of events; an empty list means every event.
Adding a webhook
# Generic, signed
curl -s -X POST https://panel.example.com/api/v1/webhooks \
-H "Authorization: Bearer ngcpk_..." -H "Content-Type: application/json" \
-d '{"name": "Ops automation", "kind": "generic", "url": "https://hooks.example.com/ngcp",
"secret": "a-long-random-string", "events": ["service.crashed", "backup.failed"], "enabled": true}'
# Discord
curl -s -X POST https://panel.example.com/api/v1/webhooks \
-H "Authorization: Bearer ngcpk_..." -H "Content-Type: application/json" \
-d '{"name": "Discord #server-status", "kind": "discord",
"url": "https://discord.com/api/webhooks/123/abc", "events": [], "enabled": true}'
# E-mail (needs SMTP, see below)
curl -s -X POST https://panel.example.com/api/v1/webhooks \
-H "Authorization: Bearer ngcpk_..." -H "Content-Type: application/json" \
-d '{"name": "On-call mail", "kind": "email", "url": "[email protected], [email protected]",
"events": ["node.offline", "backup.failed"], "enabled": true}'
- For Discord, create the webhook in the channel’s settings (Integrations -> Webhooks) and paste its URL. The panel shows it masked afterwards, because the URL itself is the credential.
- The kind of a webhook cannot be changed later.
- Signing secrets are sealed with the master key and never shown again.
- Test (
POST /api/v1/webhooks/{id}/test) queues a test message; check the delivery log for the result.
Delivery and retries
Notifications are queued in the database and sent by a background loop, so a slow or dead receiver never slows the panel down.
- Each attempt times out after 15 seconds. Redirects are not followed.
- A
2xxanswer is success. - Network errors,
429and5xxanswers are retried after 1, 2, 4, 8 … minutes (at most two hours apart), up to 8 attempts in all. - Other answers (
400,401,404…) fail at once without retries: fix the receiver or the URL. - The delivery log (
GET /api/v1/notification-deliveries) shows the latest 200 deliveries with status (pending, sent, failed), attempts and the last error. It shows only the receiver’s host, never the full URL. Entries are kept for 30 days.
E-mail: the SMTP server
E-mail webhooks use the panel’s outgoing mail server, set by a SuperAdmin (settings:write):
| Setting | Notes |
|---|---|
| Host | Empty turns e-mail off. |
| Port | Defaults to 587 (STARTTLS), 465 (TLS) or 25 (none). |
| Security | starttls (default), tls or none. |
| User name, password | Optional. A password is never sent without TLS: user name with none is refused. |
| From | A valid address, for example NGCP <[email protected]>. |
curl -s -X PUT https://panel.example.com/api/v1/settings/smtp \
-H "Authorization: Bearer ngcpk_..." -H "Content-Type: application/json" \
-d '{"host": "smtp.example.com", "port": 587, "security": "starttls",
"username": "[email protected]", "password": "...", "from": "NGCP <[email protected]>"}'
Leave password out to keep the stored one. Mails have the subject [<panel name>] <first line of the message> and a plain-text body with the message, the event name and the time.
Message templates
Each event’s text comes from a template. SuperAdmins can replace the defaults (PUT /api/v1/settings/notification-templates); an empty template goes back to the default. Templates
use Go’s text/template syntax with these fields:
| Field | Value |
|---|---|
{{.Service}} |
Service name (service events) |
{{.ServiceID}} |
Service id |
{{.Node}} |
Node name (node events) |
{{.NodeID}} |
Node id |
{{.Message}} |
The event’s detail: crash reason, backup size, error text, your notify message |
{{.Panel}} |
The panel’s product name (Branding) |
{{.Type}} |
The event name |
{{.At}} |
The time |
Defaults:
| Event | Default template |
|---|---|
service.crashed |
{{.Service}} crashed{{if .Message}}: {{.Message}}{{end}} |
service.install_finished |
{{.Service}}: {{.Message}} finished |
service.install_failed |
{{.Service}}: {{.Message}} |
backup.completed |
{{.Service}}: backup completed{{if .Message}} ({{.Message}}){{end}} |
backup.failed |
{{.Service}}: backup FAILED: {{.Message}} |
restore.completed |
{{.Service}}: restore completed{{if .Message}} ({{.Message}}){{end}} |
restore.failed |
{{.Service}}: restore FAILED: {{.Message}} |
schedule.failed |
{{.Service}}: scheduled task failed: {{.Message}} |
schedule.notify |
{{.Service}}: {{.Message}} |
node.offline |
Node {{.Node}} is offline |
node.online |
Node {{.Node}} is back online |
A template may be up to 2000 characters. Discord messages are cut at 1900 characters, and
mentions such as @everyone are switched off. The panel name used in messages is read when the
controller starts; restart it after changing the product name.
Generic webhooks: the payload
A generic webhook receives a POST with Content-Type: application/json:
{
"event": "backup.completed",
"at": "2026-11-06T06:00:42Z",
"service_id": 12,
"service": "ArmA 3 - Friday ops",
"node_id": 3,
"message": "1.2 GiB",
"panel": "NGCP",
"text": "ArmA 3 - Friday ops: backup completed (1.2 GiB)"
}
text is the rendered template. Node events carry node (the name) instead of service. Some
events add a data object (scheduled-task events: task and action). Fields without a value
are left out.
Headers:
| Header | Value |
|---|---|
X-NGCP-Event |
The event name, for example backup.completed (test for test messages) |
X-NGCP-Timestamp |
Unix time in seconds when this attempt was sent |
X-NGCP-Signature |
Only when the webhook has a secret: sha256= followed by the hex HMAC-SHA256 of <timestamp>.<raw body> with the secret as key |
User-Agent |
NGCP-webhook |
Verifying the signature
Check every request before you trust it:
- Read the raw request body as bytes. Do not parse and re-serialise the JSON first.
- Build the string
X-NGCP-Timestamp+.+ body. - Compute HMAC-SHA256 over it with your secret, hex-encode it and put
sha256=in front. - Compare it with
X-NGCP-Signaturein constant time. - Reject old timestamps (for example older than five minutes) so a captured request cannot be replayed later. Each retry carries a new timestamp and a new signature.
Python (standard library):
import hashlib
import hmac
import time
def verify_ngcp(secret: bytes, timestamp: str, body: bytes, signature: str, max_age: int = 300) -> bool:
if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > max_age:
return False
mac = hmac.new(secret, timestamp.encode() + b"." + body, hashlib.sha256)
return hmac.compare_digest("sha256=" + mac.hexdigest(), signature)
# Flask example:
# from flask import Flask, request, abort
# app = Flask(__name__)
#
# @app.post("/ngcp")
# def ngcp():
# ok = verify_ngcp(b"a-long-random-string",
# request.headers.get("X-NGCP-Timestamp", ""),
# request.get_data(),
# request.headers.get("X-NGCP-Signature", ""))
# if not ok:
# abort(401)
# event = request.get_json()
# print(event["event"], event.get("text"))
# return "", 204
Node.js (no dependencies):
const crypto = require('node:crypto');
const http = require('node:http');
const SECRET = 'a-long-random-string';
function verifyNgcp(secret, timestamp, body, signature, maxAge = 300) {
if (!/^\d+$/.test(timestamp || '')) return false;
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > maxAge) return false;
const expected =
'sha256=' +
crypto.createHmac('sha256', secret).update(`${timestamp}.`).update(body).digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(signature || '');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
http
.createServer((req, res) => {
const chunks = [];
req.on('data', (c) => chunks.push(c));
req.on('end', () => {
const body = Buffer.concat(chunks); // raw bytes, not re-serialised JSON
const ok = verifyNgcp(
SECRET,
req.headers['x-ngcp-timestamp'],
body,
req.headers['x-ngcp-signature'],
);
if (!ok) {
res.writeHead(401).end();
return;
}
const event = JSON.parse(body.toString('utf8'));
console.log(event.event, event.text);
res.writeHead(204).end();
});
})
.listen(8000);
Answer quickly with a 2xx and do slow work afterwards; the panel waits at most 15 seconds.
Deliveries can repeat after a timeout, so make your handler tolerate the same event twice.