# Manager integration contract

Host: `https://monitor.skjldlabs.com`  
Audience: external systems and autonomous agents that need to report telemetry.  
Generated from the running configuration, so the numbers below are the ones actually enforced.

## What Manager does

Manager receives telemetry from digital services and turns it into operational state.
It watches whether each feed keeps its declared reporting frequency, evaluates metric
thresholds, opens and resolves fingerprinted incidents, and notifies humans through the
dashboard, email, webhooks and mobile push.

The model is: a **tenant** owns **services**; a service owns **data sources**; a data
source is one authenticated feed with its own token, its own declared contract and its
own freshness expectation. You send events to a data source, never to a service.

## What you need before you start

One **registration token**, created by an owner or admin in the dashboard under
"API & MCP". It is bound to a single tenant and a single stable service key, and it is
the only thing you cannot obtain yourself. Everything after that is self-service.

## Every endpoint

Two credentials exist and they are not interchangeable. A **registration token**
manages the manifest; a **source token** sends telemetry for one data source.
Both travel as `Authorization: Bearer {TOKEN}` and are stored as SHA-256 hashes,
so a lost token can be replaced but never recovered. Registration tokens begin with
`mgr_reg_` and source tokens with `mgr_live_`, which is enough to tell them apart if
you are handed both.

| Method | Path | Token | Purpose |
| --- | --- | --- | --- |
| `GET` | `/api/v1/register` | registration | Read back the manifest this registration token is bound to. |
| `POST` | `/api/v1/register` | registration | Create or update the service and its data sources from a manifest. Returns one-time tokens for newly created sources. |
| `POST` | `/api/v1/register/sources/{source_key}/rotate-token` | registration | Issue a new ingest token for one source of the same service. |
| `POST` | `/api/v1/ingest/{source_id}` | source | Send one telemetry event. |
| `POST` | `/api/v1/ingest/{source_id}/batch` | source | Send up to 100 events in one request, as an "events" array. |
| `GET` | `/api/v1/ingest/{source_id}/status` | source | Read the source status, when data last arrived and when the next report is expected. |
| `POST` | `/mcp/{source_id}` | source | Streamable HTTP MCP endpoint exposing the same ingestion path as REST. |

## Step 1: declare yourself with a manifest

```http
POST https://monitor.skjldlabs.com/api/v1/register
Authorization: Bearer {REGISTRATION_TOKEN}
Content-Type: application/json
```

The manifest is versioned and idempotent. Send it again whenever your service changes:
declared sources are created or updated, sources you stop declaring are paused with their
history intact, and tokens for sources that already exist are never disclosed again.

```json
{
    "manifest_version": "1.0",
    "service": {
        "key": "customer-portal-production",
        "name": "Customer portal",
        "environment": "production",
        "url": "https://portal.example.com",
        "version": "4.12.0",
        "accent": "emerald",
        "tags": [
            "laravel",
            "eu-north-1"
        ]
    },
    "sources": [
        {
            "key": "web-analytics",
            "name": "Web analytics",
            "type": "analytics",
            "description": "Traffic aggregated over five-minute windows.",
            "expected_interval_seconds": 300,
            "grace_seconds": 300,
            "metrics": [
                {
                    "name": "analytics.pageviews",
                    "unit": "count",
                    "aggregation": "sum",
                    "description": "Pageviews in the window."
                },
                {
                    "name": "analytics.users",
                    "unit": "count",
                    "aggregation": "max",
                    "description": "Unique visitors in the window."
                }
            ],
            "events": [
                {
                    "type": "analytics.top_pages",
                    "severity": "info",
                    "description": "Most visited pages in the window."
                }
            ],
            "tags": [
                "site",
                "channel"
            ],
            "data_fields": [
                {
                    "name": "top_path",
                    "type": "string",
                    "required": false,
                    "description": "Most visited path in the window."
                }
            ]
        }
    ]
}
```

### Manifest rules

| Field | Rule |
| --- | --- |
| `manifest_version` | `MAJOR.MINOR`, for example `1.0`. |
| `service.key` | Lowercase kebab-case. Must match the key your registration token is bound to. |
| `service.environment` | `production`, `staging` or `development`. |
| `sources[].key` | Lowercase kebab-case, unique within the manifest. This is the stable identity of the feed. |
| `sources[].type` | One of `health`, `analytics`, `contacts`, `security`, `laravel-log`, `performance`, `custom`. |
| `sources[].expected_interval_seconds` | 60 to 2 592 000. Declare what you will actually keep, not what you wish for. |
| `sources[].grace_seconds` | 0 to 86 400. Added to the interval before a feed counts as late. |
| `sources[].metrics[].aggregation` | One of `last`, `sum`, `avg`, `min`, `max`, `p50`, `p95`, `p99`. |
| `sources[].events[].severity` | One of `info`, `warning`, `error`, `critical`. |

Be honest about `expected_interval_seconds`. Manager opens a "no new data" incident when a
feed misses its declared window plus grace for several consecutive checks. Declaring a
cadence you do not keep produces noise, not safety.

### Response

The response contains a `sources` array. Each newly created source carries a plaintext
`token` **once**, plus its `ingest_url` and `mcp_url`. Store them before you do anything
else; they cannot be read back. Use
`POST https://monitor.skjldlabs.com/api/v1/register/sources/{source_key}/rotate-token` if you lose one.

## Step 2: send telemetry

Two interfaces, one behaviour. Pick whichever suits you; you can use both.

### REST

```http
POST https://monitor.skjldlabs.com/api/v1/ingest/{SOURCE_ID}
Authorization: Bearer {SOURCE_TOKEN}
Content-Type: application/json
```

```json
{
    "idempotency_key": "checkout-health-2026-08-08T13:00",
    "type": "heartbeat",
    "status": "healthy",
    "severity": "info",
    "title": "Checkout health",
    "message": "p95 over the last five minutes",
    "latency_ms": 184,
    "metric": {
        "name": "http.latency.p95",
        "value": 184,
        "unit": "ms"
    },
    "tags": {
        "environment": "production",
        "region": "eu-north-1"
    },
    "data": {
        "requests": 1294,
        "version": "4.12.0"
    },
    "occurred_at": "2026-08-08T13:00:00Z"
}
```

Every field is optional. Send only what you have.

| Field | Type | Notes |
| --- | --- | --- |
| `type` | string | Free-form kind of report, for example heartbeat, metric, event or log. Max 40 characters. |
| `status` | string | Service state. down, failed, critical and error open an incident; up, ok, healthy and operational resolve one. Max 40 characters. |
| `severity` | enum | One of info, warning, error, critical. Defaults to info. |
| `title` | string | Short headline shown in the event list. Max 255 characters. |
| `message` | string | Longer context. Max 10 000 characters. |
| `latency_ms` | integer | Measured latency, 0 to 3 600 000. |
| `metric.name` | string | Required when metric is present. Stable dot-separated name, max 120 characters. |
| `metric.value` | number | Required when metric is present. |
| `metric.unit` | string | For example ms, percent, count or bytes. Max 32 characters. |
| `tags` | object | Up to 30 string values. Use for dimensions you want to filter on, such as environment or region. |
| `data` | object | Up to 100 structured values kept with the event. |
| `occurred_at` | string | ISO 8601. Defaults to now and may not be more than five minutes in the future. |
| `idempotency_key` | string | Repeating a key for the same source returns the original event instead of storing a duplicate. Max 190 characters. |

Post an `events` array to `/batch` for up to 100 events in one request.

### MCP

Every data source is also a Streamable HTTP MCP server at
`POST https://monitor.skjldlabs.com/mcp/{SOURCE_ID}`, authenticated with the same bearer token.

```json
{
    "mcpServers": {
        "manager-production-health": {
            "type": "http",
            "url": "https://monitor.skjldlabs.com/mcp/{SOURCE_ID}",
            "headers": {
                "Authorization": "Bearer {SOURCE_TOKEN}"
            }
        }
    }
}
```

| Tool | Arguments | Purpose |
| --- | --- | --- |
| `report_heartbeat` | `status`, `message`, `latency_ms`, `idempotency_key` | Report that the service is alive, with its current status and optional latency. |
| `report_event` | `type`, `title`, `message`, `severity`, `status`, `tags`, `data`, `idempotency_key`, `occurred_at` | Report an operational event such as a deploy, a failure or a signup. |
| `report_metric` | `name`, `value`, `unit`, `severity`, `message`, `idempotency_key`, `occurred_at` | Report a numeric metric such as latency, users, errors, conversions or queue depth. |
| `get_source_status` | none | Read back the current status of the authenticated source. |

Resource `manager://source/recent-events`: The most recent events already stored for the authenticated source.

## How often to report

| Source type | Suggested interval | Note |
| --- | --- | --- |
| `health` | 60s | A heartbeat every minute gives the fastest possible outage detection. |
| `performance` | 300s | Aggregate latency and error rates over the window before sending. |
| `analytics` | 300s | Aggregate first. Never send one event per pageview. |
| `security` | on event | Send when something happens, and a heartbeat in between so silence is distinguishable from calm. |
| `contacts` | on event | Send each submission as it arrives. |
| `laravel-log` | on event | Send warnings and above. Batch to stay inside the rate limit. |

## Status and severity

`status` drives incidents: `down`, `failed`, `critical` and `error` open one, and
`up`, `ok`, `healthy` and `operational` resolve the matching availability incident.
Heartbeats over MCP accept `healthy`, `up`, `ok`, `degraded`, `warning`, `down`, `critical`.

`severity` is `info`, `warning`, `error`, `critical` and controls how loudly a
stored event is presented. It does not by itself open an incident.

## Limits

| Limit | Value |
| --- | --- |
| Ingest requests per minute, per source | 120 |
| Registration requests per minute | 30 |
| Events per batch | 100 |
| Request body | 1.0 MB |
| Sources per manifest | 20 |
| Metrics per source | 100 |
| Declared event types per source | 50 |
| Raw event retention | 90 days |

## Response codes

| Code | Meaning |
| --- | --- |
| 202 | Accepted and stored. |
| 200 | Accepted, but the idempotency key matched an event already stored. Nothing was duplicated. |
| 401 | Missing or wrong bearer token, or an invalid or expired signature. |
| 404 | Unknown data source id. |
| 413 | Request body larger than 1.0 MB. |
| 422 | The payload failed validation. The body names the offending fields. |
| 423 | The data source is paused in the dashboard. |
| 429 | Rate limit exceeded for this source. |

## Retries and duplicates

Set `idempotency_key` to something stable and unique per logical report, such as
`checkout-health-2026-08-08T13:00`. Repeating a key for the same source returns the
original event with HTTP 200 and stores nothing new, so a retry after a timeout is safe.

## Optional request signing

A source can be set to require signatures. Then every request also needs:

```text
X-Manager-Timestamp: {unix seconds}
X-Manager-Signature: hex(HMAC-SHA256(timestamp + "." + raw_request_body, source_token))
```

The timestamp must be within 300 seconds of server time.

## What not to send

Do not put secrets, credentials, session tokens or personal data in `title`, `message`,
`tags` or `data`. Telemetry is retained for 90 days and is visible to everyone
with access to the tenant. Aggregate before you send: counts and percentiles, not raw rows.

