Skip to main content

Overview

Monitors turn entity interests into standing alerts. A tracker watches one entity; a monitor groups trackers and owns delivery settings (email, Slack, webhook).
  • Base URL: https://api.arcmira.com, all paths under /v1.
  • Auth: Authorization: Bearer arc_sk_... or x-api-key: arc_sk_....
  • Scopes: read for all GETs; monitors:write for non-GET /v1/monitors*; trackers:write for non-GET /v1/trackers*.
  • Send Idempotency-Key (a UUID) on every POST/PATCH; replays return the cached response with Idempotency-Replayed: true.
  • Tier gates: Slack delivery from Free; webhook delivery and realtime email on paid tiers.
  • Billing: monitor and tracker CRUD is free. Each alert occurrence (a tracker matching a new appearance) debits 100 rows from the shared monthly pool, once per occurrence no matter how many channels it fans out to.
  • Alerts fire for recently published media (roughly a 14-day publish window) as it is analyzed. This is monitoring, not retroactive search.

Minimal working example

const BASE = 'https://api.arcmira.com';
const H = {
  Authorization: `Bearer ${process.env.ARCMIRA_API_KEY}`,
  'Content-Type': 'application/json',
};

const { monitor } = await fetch(`${BASE}/v1/monitors`, {
  method: 'POST',
  headers: { ...H, 'Idempotency-Key': crypto.randomUUID() },
  body: JSON.stringify({ name: 'Competitor watch', icon: 'radar', color: 'indigo' }),
}).then(r => r.json());

const { tracker } = await fetch(`${BASE}/v1/trackers`, {
  method: 'POST',
  headers: { ...H, 'Idempotency-Key': crypto.randomUUID() },
  body: JSON.stringify({ entityName: 'Ramp', entityType: 'organization' }),
}).then(r => r.json());

await fetch(`${BASE}/v1/monitors/${monitor.id}/trackers`, {
  method: 'POST',
  headers: { ...H, 'Idempotency-Key': crypto.randomUUID() },
  body: JSON.stringify({ trackerIds: [tracker.id] }),
});

const { data: alerts } = await fetch(
  `${BASE}/v1/monitors/${monitor.id}/alerts?n=25`, { headers: H },
).then(r => r.json());
import os, uuid, httpx

BASE = 'https://api.arcmira.com'
H = {'Authorization': f"Bearer {os.environ['ARCMIRA_API_KEY']}"}

monitor = httpx.post(
    f'{BASE}/v1/monitors',
    json={'name': 'Competitor watch', 'icon': 'radar', 'color': 'indigo'},
    headers={**H, 'Idempotency-Key': str(uuid.uuid4())},
).json()['monitor']

tracker = httpx.post(
    f'{BASE}/v1/trackers',
    json={'entityName': 'Ramp', 'entityType': 'organization'},
    headers={**H, 'Idempotency-Key': str(uuid.uuid4())},
).json()['tracker']

httpx.post(
    f"{BASE}/v1/monitors/{monitor['id']}/trackers",
    json={'trackerIds': [tracker['id']]},
    headers={**H, 'Idempotency-Key': str(uuid.uuid4())},
)

alerts = httpx.get(f"{BASE}/v1/monitors/{monitor['id']}/alerts", params={'n': 25}, headers=H).json()['data']
curl -X POST 'https://api.arcmira.com/v1/monitors' \
  -H "Authorization: Bearer $ARCMIRA_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{ "name": "Competitor watch", "icon": "radar", "color": "indigo" }'

Endpoints

Monitors

MethodPathScopePurpose
GET/v1/monitorsreadList monitors with tracker counts
POST/v1/monitorsmonitors:writeCreate
PATCH/v1/monitors/{id}monitors:writePartial update (send only changed fields)
DELETE/v1/monitors/{id}monitors:writeDelete (cannot be undone)
GET/v1/monitors/{id}/trackersreadList trackers in the monitor
POST/v1/monitors/{id}/trackersmonitors:writeAttach existing trackers by ID: { "trackerIds": ["trk_..."] }
GET/v1/monitors/{id}/alerts?n=readRecent alert deliveries
POST/v1/monitors/{id}/webhook-secret/rotatemonitors:writeRotate the signing secret (returned once; 24h dual-signed overlap)

Trackers

MethodPathScopePurpose
GET/v1/trackersreadList trackers
POST/v1/trackerstrackers:writeCreate
PATCH/v1/trackers/{id}trackers:writeUpdate
DELETE/v1/trackers/{id}trackers:writeDelete
GET/v1/trackers/{id}/alerts?n=readRecent alert deliveries

Request parameters

Monitor create/update body (all fields optional except name on create):
FieldTypeDefaultDescription
namestringDisplay name. Required on create.
iconstringeyeKebab-case Lucide icon name.
colorstringblueOne of the dashboard palette names (gray, red, …, rose).
notifyEmailsstring[][]Email recipients.
notifyFrequencyenumrealtimerealtime (as analysis completes), hourly, daily (batched digests). On Free, email delivery is coerced to daily regardless of what you set.
digestDaystringmondayDigest day. Consulted only by weekly digests, which are dashboard-configured today; inert for API-set frequencies.
digestTimestring09:00Send hour for daily digests (account timezone).
notifyWebhookbooleanfalseEnable webhook delivery. Paid tiers only.
webhookUrlstringHTTPS endpoint to receive deliveries.
notifySlackbooleanfalseEnable Slack delivery.
slackIntegrationId / slackChannelIdstringFrom the dashboard Slack OAuth flow.
isPausedbooleanfalseDashboard pause state. Delivery-time pause is per tracker (PATCH /v1/trackers/{id} { "paused": true }).
isCollapsed / sortOrderboolean / intDashboard display state.
Tracker create/update body (POST /v1/trackers; creating the same entity twice returns 409 with the existingId):
FieldTypeDescription
entityNamestringRequired on create. The name to resolve and watch.
entityTypeenumRequired on create. person, organization, product, topic, channel.
displayNamestringOptional label shown in alerts and the dashboard.
notifyEmail / notifyWebhook / notifySlackbooleanPer-tracker delivery overrides.
webhookUrl / slackChannelId / slackIntegrationIdstringPer-tracker destination overrides.
filtersobjectOptional matching filters.

Response schema

Monitor object (create/read):
{
  "monitor": {
    "id": "mon_abc123",
    "name": "Competitor watch",
    "icon": "radar",
    "color": "indigo",
    "isCollapsed": false,
    "isPaused": false,
    "sortOrder": 3,
    "notifyEmails": ["ops@example.com"],
    "notifyFrequency": "realtime",
    "digestDay": "monday",
    "digestTime": "09:00",
    "notifyWebhook": true,
    "webhookUrl": "https://example.com/hooks/arcmira",
    "webhookSecretSet": true,
    "webhookSecretHint": "f3a1",
    "webhookFailures": 0,
    "webhookDisabledAt": null,
    "webhookDisabledReason": null,
    "notifySlack": false,
    "slackIntegrationId": null,
    "slackChannelId": null,
    "createdAt": "2026-07-01T18:00:00.000Z",
    "updatedAt": "2026-07-09T02:10:00.000Z",
    "trackerCount": 4
  }
}
FieldTypeNotes
idstringmon_* (rows minted before the Monitors rename carry a legacy wl_* prefix; both resolve).
webhookSecretSet / webhookSecretHintboolean / string | nullWhether a signing secret exists, plus its last 4 characters. The secret itself is never returned on reads.
webhookFailuresnumberConsecutive delivery failures recorded for this monitor. Reset by rotation or PATCH { "notifyWebhook": true }. Today the delivery pipeline accrues failures on the tracker that fired, so this counter can lag (fix pending).
webhookDisabledAt / webhookDisabledReasonstring | nullSet when repeated failures auto-disable the webhook (10 consecutive). Null while healthy. Fix your endpoint, then re-enable with PATCH { "notifyWebhook": true }.
isPausedbooleanDashboard pause state. Delivery-time pause is enforced per tracker today (PATCH /v1/trackers/{id} with { "paused": true }); monitor-level pause alone does not stop tracker deliveries (fix pending).
Alert history row (GET /v1/monitors/{id}/alerts?n=25; also per tracker):
{
  "data": [
    {
      "id": "dlv_8c41f2a9b7e64d1a90cc55aa1b2c3d4e",
      "tracker_id": "trk_9f2ab4c8d1e6",
      "monitor_id": "mon_abc123",
      "entity_id": null,
      "mention_id": "men_2049815",
      "media_id": 9912345,
      "appearance_id": 2049815,
      "channel": "webhook",
      "status": "sent",
      "final_status": "sent",
      "error_message": null,
      "scheduled_at": "2026-07-09T01:58:00.000Z",
      "sent_at": "2026-07-09T01:58:04.000Z",
      "created_at": "2026-07-09T01:58:00.000Z",
      "tracker": { "id": "trk_9f2ab4c8d1e6", "entity_name": "Ramp", "entity_type": "organization", "display_name": "Ramp" },
      "monitor": { "id": "mon_abc123", "name": "Competitor watch" }
    }
  ],
  "has_more": false,
  "next_cursor": null
}
FieldTypeNotes
channelenumemail, slack, webhook. One matched appearance produces one row per enabled channel, and one per recipient for email; do not count delivery rows as distinct detections. Group by appearance_id for occurrence counts. Digest-frequency email lands in these rows only after the digest sends.
status / final_statusstringDelivery state machine; error_message set on failures.
entity_idstring | nullPublic ent_* form; joins directly against entity reads. Null on older rows; the embedded tracker always carries the entity.
mention_idstring | nullPublic men_* form; joins directly against mention rows and Community Review.
media_id / appearance_idnumberNumeric, matching their forms elsewhere in the API.
n paramint1-100, default 25. This endpoint does not cursor-paginate: has_more is always false. A documented exception to the standard list contract.

Webhooks

Webhook delivery POSTs each alert to your webhookUrl as JSON. Paid tiers only. Endpoint requirements: HTTPS, publicly reachable, responds 2xx within 10 seconds.

Delivery payload

{
  "event": "new_appearance",
  "tracker": { "id": "trk_9f2ab4c8d1e6", "entityName": "Ramp", "entityType": "organization", "entitySlug": "ramp" },
  "monitor": { "id": "mon_abc123", "name": "Competitor watch", "icon": "radar", "color": "indigo" },
  "appearance": {
    "id": 2049815,
    "videoId": "abcd_1234",
    "videoTitle": "…",
    "channelName": "TBPN",
    "timestamp": "08:12",
    "timestampSeconds": 492,
    "duration": "1:02:00",
    "sentiment": 0.42,
    "context": "Every startup I know is moving their spend management over…",
    "videoUrl": "https://www.youtube.com/watch?v=abcd_1234&t=492"
  },
  "media": { "title": "…", "channel": "TBPN", "publishedAt": "2026-07-08T16:00:00Z", "duration": "1:02:00", "thumbnailUrl": "…" },
  "people": [{ "name": "John Coogan" }],
  "topics": [{ "name": "fintech", "sentiment": "positive" }],
  "arcmira": { "momentUrl": "https://arcmira.com/watch?...", "channelUrl": "…", "shareId": "…" },
  "timestamp": "2026-07-09T01:58:04.000Z"
}
FieldTypeNotes
eventenumnew_appearance (a tracked entity was matched) or test (dashboard test delivery). Handle unknown values gracefully; new event names will be dot-delimited past-tense.
appearance.timestampSecondsnumber | nullInteger seconds where the moment starts; read this. 0 means the full episode, no specific moment. Null only if the stored string is unparseable.
appearance.timestampstringDeprecated MM:SS (or HH:MM:SS) string, same information as timestampSeconds. "0:00" means the full episode. Kept until a changelog-announced removal.
appearance.sentimentnumber | nullRaw sentiment score in [-1, 1]; null when not computed. context is the quote. (Per-topic topics[].sentiment values are bucketed labels.)
arcmiraobjectDeep links (watch-the-moment URL, channel URL, share ID).
timestampstringISO delivery time.

Request headers

Every delivery carries:
Content-Type: application/json
User-Agent: Arcmira-Webhook/1.0
X-Arcmira-Signature: sha256=<base64 HMAC>
X-Arcmira-Timestamp: 1751940000
X-Arcmira-Delivery-Id: <delivery id>
X-Arcmira-Event: new_appearance

Signature verification

The signature is HMAC-SHA256 over the string {X-Arcmira-Timestamp}.{raw request body}, keyed by your webhook secret (whsec_..., with the whsec_ prefix stripped before use), base64-encoded, and sent as sha256=<base64>. Reject deliveries whose timestamp is more than 300 seconds from now (replay protection), and always compare in constant time.
Verify against the raw request body bytes, before any JSON parsing or re-serialization. Framework body parsers that re-encode JSON will break the signature.
import { createHmac, timingSafeEqual } from 'node:crypto';
import express from 'express';

const app = express();

app.post(
  '/hooks/arcmira',
  express.raw({ type: 'application/json' }), // raw body is required
  (req, res) => {
    const secret = process.env.ARCMIRA_WEBHOOK_SECRET!.replace(/^whsec_/, '');
    const timestamp = req.header('X-Arcmira-Timestamp') ?? '';
    const signature = req.header('X-Arcmira-Signature') ?? '';

    if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
      return res.status(400).send('stale timestamp');
    }

    const expected = 'sha256=' + createHmac('sha256', secret)
      .update(`${timestamp}.${req.body.toString('utf8')}`)
      .digest('base64');

    const a = Buffer.from(signature);
    const b = Buffer.from(expected);
    if (a.length !== b.length || !timingSafeEqual(a, b)) {
      return res.status(401).send('bad signature');
    }

    const alert = JSON.parse(req.body.toString('utf8'));
    // handle alert.event === 'new_appearance' ...
    res.sendStatus(200);
  },
);
import base64, hashlib, hmac, os, time
from fastapi import FastAPI, Request, HTTPException

app = FastAPI()
SECRET = os.environ['ARCMIRA_WEBHOOK_SECRET'].removeprefix('whsec_').encode()

@app.post('/hooks/arcmira')
async def arcmira_hook(request: Request):
    raw = await request.body()  # raw bytes, before parsing
    timestamp = request.headers.get('x-arcmira-timestamp', '')
    signature = request.headers.get('x-arcmira-signature', '')

    if abs(time.time() - int(timestamp or 0)) > 300:
        raise HTTPException(400, 'stale timestamp')

    digest = hmac.new(SECRET, f'{timestamp}.'.encode() + raw, hashlib.sha256).digest()
    expected = 'sha256=' + base64.b64encode(digest).decode()
    if not hmac.compare_digest(signature, expected):
        raise HTTPException(401, 'bad signature')

    alert = await request.json()
    # handle alert['event'] == 'new_appearance' ...
    return {'ok': True}

Secret lifecycle

  • The signing secret (whsec_...) is generated when webhook delivery is first enabled on a monitor.
  • The secret is returned exactly once, in the response that enabled the webhook. Store it securely; reads return webhookSecretSet: true and a hint only.
  • Lost it? Rotate: POST /v1/monitors/{id}/webhook-secret/rotate (scope monitors:write) returns a fresh secret once. During a 24h overlap window, deliveries are signed with both secrets (X-Arcmira-Signature new, X-Arcmira-Signature-Previous old) so you can roll with zero downtime.
  • Rotation does not re-enable a disabled webhook; re-enable explicitly with PATCH { "notifyWebhook": true } after fixing your endpoint.

Retries and auto-disable

The delivery queue retries failed deliveries (platform-default schedule; rate-limited endpoints retry after 60 seconds). 10 consecutive failures auto-disable the webhook: notifyWebhook stays configured but webhookDisabledAt/webhookDisabledReason are set and deliveries stop. Auto-disable is enforced per tracker today; a monitor-level webhook’s failure counter accrues on the tracker that fired (fix pending), so also watch your endpoint’s own logs.
StateDetectionAction
HealthywebhookFailures: 0, webhookDisabledAt: nullNothing.
FailingwebhookFailures > 0Check your endpoint’s 2xx rate and latency (respond in under 10s; do heavy work async).
Auto-disabledwebhookDisabledAt setFix the endpoint, then PATCH { "notifyWebhook": true } to re-enable.

Errors

CodeHTTPWhen
invalid_api_key401Bad or internal key.
insufficient_scope403Missing monitors:write / trackers:write on a mutation.
monitor_not_found / tracker_not_found404ID not found or not yours.
webhook_not_configured409Rotate called on a monitor with no webhook.
idempotency_conflict409Same Idempotency-Key, different body. Mint a new key per logical operation.
rate_limit_exceeded429Honor Retry-After.
Envelope: { "error": { "type", "code", "message", "doc_url", "request_id" } }. Switch on error.code. Documented exception: tier gates on delivery features (webhook or Slack requested on a plan without them) return a legacy bare 403 body, { "error": "...", "upgradeRequired": true, "feature": "webhooks" }, without the envelope. Detect it by the upgradeRequired field.

Rate limits & idempotency

  • Per-key sliding 60s window (free 60/min, paid 240, teams/enterprise 600); RateLimit-* headers on every response.
  • Idempotency-Key on every POST/PATCH; 24h cache per key; replays return Idempotency-Replayed: true; reuse with a different body returns 409.

Community Review

Any fired alert is disputable, free (0 rows), via type: "monitor_alert" on POST /v1/feedback. Corrections key off the alert row id; referenced rows must belong to your account (404 alert_not_found otherwise):
{
  "type": "monitor_alert",
  "query": { "monitor_id": "mon_abc123", "tracker_id": "trk_9f2ab4c8d1e6", "alert_id": "<alert row id>" },
  "endpoint": "/v1/monitors/mon_abc123/alerts",
  "corrections": [
    {
      "id": "<alert row id>",
      "issue_type": "wrong_entity",
      "suggested_change": { "entity_id": "ent_999" },
      "notes": "Fired for the wrong OpenAI merge candidate."
    }
  ]
}
Issue set: false_positive_alert, wrong_entity, wrong_media, wrong_timestamp, duplicate_alert, missed_alert (expectation shape: no row to target; suggested_change carries source_url + approximate_timestamp_seconds), delivery_issue (targets the delivery row; suggested_change: { "channel": ... }), other. Content disputes target the occurrence, not each delivery row; file once per appearance_id. Check status later with GET /v1/feedback/{feedback_id}. Nothing auto-applies. Catalog: Community Review.

Common mistakes

WrongCorrect
Counting alert rows as detectionsOne matched appearance writes one row per enabled channel; group by appearance_id
Paginating alert history with cursorIt takes ?n= (1-100, default 25) and never cursor-paginates; documented exception
Joining alerts to mentions via appearance_id string tricksAlert rows carry mention_id (men_*) and entity_id (ent_*) directly; media_id/appearance_id stay numeric
Expecting GET /v1/monitors/{id} to return the webhook secretThe secret is returned once when the webhook is enabled; recover via the rotate endpoint
Verifying the signature against re-serialized JSONVerify against the raw body bytes; use a raw body parser
Skipping Idempotency-Key on POST/PATCHSend it on every mutation; retries are unsafe without it
POST /v1/monitors/{id}/trackers with entityNameThat route attaches existing trackers: { "trackerIds": ["trk_..."] }. Create the tracker first with POST /v1/trackers
One monitor per entityTrackers watch entities; monitors group them with shared delivery
Expecting alerts for years-old videosAlerts cover recently published media (~14-day window); backfill with Search
entityType: "company"The enum is person, organization, product, topic, channel

Patterns and gotchas

  • Webhook for speed, polling for reconciliation: consume webhooks in realtime and sweep ?n=100 alert history periodically to catch anything missed during downtime.
  • Respond 2xx fast (under 10s) and process async; slow endpoints accumulate failures and auto-disable after 10 consecutive.
  • To stop deliveries, pause the tracker: PATCH /v1/trackers/{id} with { "paused": true }. There is no backfill for the paused window. Monitor-level isPaused is dashboard state and does not stop tracker deliveries today.
  • Alert occurrences debit 100 rows each; /v1/meusage.rows_remaining is the live balance.
  • Deliveries from usage-cap pauses appear as missed alerts in the dashboard and can be replayed there.
  • Dashboard-created monitors and API-created monitors are the same objects; you can mix freely.

Complete examples

Create, deliver, verify, and reconcile:
TypeScript
const BASE = 'https://api.arcmira.com';
const H = {
  Authorization: `Bearer ${process.env.ARCMIRA_API_KEY}`,
  'Content-Type': 'application/json',
};

// 1. Monitor with webhook delivery; capture the one-time secret
const createRes = await fetch(`${BASE}/v1/monitors`, {
  method: 'POST',
  headers: { ...H, 'Idempotency-Key': crypto.randomUUID() },
  body: JSON.stringify({
    name: 'Portfolio watch',
    notifyWebhook: true,
    webhookUrl: 'https://example.com/hooks/arcmira',
  }),
}).then(r => r.json());
// -> persist createRes.monitor.webhookSecret (whsec_...) NOW; it is not shown again

// 2. Trackers from your own list, attached to the monitor
const trackerIds: string[] = [];
for (const org of ['Ramp', 'Vanta', 'Figma']) {
  const { tracker } = await fetch(`${BASE}/v1/trackers`, {
    method: 'POST',
    headers: { ...H, 'Idempotency-Key': crypto.randomUUID() },
    body: JSON.stringify({ entityName: org, entityType: 'organization' }),
  }).then(r => r.json());
  trackerIds.push(tracker.id);
}
await fetch(`${BASE}/v1/monitors/${createRes.monitor.id}/trackers`, {
  method: 'POST',
  headers: { ...H, 'Idempotency-Key': crypto.randomUUID() },
  body: JSON.stringify({ trackerIds }),
});

// 3. Reconciliation sweep (run on a schedule)
const { data } = await fetch(
  `${BASE}/v1/monitors/${createRes.monitor.id}/alerts?n=100`, { headers: H },
).then(r => r.json());
const occurrences = new Set(data.map((a: any) => a.appearance_id));
console.log(`${occurrences.size} occurrences across ${data.length} deliveries`);