Skip to content

← Documentation

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.

Set up a webhook

  1. In the panel, open Settings → Notifications. Only owners and admins of the organization can add webhooks.
  2. 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.
  3. Pick the events. Confirm with your two-step verification code: a webhook sends server and plan names outside Arkame.
  4. 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's id, and the same on every retry and resend. Use it to avoid processing an event twice.
  • Arkame-Event-Type and User-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.

TypeWhen it is sent
backup.failedThe backup ended in failure. Not sent when the agent service was stopped or restarted mid-backup.
backup.partialThe backup ended incomplete: some files were not saved.
backup.completedunticked by defaultThe backup completed. One per backup, all day long.
anomaly.detectedFar more files changed or removed than the plan's normal. severity: warning or critical.
anomaly.resolvedSomeone confirmed in the panel that the change was expected.
purge.blockedCleanup 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.completedThe sample restore test restored and checked a server's files, with no failure.
verification.failedThe restore test found a file that does not restore (critical).
inventory.completedunticked by defaultThe bucket inventory checked everything, and no catalog version is missing.
inventory.mismatchThe inventory found a difference between the catalog and the bucket: critical when a version is missing; warning for what does not prevent restoring.
backup.lateA plan's scheduled run had not started two hours after the scheduled time. One per delay.
agent.offlineThe server has not checked in for more than 60 minutes. One per episode.
agent.onlineThe server that triggered agent.offline is responding again.
restore.completedThe 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.partialThe restore finished with some files not restored.
restore.failedThe restore finished without restoring any file, or the import of the Zimbra account failed.
report.readyThe organization's monthly report was generated.
storage.config_changedSomeone 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_failedA server's automatic update failed, and it stays on the previous version. One per attempt.
storage.unreachableA 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.reachableThe 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 2xx response 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_at and the Arkame-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…/sendMessage URL 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 →