# Calls COHO makes to you

Atrium app developer spec, section 3: Calls COHO makes to you.

## Signed POST (lifecycle, schedules, events)

Health is **not** signed. Everything else is.

| Header | Value |
|---|---|
| `Content-Type` | `application/json` |
| `X-Atrium-Signature` | `t=<unix_seconds>,v1=<hex_hmac_sha256>` |
| `X-Atrium-Connection-Reference` | Connection Guid (`D` format) |
| `X-Atrium-Delivery-Id` | Guid — treat as idempotency key; **dedupe within the 300s window** |
| `X-Atrium-Event` | `lifecycle.setup` \| `lifecycle.disconnect` \| `schedule.<key>` \| `event.<key>` |

Signed string (UTF-8): `{t}.{METHOD}.{pathAndQuery}.{connectionReference}.{rawBody}`.

- `METHOD` is uppercase (`GET`, `POST`, `PUT`).
- `pathAndQuery` is the request path plus query string, **without** `atriumSignature` (do not reorder other params).
- `connectionReference` is the connection Guid, also sent as `X-Atrium-Connection-Reference` (iframe loads put it on the query string instead of a header).
- HMAC-SHA256 with the connection **signing secret** from setup, except `lifecycle.setup`, which uses your app's **setup secret** described below (never the Public API bearer token).
- Reject timestamps more than **30 seconds in the future**. Reject timestamps more than **300 seconds in the past**.

Three details cause most verification bugs:

- Sign the **raw request body bytes** exactly as received. Do not parse and re-serialise JSON first — key order and whitespace change, and every signature then fails.
- An **empty body is valid and still signed**. Signed `GET`s (config schema, config values, quick view) still include method, path, and connection in the signed string.
- Compare digests in **constant time** (`timingSafeEqual`, `hmac.compare_digest`, `hmac.Equal`, `CryptographicOperations.FixedTimeEquals`). A `==` string compare leaks timing.

### Signature test vectors

Assert against these before you trust your implementation. Secret `atrium_test_secret`, timestamp `1767225600`, connection `11111111-1111-1111-1111-111111111111`, so the header is `t=1767225600,v1=<expected>`. For the setup row, treat this as the app's setup secret; for later rows, treat it as the connection signing secret.

| METHOD | pathAndQuery | Raw body | Expected `v1` |
|---|---|---|---|
| `POST` | `/webhooks/atrium/setup` | `{"type":"lifecycle.setup","data":{}}` | `dff6a1bb99bc74adf1c24bfbae39f9b511582c98e3eda0a520e23b37feca56df` |
| `GET` | `/config?connectionReference=11111111-1111-1111-1111-111111111111` | *(empty string)* | `7dde59340b9b657a939e69760412d0ba615a0052d74674399ba7df87f7e4b906` |
| `POST` | `/webhooks/atrium/triggers/event` | `{"type":"event.tenancy.started","data":{"name":"Zoe"}}` | `f62b7d1059edffe1c88013f0a20c9dc5a96036ed722dc71f0f9d5687bedaad90` |

Your verifier must also **reject** each of these: a tampered body, a different path or method, a `v1` that does not match, a timestamp more than 300 seconds in the past, and a timestamp more than 30 seconds in the future. COHO pins its platform verifier to these same vectors.

Envelope:

```json
{
  "deliveryId": "00000000-0000-0000-0000-000000000001",
  "occurredAt": "2026-08-07T15:00:00Z",
  "appReference": "…",
  "connectionReference": "…",
  "organisationReference": "…",
  "type": "lifecycle.setup",
  "data": { }
}
```

Return **HTTP 2xx** quickly. Do long work asynchronously. Treat `deliveryId` as idempotent.

| Your status | COHO |
|---|---|
| `2xx` | Success |
| Timeout, `408`, `429`, `5xx` | Retry with backoff, then dead-letter. On `429`, COHO honours your `Retry-After` (capped at 60s) |
| `401` / `403` / `400` | No retry |
| `404` on **setup** | Connect fails and rolls back |

Health is **app-scoped** (one probe per app base URL). After the first not-OK result the app is
degraded; after three consecutive not-OK results COHO pauses schedule/event triggers for **all**
connections of that app. Any OK result immediately clears the failure count and resumes delivery.
Disconnect may still be attempted while paused.

## `lifecycle.setup` — app connected

COHO signs `lifecycle.setup` with your app's **setup secret**. Your process must verify that signature **before** accepting or storing anything from the request. Never authenticate setup with `data.signingSecret`: that value is untrusted until setup verification succeeds.

Read the setup secret from the environment as `ATRIUM_SETUP_SECRET` (the hello samples, the Node kit, and the Hosting task definition use that name).

**Where it comes from.** COHO mints a setup secret **per app** when the app is registered and shows it **once** in the create response (Store UI, publisher portal, Public API v1.1, and MCP registration all return it). It is never shown again on a later read.

- **Self-hosted (external URL) and Draft apps:** copy the value when you register and set it as `ATRIUM_SETUP_SECRET` on your service before connecting. Draft apps receive it alongside the one-time API key and signing secret.
- **Atrium Hosting:** COHO injects `ATRIUM_SETUP_SECRET` into your task automatically on every provision. You only need a copy if you also run the same build somewhere else.
- **Lost or leaked:** the app owner can **regenerate** it from My apps (org Store) or the publisher portal. The old value stops working immediately; hosted apps are re-provisioned with the new value; self-hosted apps must update their environment before the next connect. Regeneration does not affect connections that already completed setup.

The setup secret is only for setup, and only your app's setup — it cannot verify or forge setup for any other app. After successful setup, verify config, quick-view, lifecycle, schedule, and event requests with the per-connection `data.signingSecret`.

`data`:

```json
{
  "apiKey": "<guid>",
  "apiBaseUrl": "https://api.example.com",
  "signingSecret": "<hex>"
}
```

- `apiKey` — the **background / unattended app credential** for this install. `Authorization: Bearer <apiKey>` on Public API v1.1 and Store log/config calls. Use this for schedules, webhooks, and any work with no manager signed in. Shown once.
- `apiBaseUrl` — no trailing slash. Call `{apiBaseUrl}/v1.1/public/…`.
- `signingSecret` — verify all later signed POSTs (and signed config GET/PUT).

Store these against `connectionReference`. If you return non-2xx, COHO deletes the connection and the key.

## `lifecycle.disconnect` — app removed

Credentials are already dead. `data` may include a reason. Delete org-scoped data you hold for that connection.

## Schedules and events

`type` is `schedule.<key>` or `event.<key>`. Domain payloads in `data` use **references / Guids only**, never database integer ids.

Published event keys (subscribe via `triggers.events`; delivery requires the Store dispatcher):

| Key | When |
|---|---|
| `tenancy.started` | Tenancy becomes active (e.g. FUTURE to ACTIVE) |
| `tenancy.ended` | Tenancy becomes past (e.g. ACTIVE to PAST) |
| `tenancy.updated` | Manager patches tenancy details or moves the tenancy to another room |
| `onboarding.started` | Onboarding is created / first pushed |
| `onboarding.updated` | Onboarding status or step state changes |
| `onboarding.completed` | Onboarding reaches completed |
| `onboarding.cancelled` | Onboarding is cancelled |
| `property.updated` | Property listing / lettings-relevant fields change |
| `room.availability.changed` | Room `AvailableFrom` is persisted with a new value (any manager / Public API / occupancy path that saves that change) |
| `maintenance.created` | A maintenance issue is created (including convert-from-conversation) |
| `message.created` | A conversation message is saved as Sent (references only; no body) |
| `atrium.test` | Manager hits **Run test** in the Store (not a domain event) |

The catalogue will grow. Ignore unknown keys.

### Event `data` shapes

`event.tenancy.started`:

```json
{
  "tenancyReference": "00000000-0000-0000-0000-000000000001",
  "propertyReference": "12-example-street-abcde",
  "activatedAt": "2026-09-17T08:00:00Z"
}
```

`event.tenancy.ended`:

```json
{
  "tenancyReference": "00000000-0000-0000-0000-000000000001",
  "propertyReference": "12-example-street-abcde",
  "endedAt": "2026-09-17T08:00:00Z"
}
```

`event.tenancy.updated`:

```json
{
  "tenancyReference": "00000000-0000-0000-0000-000000000001",
  "propertyReference": "12-example-street-abcde",
  "roomReference": "room-3",
  "updatedAt": "2026-10-05T08:00:00Z"
}
```

`event.maintenance.created`:

```json
{
  "maintenanceIssueGuid": "00000000-0000-0000-0000-000000000001",
  "jobNumber": "M0042",
  "propertyReference": "12-example-street-abcde",
  "roomReference": "room-3",
  "raisedBy": "manager",
  "severity": "medium",
  "maintenanceCategoryGuid": null,
  "tenancyTenantGuid": null,
  "createdAt": "2026-10-05T08:00:00Z"
}
```

`event.message.created`:

```json
{
  "messageGuid": "00000000-0000-0000-0000-000000000001",
  "conversationReference": "message-abcde",
  "medium": "email",
  "createdAt": "2026-10-05T08:00:00Z"
}
```

`schedule.<key>` (COHO cron tick):

```json
{
  "scheduleKey": "daily",
  "fireTime": "2026-09-17T08:00:05Z"
}
```

`tenancyReference` is the tenancy Guid. Times are UTC.

## App operations gateway (optional)

Declare operations in the manifest so managers and integrations can call your app **through COHO** (one Public API / MCP connection — your app is never a second MCP endpoint).

Each operation:

| Field | Rules |
|---|---|
| `name` | Lowercase letter, then letters/digits/underscores (max 64). Callers use this name. |
| `path` | Relative path on your base URL (e.g. `/operations/list`). Same signing as config. |
| `method` | `GET`, `POST`, `PUT`, `PATCH`, or `DELETE`. |
| `description` | 8–500 characters; written for an AI reader. |
| `effect` | **Required.** `read`, `write`, or `delete` — no default. Omit or mistype and the manifest write fails. MCP confirms write/delete before calling. |

**Public API:** `METHOD /v1.1/public/apps/{appReference}/{name}` with scope `apps.operations`. `appReference` is your app Guid or `ShortName`. The HTTP method must match the manifest. Org API keys may call operations only on **unrestricted organisation** installs. Personal installs and organisation installs with a person allow-list need a manager session (MCP / manage), not a bare API key.

**MCP:** `list_app_operations` / `call_app_operation` (manager session). Write/delete effects ask for confirmation.

**Your handler:** verify `X-Atrium-Signature` the same way as config GET/PUT (signed string includes method, path+query with `connectionReference`, connection Guid, raw body). Return JSON. Timeout 10s; no retries. Max 50 operations per manifest.
