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
- Open your project → Webhooks tab → Add endpoint.
- Pick a label (your reference, e.g.
#perf-alerts) + paste the destination URL (HTTPS only). - 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-signatureHMAC header. For your own integrations.
- Slack — sends Slack incoming-webhook payloads, skips HMAC signature. Auto-selected if the URL contains
- Tick the events you want delivered. For Slack, use
test_session.completed— fires once per playtest with a rollup summary. Avoidsession.createdon Slack endpoints (it fires per capture and floods the channel). - 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)
- In Slack: Apps → search "Incoming Webhooks" → Add to Slack → pick the channel you want messages in (e.g.
#perf-alerts). - Slack shows a Webhook URL like
https://hooks.slack.com/services/T.../B.../X.... Copy it. - In Argus: project → Webhooks → Add endpoint. Label = "#perf-alerts Slack". Paste the URL — the format selector auto-flips to Slack.
- Tick
test_session.completed(recommended for Slack — one summary per playtest) + any other events you care about. Click Create. - 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).
| Event | Fires when | Slack message |
|---|---|---|
test_session.completedrecommended 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.created | A 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.detected | An 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.revoked | API key generated or revoked from the dashboard. | :key: API key created: `<name>` in *<project>* |
project.created | A new project is created in the org. | :sparkles: Project created: *<name>* in *<org>* |
billing.subscription.updated | Plan changed (upgrade or downgrade). | :arrow_up: Subscription updated for *<org>*: now on *<plan>* |
billing.subscription.canceled | Plan cancelled (effective at period end). | :wave: Subscription canceled for *<org>* — ends <periodEnd> |
billing.payment_failed | Stripe 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
| Header | Value |
|---|---|
content-type | application/json |
user-agent | Argus-Webhooks/1 |
argus-event | The event type (e.g. session.created). |
argus-delivery | The envelope's id — use as an idempotency key to dedup re-deliveries. |
argus-signature | Stripe-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.