Webhooks
Service events (a failed backup, a server that stopped responding, a finished restore, the bucket inventory) sent over HTTPS to your system, signed, with retries.
Contents
Set up a webhook
- In the panel, open Settings → Notifications. Only owners and admins of the organization can add webhooks.
- Give it a name, pick the format (Signed JSON for your system; Slack, Teams or Telegram for a ready-made message) and paste the destination URL. It must be
https://, on a public internet address. - Pick the events. Confirm with your two-step verification code: a webhook sends server and plan names outside Arkame.
- For JSON, copy the signing secret (
whsec_…): it is shown only once. Then use Send test to check the destination.
The request
Each event is a POST with Content-Type: application/json. What varies by type is in data.details:
{
"id": "0b6f0c1e-5f1a-4f6e-9d1e-2a7c3e9b8d41",
"type": "backup.failed",
"api_version": "2026-10-01",
"created_at": "2026-10-13T12:00:00.000Z",
"organization": { "id": "…", "name": "Acme" },
"data": {
"severity": "warning",
"subject": { "type": "session", "id": "…" },
"details": {
"error_code": "storage_unreachable",
"agent_id": "…", "agent_name": "fs-01",
"plan_id": "…", "plan_name": "Nightly",
"storage_id": "…", "storage_name": "Main bucket",
"panel_url": "https://save.arkame.app/historico/backups/…"
}
}
}Arkame-Signature: the timestamp and the signature of the body (see below).Arkame-Event-Id: the same as the body'sid, and the same on every retry and resend. Use it to avoid processing an event twice.Arkame-Event-TypeandUser-Agent: Arkame-Webhooks/1.
Within api_version: 2026-10-01, changes are additive only: a new field may appear in details; no existing field changes name, type or meaning.
Event types
You choose which ones each webhook receives, and you can change that later. A new type does not reach an existing webhook on its own: tick it on the screen.
| Type | When it is sent |
|---|---|
backup.failed | The backup ended in failure. Not sent when the agent service was stopped or restarted mid-backup. |
backup.partial | The backup ended incomplete: some files were not saved. |
backup.completedunticked by default | The backup completed. One per backup, all day long. |
anomaly.detected | Far more files changed or removed than the plan's normal. severity: warning or critical. |
anomaly.resolved | Someone confirmed in the panel that the change was expected. |
purge.blocked | Cleanup of old versions in a storage was put on hold, because of a sudden change in a backup or a version that does not restore. |
verification.completed | The sample restore test restored and checked a server's files, with no failure. |
verification.failed | The restore test found a file that does not restore (critical). |
inventory.completedunticked by default | The bucket inventory checked everything, and no catalog version is missing. |
inventory.mismatch | The inventory found a difference between the catalog and the bucket: critical when a version is missing; warning for what does not prevent restoring. |
backup.late | A plan's scheduled run had not started two hours after the scheduled time. One per delay. |
agent.offline | The server has not checked in for more than 60 minutes. One per episode. |
agent.online | The server that triggered agent.offline is responding again. |
restore.completed | The restore finished with every file restored. For a Zimbra account, it is sent once the account has been imported into Zimbra, not when the files reach the server. |
restore.partial | The restore finished with some files not restored. |
restore.failed | The restore finished without restoring any file, or the import of the Zimbra account failed. |
report.ready | The organization's monthly report was generated. |
storage.config_changed | Someone changed a storage or its retention policy, or a scheduled change took effect. details.changed lists which fields, never the values; warning when it changes where the backup goes or shortens the protection (shorter retention, permanent deletion, disabling). |
agent.update_failed | A server's automatic update failed, and it stays on the previous version. One per attempt. |
storage.unreachable | A storage's last bucket test failed and no server in use reaches it: the bucket was deleted, the registration changed, or the key and network fail on every server. One per episode, critical. details.cause is bucket, chave (key), rede (network) or outra (other); the provider's raw error stays in the panel. |
storage.reachable | The bucket that raised storage.unreachable answers a server again. |
There is also ping, sent only by the Send test button.
Verify the signature
Arkame-Signature: t=SECONDS,v1=HEX, where v1 is the HMAC-SHA256 of t + "." + body, keyed with the whole secret (whsec_…). Compute it over the raw body, exactly as received, and reject requests older than 5 minutes: otherwise a captured request would be valid forever.
Node.js
import { createHmac, timingSafeEqual } from 'node:crypto';
// rawBody: the text as received, before any JSON.parse.
export function isValidSignature(secret, header, rawBody) {
const p = Object.fromEntries(header.split(',').map((x) => x.trim().split('=')));
if (!/^\d+$/.test(p.t ?? '') || !p.v1) return false;
if (Math.abs(Date.now() / 1000 - Number(p.t)) > 300) return false;
const expected = createHmac('sha256', secret).update(`${p.t}.${rawBody}`).digest();
const received = Buffer.from(p.v1, 'hex');
return received.length === expected.length && timingSafeEqual(received, expected);
}Python
import hashlib, hmac, time
# raw_body: the bytes as received, before any json.loads.
def is_valid_signature(secret: str, header: str, raw_body: bytes) -> bool:
try:
p = dict(x.strip().split("=", 1) for x in header.split(","))
t = int(p["t"])
except (KeyError, ValueError):
return False
if "v1" not in p or abs(time.time() - t) > 300:
return False
expected = hmac.new(secret.encode(), p["t"].encode() + b"." + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, p["v1"])PHP
// $rawBody: file_get_contents('php://input'), before json_decode.
function is_valid_signature(string $secret, string $header, string $rawBody): bool {
parse_str(str_replace(',', '&', $header), $p);
if (empty($p['t']) || empty($p['v1']) || abs(time() - (int) $p['t']) > 300) return false;
return hash_equals(hash_hmac('sha256', $p['t'] . '.' . $rawBody, $secret), $p['v1']);
}Keep the secret like a password. If it leaks, remove the webhook and add a new one: the new secret is shown when you add it.
Delivery and retries
- Success is any
2xxresponse within 5 seconds: respond first and process later. A redirect (3xx) counts as a failure. - On failure, we retry after 1 min, 5 min, 30 min, 2 h, 6 h and 24 h. Events may arrive out of order: use
created_atand theArkame-Event-Id. - 20 failures in a row, or 3 days failing, disable the webhook, and owners and admins get an email. You turn it back on from the same screen.
- The screen shows the latest deliveries and what the destination answered. A finished delivery can be resent: it goes out again with the same event id.
- Only
https, on ports 443 or 8443, on a public address. Internal network addresses are refused, including a public name that points to one of them. An n8n inside your network needs a public address in front of it.
Slack, Teams and Telegram
The same event, already as a message, in the language chosen when you set it up:
- Slack: the URL of an Incoming Webhook for the channel.
- Teams: the URL of a “When a Teams webhook request is received” flow (Workflows).
- Telegram: your bot's
https://api.telegram.org/bot123456:ABC…/sendMessageURL and the chat (numeric id or@channel). The URL is stored encrypted.
n8n, Make and Zapier: use the JSON format with each one's webhook trigger, and verify the signature in a code step.
Ready to set one up?
Webhooks are in Settings → Notifications, next to the email addresses that receive alerts.
Open notifications in the panel →