Signed POST (lifecycle, schedules, events)
Section titled “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}.
METHODis uppercase (GET,POST,PUT).pathAndQueryis the request path plus query string, withoutatriumSignature(do not reorder other params).connectionReferenceis the connection Guid, also sent asX-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
GETs (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
Section titled “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:
{ "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
Section titled “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_SECRETon your service before connecting. Draft apps receive it alongside the one-time API key and signing secret. - Atrium Hosting: COHO injects
ATRIUM_SETUP_SECRETinto 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:
{ "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
Section titled “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
Section titled “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
Section titled “Event data shapes”event.tenancy.started:
{ "tenancyReference": "00000000-0000-0000-0000-000000000001", "propertyReference": "12-example-street-abcde", "activatedAt": "2026-09-17T08:00:00Z"}event.tenancy.ended:
{ "tenancyReference": "00000000-0000-0000-0000-000000000001", "propertyReference": "12-example-street-abcde", "endedAt": "2026-09-17T08:00:00Z"}event.tenancy.updated:
{ "tenancyReference": "00000000-0000-0000-0000-000000000001", "propertyReference": "12-example-street-abcde", "roomReference": "room-3", "updatedAt": "2026-10-05T08:00:00Z"}event.maintenance.created:
{ "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:
{ "messageGuid": "00000000-0000-0000-0000-000000000001", "conversationReference": "message-abcde", "medium": "email", "createdAt": "2026-10-05T08:00:00Z"}schedule.<key> (COHO cron tick):
{ "scheduleKey": "daily", "fireTime": "2026-09-17T08:00:05Z"}tenancyReference is the tenancy Guid. Times are UTC.
App operations gateway (optional)
Section titled “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.