Argus

Webhooks & integrations

Push every Argus event somewhere outside the dashboard. Two formats: Slack for one-line messages in a channel, or native for custom integrations with HMAC-signed JSON payloads. Same UI for both.

Setting up a webhook

  1. Open your project → Webhooks tab → Add endpoint.
  2. Pick a label (your reference, e.g. #perf-alerts) + paste the destination URL (HTTPS only).
  3. Choose the format:
    • Slack — sends Slack incoming-webhook payloads, skips HMAC signature. Auto-selected if the URL contains hooks.slack.com.
    • Native — sends Argus's own JSON envelope with an argus-signature HMAC header. For your own integrations.
  4. Tick the events you want delivered. For Slack, use test_session.completed — fires once per playtest with a rollup summary. Avoid session.created on Slack endpoints (it fires per capture and floods the channel).
  5. Click Create. For native endpoints, the signing secret is shown once on the next screen — copy it now, it's unrecoverable.

Slack setup (step-by-step)

  1. In Slack: Apps → search "Incoming Webhooks" → Add to Slack → pick the channel you want messages in (e.g. #perf-alerts).
  2. Slack shows a Webhook URL like https://hooks.slack.com/services/T.../B.../X.... Copy it.
  3. In Argus: project → Webhooks → Add endpoint. Label = "#perf-alerts Slack". Paste the URL — the format selector auto-flips to Slack.
  4. Tick test_session.completed (recommended for Slack — one summary per playtest) + any other events you care about. Click Create.
  5. Run a playtest in Unity (any captures will do). About 5 minutes after the LAST capture for that test-session label arrives, the Slack channel shows a summary message like:
    :white_check_mark: Test session `MyGame_2026-05-04_15-23-45` completed for *MyGame* v0.4.2 — 12 captures, 58.4 avg FPS, 17.12ms avg frame, peak 287MB mono. <https://argus.../test-sessions/...|Open in dashboard>
      • `Order.Complete` · Tutorial Order — 59.8 FPS
      • `Order.Complete` · Boss Order — 54.1 FPS
      • `Chapter.Complete` · Chapter 1 — 60.0 FPS
      …and 9 more

Done. From now on, every capture posts to Slack automatically. To stop: Webhooks tab → row → Pause (keeps the URL + config; reversible) or Delete (gone for good).

Disabling per-event

Want sessions but not API-key-rotated noise? Edit the endpoint and untick events you don't want. Subscribers default to only what you tick — no surprise events on existing endpoints when new event types ship.

Event types

For Slack endpoints, use test_session.completed — it fires once per playtest with a rollup summary. The other session- related event, session.created, fires per individual capture and is best left to native consumers that want per-event granularity (it would flood a Slack channel during a normal playtest).

EventFires whenSlack message
test_session.completed
recommended for Slack
~5 minutes after the last capture for a test-session label arrives (server-side debounce). Fires once per playtest.:white_check_mark: Test session `<label>` completed for *<project>* v<appVersion> — N captures, X avg FPS, ... + a list of the captures inside.
session.createdA capture is uploaded + persisted. Per-capture — fires N times per playtest (noisy for Slack).Profiling Session `<label>` · <uniqueId> is finished for *<project>* v<appVersion> on device `<deviceModel>`. <url|View session>
anomaly.detectedAn anomaly rule matches a captured metric. Currently triggered manually only — the rule engine is on the roadmap.:rotating_light: Anomaly in *<project>*: <description>.
api_key.created / api_key.revokedAPI key generated or revoked from the dashboard.:key: API key created: `<name>` in *<project>*
project.createdA new project is created in the org.:sparkles: Project created: *<name>* in *<org>*
billing.subscription.updatedPlan changed (upgrade or downgrade).:arrow_up: Subscription updated for *<org>*: now on *<plan>*
billing.subscription.canceledPlan cancelled (effective at period end).:wave: Subscription canceled for *<org>* — ends <periodEnd>
billing.payment_failedStripe reports a failed invoice.:credit_card: Payment failed for *<org>* — <amount>.

Native (HMAC-signed) format

For when you're building your own integration. Each delivery is a POST to your URL with this shape:

Request body

{
  "id": "0e8c4f3b1a2d4e5f6789abcdef012345",
  "type": "session.created",
  "created": 1714742400,
  "data": {
    "sessionId": "uuid-...",
    "sessionName": "MyGame_2026-05-03_15-23-45",
    "eventName": "Order.Complete",
    "eventUniqueId": "Tutorial Order",
    "appVersion": "0.4.2",
    "deviceModel": "iPhone 14 Pro",
    "averageFps": 58.3,
    "avgFrameTime": 17.1,
    "projectSlug": "mygame",
    "projectName": "MyGame",
    "orgSlug": "mystudio",
    "orgName": "MyStudio",
    "url": "https://argus.../sessions/uuid-..."
  }
}

Headers

HeaderValue
content-typeapplication/json
user-agentArgus-Webhooks/1
argus-eventThe event type (e.g. session.created).
argus-deliveryThe envelope's id — use as an idempotency key to dedup re-deliveries.
argus-signatureStripe-style signature: t=<unix_ts>,v1=<hex> where v1 is HMAC-SHA256 over <ts>.<raw_body> using your endpoint's signing secret.

Verifying the signature (Node.js)

import crypto from 'node:crypto';

function verifyArgusSignature(rawBody, header, secret, toleranceSec = 300) {
  // header looks like: "t=1714742400,v1=abcdef0123456789..."
  const parts = Object.fromEntries(header.split(',').map(p => p.split('=')));
  const ts = Number(parts.t);
  const sig = parts.v1;
  if (!ts || !sig) throw new Error('malformed signature header');

  // Reject stale deliveries to defeat replay attacks
  if (Math.abs(Date.now() / 1000 - ts) > toleranceSec) {
    throw new Error('signature timestamp outside tolerance window');
  }

  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${ts}.${rawBody}`, 'utf8')
    .digest('hex');

  // Constant-time compare to defeat timing attacks
  if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
    throw new Error('invalid signature');
  }
}

// Express handler example:
app.post('/argus-webhook', express.raw({ type: 'application/json' }), (req, res) => {
  try {
    verifyArgusSignature(
      req.body.toString('utf8'),
      req.header('argus-signature') ?? '',
      process.env.ARGUS_WEBHOOK_SECRET
    );
  } catch (err) {
    return res.status(400).json({ error: err.message });
  }
  const event = JSON.parse(req.body.toString('utf8'));
  // ... do something with event.type / event.data
  res.json({ ok: true });
});

Verifying the signature (Python)

import hashlib, hmac, time

def verify_argus_signature(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> None:
    parts = dict(p.split("=", 1) for p in header.split(","))
    ts = int(parts["t"])
    sig = parts["v1"]

    if abs(time.time() - ts) > tolerance:
        raise ValueError("signature timestamp outside tolerance window")

    expected = hmac.new(
        secret.encode(),
        f"{ts}.".encode() + raw_body,
        hashlib.sha256,
    ).hexdigest()

    if not hmac.compare_digest(sig, expected):
        raise ValueError("invalid signature")

Retry behaviour

Argus retries once on non-2xx after a 750ms backoff. After the second failure, the delivery is recorded as failed in webhook_deliveries with the response status (or error tag for network/timeout failures) — visible in the dashboard. Anything beyond is manual: re-deliver from the UI, or fix your consumer and the next event lands cleanly.

DNS failures aren't retried (a flapping DNS doesn't fix in 750ms). Timeouts use the default 10s deadline.

Slack format details

Slack endpoints get a different payload shape — Slack's incoming-webhook contract — and no signature header (Slack doesn't validate one):

{
  "text": "Profiling Session `MyGame_...` · Tutorial Order is finished for *MyGame* v0.4.2 on device `iPhone 14 Pro`. <https://...|View session>",
  "blocks": [
    {
      "type": "section",
      "text": {
        "type": "mrkdwn",
        "text": "Profiling Session..."
      }
    }
  ]
}

Both the text field (used by older Slack clients + notifications) and the blocks array (rich rendering in the channel) carry the same content — Slack picks whichever applies. Per-event templates are baked in; if you want different wording, fork the shapeForSlack function in backend/lib/webhooks.ts or use the native format and do your own translation upstream of Slack.

Operational notes

  • Editing an endpoint. The row's Editbutton lets you change the label + which events the endpoint subscribes to. URL and format are intentionally read-only — changing them mid-flight would orphan the signing secret / alter the wire shape consumers depend on. To change either, delete + recreate.
  • Pause vs delete. Pausing keeps the URL + secret intact; resume by toggling Enable. Deleting removes the row + secret; you'd need to re-create + re-paste into Slack/your own consumer.
  • Rotating the signing secret. Not a UI feature yet — delete + re-create. Both the new and old secret are unrecoverable so this is a hard rotation. Track in your secrets manager / 1Password.
  • Last delivery summary. Each row in the Webhooks list shows the last delivery's status + timestamp. Click for the per-delivery breakdown (status, duration, response body capped at 1KB).
  • Discord, MS Teams, PagerDuty, etc.. Not first- class formats yet. Discord's incoming-webhook shape is near-identical to Slack's — would be a small adapter addition (open an issue). Teams & PagerDuty accept generic webhooks; build a tiny relay around the native format.