Monotree Open API Reference (v0.44.0 Beta) Base URL: https://customer.monotree.com/api/open/v1 (Use your own company's Monotree URL here, e.g. company.monotree.com.) Monotree is a workplace communication and intranet platform. The Open API lets external systems read and write data, react to events via outgoing webhooks, and push messages into Monotree via incoming webhooks. AI assistants connect through the MCP server instead (see the MCP section at the end). ======================================== AUTHENTICATION ======================================== All API requests require a Bearer token in the Authorization header (except incoming webhooks which use a token in the URL). curl https://customer.monotree.com/api/open/v1/walls \ -H "Authorization: Bearer mono_your_token_here" \ -H "Content-Type: application/json" Tokens are prefixed with "mono_" and have scoped permissions: - read:posts, write:posts - read:comments, write:comments - write:media - read:announcements, write:announcements - read:handbooks, write:handbooks - read:courses, write:courses - read:journeys - read:articles, write:articles - read:events, write:events - read:calendar_entries, write:calendar_entries - read:calendar_event_types, write:calendar_event_types - read:locales - read:users, write:users - read:groups, write:groups - read:org_chart - read:achievements, write:achievements - read:walls - read:stats - read:managerhub - read:surveys - read:forms - read:formresponses - read:certificates, write:certificates - read:certificatetypes, write:certificatetypes - read:staff_cards, write:staff_cards - read:webhooks, write:webhooks - read:incoming_webhooks, write:incoming_webhooks - read:bots, write:bots - write:chat_messages - write:notifications Tokens can optionally be restricted to one IP address (no ranges) and can have an expiration date. Some endpoints belong to per-company features (org chart, manager hub, achievements). If the feature is off they return 403 "The feature is not enabled." regardless of scope. Content created via the API is attributed to either the token creator or a system user, configured when the token is created. ======================================== RESOURCES ======================================== ---------------------------------------- WALLS (scope: read:walls) ---------------------------------------- Walls are content spaces where posts are published. Each wall can be scoped to specific groups (departments/teams). GET /walls — List all walls (paginated) GET /walls/{id} — Get a single wall Response: { "data": { "id": 1, "name": "General", "groups": [ { "id": 1, "name": "Kitchen Staff", "type": "department", "created_at": "2026-01-15T10:00:00+00:00" } ], "created_at": "2026-01-15T10:00:00+00:00" } } ---------------------------------------- POSTS (scope: read:posts, write:posts) ---------------------------------------- Posts are content items on a wall. They are nested under walls in the URL. GET /walls/{wall_id}/posts — List posts on a wall (published by default, paginated; ?status=draft|all to include drafts). Posts an employee has scheduled for later are never returned GET /walls/{wall_id}/posts/{id} — Get a single post (a scheduled post → 404 until it goes live) POST /walls/{wall_id}/posts — Create a post PUT /walls/{wall_id}/posts/{id} — Update a post DELETE /walls/{wall_id}/posts/{id} — Delete a post (returns 204) Create request body: { "body": "Hello from the API!", "media_ids": [123, 124], // optional — IDs from POST /media. Requires the write:media scope. "bot_user_id": 873 // optional — a bot user (see Bots) to attribute the post to instead of the token's user } Update request body: { "body": "Updated body" } Notes on media_ids: - Upload each file via POST /media first (see Media section) to get an ID. - Media may mix image / video / document. A single-type batch is stored as an image, video or file gallery; a mix (or more than one video) as a mixed gallery. Audio and other types → 422. - Maximum 10 media items per post. - Attaching media requires the write:media scope in addition to write:posts. Notes on bot_user_id: - By default the post is authored by the token's user (or the system user, if the token is configured that way). - When bot_user_id is present the bot is ALWAYS the author, regardless of token configuration — same as the direct message endpoint and wall_post incoming webhooks. - Must be a bot user (create one via POST /bots). A non-bot or unknown id → 422 (errors.bot_user_id). Response: { "data": { "id": 123, "body": "Hello from the API!", "wall_id": 1, "wall_name": "General", "author": { "id": 1, "name": "John Doe", "email": "john@example.com", "type": "user", "avatar_url": "https://images.monotree.com/avatars/john-doe.jpg", "groups": [ { "id": 1, "name": "Kitchen Staff", "type": "department", "created_at": "2026-01-15T10:00:00+00:00" } ], "hired_at": "2025-06-01", "is_away": false, "last_active_on": "2026-03-22", "registered_at": "2025-06-01T09:00:00+00:00", "created_at": "2025-06-01T09:00:00+00:00" }, "created_at": "2026-03-22T10:00:00+00:00", "updated_at": "2026-03-22T10:00:00+00:00" } } ---------------------------------------- COMMENTS (scope: read:comments, write:comments) ---------------------------------------- Comments are replies on posts. They are nested under posts in the URL. GET /posts/{post_id}/comments — List comments on a post (paginated) GET /posts/{post_id}/comments/{id} — Get a single comment POST /posts/{post_id}/comments — Add a comment to a post PUT /posts/{post_id}/comments/{id} — Update a comment (author only, within 30 minutes) DELETE /posts/{post_id}/comments/{id} — Delete a comment (returns 204) Create/update request body: { "body": "Thanks for the update!" } Response: { "data": { "id": 789, "body": "Thanks for the update!", "post_id": 123, "wall_id": 1, "author": { "id": 42, "name": "Jane Doe", "email": "jane@example.com", "type": "user", "avatar_url": "https://images.monotree.com/avatars/jane-doe.jpg", "groups": [ { "id": 1, "name": "Kitchen Staff", "type": "department", "created_at": "2026-01-15T10:00:00+00:00" } ], "hired_at": "2025-06-01", "is_away": false, "last_active_on": "2026-03-22", "registered_at": "2025-06-01T09:00:00+00:00", "created_at": "2025-06-01T09:00:00+00:00" }, "created_at": "2026-03-22T10:00:00+00:00", "updated_at": "2026-03-22T10:00:00+00:00" } } Updates are only permitted by the comment author within 30 minutes of creation. ---------------------------------------- MEDIA (scope: write:media) ---------------------------------------- Both endpoints need write:media; there is no read:media scope. Upload files (images, videos, documents) to attach to wall posts. Two-step flow: 1. POST /media with the file → returns a media object with an id. 2. Reference the id in the media_ids array on POST /walls/{wall_id}/posts. POST /media — Upload a file (multipart/form-data) GET /media/{id} — Get a media object by id Upload request (multipart/form-data): media: (required) type: image|video|document (required) Size limit: governed by the server's media config (typically around 100 MB for video, smaller for images). Response: { "data": { "id": 42, "type": "image", "mime_type": "image/jpeg", "filename": "photo.jpg", "size": 123456, "url": "https://cdn.monotree.dk/.../photo.jpg", "width": 1920, "height": 1080, "created_at": "2026-04-24T10:00:00+00:00" } } curl example: curl -X POST https://customer.monotree.com/api/open/v1/media \ -H "Authorization: Bearer mono_..." \ -F "media=@./photo.jpg" \ -F "type=image" Then attach on post creation: curl -X POST https://customer.monotree.com/api/open/v1/walls/1/posts \ -H "Authorization: Bearer mono_..." \ -H "Content-Type: application/json" \ -d '{"body": "Look at this!", "media_ids": [42]}' ---------------------------------------- ANNOUNCEMENTS (scope: read:announcements, write:announcements) ---------------------------------------- Announcements are company-wide messages that can be targeted to specific groups. They are automatically published on creation. GET /announcements — List published announcements (paginated) GET /announcements/{id} — Get a single announcement POST /announcements — Create an announcement (published by default; is_published: false for a draft) PUT /announcements/{id} — Update an announcement DELETE /announcements/{id} — Delete an announcement (returns 204) GET /announcements/{id}/confirmations — List who has confirmed this announcement (paginated, newest first) GET /confirmations — List announcement confirmations (paginated, newest first) Create request body: { "title": "Office closed on Friday", "body": "Due to maintenance, the office will be closed this Friday.", "subtitle": "Facility update", "groups": [1, 3] } Confirmations: an announcement marked confirmable in the CMS asks employees to acknowledge they have read it (a required briefing); confirmations_count is the total. GET /announcements/{id}/confirmations lists the confirmations of one announcement, GET /confirmations every announcement confirmation — the call to make after a failed webhook delivery, since an employee can confirm a briefing published months ago. Confirming is once per employee per announcement and cannot be undone, so each confirmation id is stable and appears exactly once. Both take since (ISO 8601, confirmations made at or after it) and per_page (default 25, max 50), and both are newest first. Each entry is { id, type, created_at, user, announcement } — type is announcement and names the key the confirmed item sits under. created_at is the moment the employee confirmed. Reconciles the monotree.announcement.confirmed webhook. Company events can be made confirmable too, but their confirmations are not exposed yet. Instead of body, pass "article_id" to attach a pre-built standalone article (see ARTICLES — build first, attach at create). body and article_id are mutually exclusive. Published by default — pass "is_published": false to create a draft, then publish later via PUT {"is_published": true}. Update request body (all fields optional): { "title": "Updated title", "body": "Updated body" } Response: { "data": { "id": 456, "article_id": 91, "title": "Office closed on Friday", "subtitle": "Facility update", "body": "Due to maintenance, the office will be closed this Friday.", "author": { "id": 1, "name": "John Doe", "email": "john@example.com", "type": "user", "avatar_url": "https://images.monotree.com/avatars/john-doe.jpg", "groups": [], "hired_at": "2025-06-01", "is_away": false, "last_active_on": "2026-03-22", "registered_at": "2025-06-01T09:00:00+00:00", "created_at": "2025-06-01T09:00:00+00:00" }, "groups": [ { "id": 1, "name": "All Staff", "type": "custom", "created_at": "2026-01-15T10:00:00+00:00" } ], "is_published": true, "is_commentable": true, "is_confirmable": false, "is_pinned": false, "comments_count": 0, "completions_count": 0, "confirmations_count": 0, "starts_at": "2026-03-22", "published_at": "2026-03-22T10:00:00+00:00", "created_at": "2026-03-22T10:00:00+00:00", "updated_at": "2026-03-22T10:00:00+00:00" } } ---------------------------------------- HANDBOOKS (scope: read:handbooks, write:handbooks) ---------------------------------------- Structured, chaptered content — policies, guides, manuals. A handbook holds ordered chapters; each chapter owns an article (see ARTICLES) carrying its title and content. Flow: create the handbook (draft by default), add chapters, fill each chapter's article, publish. Publishing notifies targeted employees. GET /handbooks — List (paginated). Filters: ?status=published|draft|all (default published), ?since= GET /handbooks/{id} — Includes ordered chapter summaries {id, title, subtitle, position, is_published, article_id} POST /handbooks — {title!, subtitle?, groups?, is_published? (default false), translations?} PATCH /handbooks/{id} — Same fields; is_published publishes/unpublishes (422 without a title) DELETE /handbooks/{id} — Deletes the handbook and its chapters (204) GET /handbooks/{id}/chapters — Position order (?status=) POST /handbooks/{id}/chapters — {title!, subtitle?, is_published? (default true)}; auto-creates the chapter's article GET /handbook-chapters/{id} PATCH /handbook-chapters/{id} — {title?, subtitle?, position?, is_published?} (title/subtitle live on the article) DELETE /handbook-chapters/{id} — 409 conflict while a course shares the chapter's article (is_referenced: true) Chapter response: { "data": { "id": 55, "handbook_id": 7, "article_id": 91, "title": "Getting Started", "subtitle": "First steps", "position": 0, "is_published": true, "published_at": "2026-07-01T09:00:00+00:00", "is_referenced": false, "created_at": "2026-07-01T09:00:00+00:00", "updated_at": "2026-07-01T09:00:00+00:00" } } ---------------------------------------- COURSES (scope: read:courses, write:courses) ---------------------------------------- Training content employees complete. A course holds ordered chapters; each chapter owns an article (see ARTICLES) carrying its title and content, and may have a CMS-managed quiz. Flow: create the course (draft by default), add chapters, fill each chapter's article, publish. Publishing notifies targeted employees. Employees pass a chapter by completing its article and passing its quiz (when one exists). Unpublishing a course keeps it in its onboarding flow and journeys as a draft (hidden from employees until republished); only deleting the course removes it from them. GET /courses — List (paginated). Filters: ?status=published|draft|all (default published), ?since= GET /courses/{id} — Includes ordered chapter summaries {id, title, subtitle, position, is_published, article_id} POST /courses — {title!, subtitle?, groups?, is_published? (default false), translations?} PATCH /courses/{id} — Same fields; is_published publishes/unpublishes (422 without a title) DELETE /courses/{id} — Deletes the course and its chapters (204) GET /courses/{id}/chapters — Position order (?status=) POST /courses/{id}/chapters — {title!, subtitle?, is_published? (default true)}; auto-creates the chapter's article GET /course-chapters/{id} PATCH /course-chapters/{id} — {title?, subtitle?, position?, is_published?} (title/subtitle live on the article) DELETE /course-chapters/{id} — Also deletes the chapter's quiz; the article is left in place Chapter response: { "data": { "id": 61, "course_id": 9, "article_id": 95, "quiz_id": null, "title": "Food Safety Basics", "subtitle": "Module one", "position": 0, "is_published": true, "published_at": "2026-07-01T09:00:00+00:00", "created_at": "2026-07-01T09:00:00+00:00", "updated_at": "2026-07-01T09:00:00+00:00" } } quiz_id is read-only — quizzes are created and managed in the CMS. ---------------------------------------- ARTICLES (scope: read:articles, write:articles) ---------------------------------------- An article is the rich content body used across Monotree. Handbook and course chapters own one (their article_id), and announcements, events, calendar entries, certificates and staff cards expose an article_id too. An article has a title, a subtitle, and an ordered list of typed elements (content blocks). Articles are addressed by their own id, so these endpoints work identically for every content type. The body convenience field on announcements, events, calendar entries and staff cards only works while the article is simple (empty or a single paragraph) — once it carries rich content, body writes return 422 to protect it. POST /articles — Create a standalone article to attach at creation time (below) GET /articles/{id} — The article with its ordered elements GET /articles/{id}/markdown — The article flattened to one markdown document. Filter: ?locale= (default content locale, 422 on inactive locale) PATCH /articles/{id} — {title?, subtitle?} PUT /articles/{id}/elements — Replace the elements (bulk save, below) BUILD FIRST, ATTACH AT CREATE Announcements and calendar entries publish by default the moment they are created (events default to drafts) — enriching published content afterwards briefly exposes half-built pages. Either create as a draft (is_published: false), enrich, then publish — or: POST /articles {title?, subtitle?, translations?} creates a standalone article, fill it via PUT /articles/{id}/elements, then pass article_id when creating the announcement/event/calendar entry. The content is complete the instant it goes live. article_id and body are mutually exclusive (422), and the article must not already belong to other content (422). ELEMENT TYPES Writable types and their structure: paragraph { "type": "paragraph", "body": "markdown" } — body is markdown in and out (bold, italic, links, lists; other markdown flattens to text) header { "type": "header", "body": "plain text" } — a section heading image_gallery { "type": "image_gallery", "title": "optional", "media_ids": [1] } — 1-10 image media video_gallery { "type": "video_gallery", "title": "optional", "media_ids": [2] } — 1-10 video media document { "type": "document", "name": "optional", "media_ids": [3] } — 1-10 document media (pdf, docx, ...); text is extracted for search Read-only types (returned on GET; cannot be created or modified): reference { "type": "reference", "referenced_type": "Handbook", "referenced_id": 3 } — an in-app link to other content gif { "type": "gif", "url": "https://..." } Media: upload via POST /media first (the token also needs write:media), then reference by id. Media must be uploaded by the same token and match the element type (422 otherwise). TRANSLATIONS Text fields read and write the default content locale; a field-keyed translations object carries the other locales and is returned on every read: article "translations": { "title": { "da": "..." }, "subtitle": { "da": "..." } } paragraph "translations": { "body": { "da": "..." } } — values are markdown, like body header "translations": { "body": { "da": "..." } } document "translations": { "name": { "da": "..." } } handbook / course "translations": { "title": { "da": "..." }, "subtitle": { "da": "..." } } — the content's own title/subtitle, on POST/PATCH /handbooks and /courses Merge semantics: locales you omit keep their value. Locale keys must be activated languages (see GET /locales; 422 otherwise). Writing a translation automatically activates that locale on the owning content (handbook, course, ...). Galleries have no translatable text fields. Reads always return the full translations map on these fields, list endpoints included. Default content locale only: chapter title/subtitle in chapter responses and chapter summaries (fetch the chapter's article for translations), and the body convenience field. GET /articles/{id}/markdown?locale= reads one language at a time. BULK SAVE — PUT /articles/{id}/elements The elements array is the complete desired state, in order: - items WITH an element id update that element; items WITHOUT an id create one at that position - writable elements missing from the array are deleted; final order = array order - read-only elements are NEVER deleted — include them by id to position them, or omit them and they keep their place after your items - changing an element's type is rejected (422); omit the id to replace it instead Max 100 elements. The response is the full updated article. { "elements": [ { "id": 501, "type": "header", "body": "First day" }, { "type": "paragraph", "body": "New **markdown** paragraph", "translations": { "body": { "da": "Nyt **markdown** afsnit" } } }, { "id": 503, "type": "image_gallery", "title": "Photos", "media_ids": [123] } ] } Article response (also the GET shape): { "data": { "id": 91, "title": "Getting Started", "subtitle": "First steps", "translations": { "title": { "en": "Getting Started", "da": "Kom godt i gang" }, "subtitle": { "en": "First steps" } }, "elements": [ { "id": 501, "type": "header", "body": "First day", "translations": { "body": { "en": "First day" } } }, { "id": 502, "type": "paragraph", "body": "Hello **world**", "translations": { "body": { "en": "Hello **world**", "da": "Hej **verden**" } } }, { "id": 503, "type": "image_gallery", "title": "Photos", "media": [ { "id": 123, "type": "image", "url": "https://...", "mime_type": "image/jpeg", "filename": "team.jpg", "size": 12345, "width": 1200, "height": 800, "created_at": "..." } ] } ], "created_at": "2026-07-01T09:00:00+00:00", "updated_at": "2026-07-01T09:00:00+00:00" } } Markdown response: { "data": { "id": 91, "title": "Getting Started", "locale": "da", "markdown": "# Getting Started\n\n..." } } ---------------------------------------- EVENTS (scope: read:events, write:events) ---------------------------------------- Events are company calendar events with date, time, location, and optional group targeting. Events are created as DRAFTS by default — pass is_published: true to publish immediately, or publish_at (future ISO 8601 timestamp, takes precedence over is_published) to schedule publication. Drafts and scheduled events are not visible to employees and are excluded from GET /events by default, but remain fetchable via GET /events/{id}. GET /events — List events (published by default, paginated; ?status=draft|all to include drafts) GET /events/{id} — Get a single event (including drafts and scheduled) POST /events — Create an event (draft by default) PUT /events/{id} — Update an event (also publish / unpublish / schedule) DELETE /events/{id} — Delete an event (returns 204) Publishing via update: Publish a draft now: { "is_published": true } Unpublish (back to draft): { "is_published": false } Schedule a draft: { "publish_at": "2026-04-28T08:00:00+00:00" } (event must be a draft — scheduling an already-published event returns 422) Cancel a pending schedule: { "publish_at": null } Create request body: { "title": "Team Building Day", "body": "Join us for a fun day of activities.", "starts_at": "2026-05-01", "starts_time": "09:00", "ends_at": "2026-05-01", "ends_time": "17:00", "location": "Main Office", "groups": [1, 3] } Instead of body, pass "article_id" to attach a pre-built standalone article (see ARTICLES — build first, attach at create). body and article_id are mutually exclusive. Update request body (all fields optional): { "title": "Updated title", "body": "Updated body", "location": "New Venue" } Response: { "data": { "id": 789, "article_id": 92, "title": "Team Building Day", "body": "Join us for a fun day of activities.", "starts_at": "2026-05-01", "starts_time": "09:00", "ends_at": "2026-05-01", "ends_time": "17:00", "location": "Main Office", "author": { "id": 1, "name": "John Doe", "email": "john@example.com" }, "groups": [ { "id": 1, "name": "All Staff", "type": "custom", "created_at": "2026-01-15T10:00:00+00:00" } ], "is_published": true, "comments_count": 0, "completions_count": 0, "scheduled_at": null, "published_at": "2026-05-01T10:00:00+00:00", "created_at": "2026-05-01T10:00:00+00:00", "updated_at": "2026-05-01T10:00:00+00:00" } } ---------------------------------------- CALENDAR ENTRIES (scope: read:calendar_entries, write:calendar_entries) ---------------------------------------- Simple date/time entries that appear in the employee calendar. Automatically published and added to calendar on creation. Body is optional. Entries can be global (no groups) or targeted. Entries can be categorized with an event type via calendar_event_type_id (see CALENDAR EVENT TYPES). When omitted, the published event type named "Default" is used — if that type has been renamed or unpublished, calendar_event_type_id is required (422 otherwise). GET /calendar-entries — List calendar entries (paginated) GET /calendar-entries/{id} — Get a single calendar entry POST /calendar-entries — Create a calendar entry (published by default; is_published: false for a draft) PATCH /calendar-entries/{id} — Update a calendar entry (partial update; PUT accepted as a legacy alias) DELETE /calendar-entries/{id} — Delete a calendar entry (returns 204) Create request body: { "title": "Company Holiday", "starts_at": "2026-06-01", "starts_time": "09:00", "ends_at": "2026-06-01", "ends_time": "17:00", "body": "Optional description", "groups": [1, 3], "calendar_event_type_id": 2 } Instead of body, pass "article_id" to attach a pre-built standalone article (see ARTICLES — build first, attach at create). body and article_id are mutually exclusive. Published by default — pass "is_published": false to create a draft, then publish later via PUT {"is_published": true}. A draft entry is not shown in employee calendars. calendar_event_type_id must reference an event type from GET /calendar-event-types (422 otherwise). Omitting it is allowed only while a published event type named "Default" exists; otherwise it is required. Response: { "data": { "id": 101, "article_id": 93, "title": "Company Holiday", "body": null, "starts_at": "2026-06-01", "starts_time": "09:00", "ends_at": "2026-06-01", "ends_time": "17:00", "calendar_event_type": { "id": 2, "name": "Kunde information", "translations": { "da": "Kunde information", "en": "Customer information" } }, "author": { "id": 1, "name": "John Doe", "email": "john@example.com" }, "groups": [], "created_at": "2026-06-01T10:00:00+00:00", "updated_at": "2026-06-01T10:00:00+00:00" } } ---------------------------------------- CALENDAR EVENT TYPES (scope: read:calendar_event_types, write:calendar_event_types) ---------------------------------------- Event types categorize calendar entries (e.g. "Meeting", "Customer information"). Assign one to a calendar entry via calendar_event_type_id. Types created via the API are immediately available to assign. Types cannot be deleted via the API. Names are translatable: "name" reads and writes the customer's default content locale, and the optional "translations" object holds per-locale names. Locale keys must be languages activated on the platform (see GET /locales; 422 otherwise). GET /calendar-event-types — List event types (paginated) GET /calendar-event-types/{id} — Get a single event type POST /calendar-event-types — Create an event type PATCH /calendar-event-types/{id} — Update an event type (rename, translations) Create request body: { "name": "Kunde information", "translations": { "en": "Customer information" } } Response: { "data": { "id": 2, "name": "Kunde information", "translations": { "da": "Kunde information", "en": "Customer information" }, "created_at": "2026-07-03T10:00:00+00:00", "updated_at": "2026-07-03T10:00:00+00:00" } } ---------------------------------------- LOCALES (scope: read:locales) ---------------------------------------- Read-only. The languages activated on the platform, as a map of locale code to language name — these are the valid locale keys for translatable fields (e.g. calendar event type translations). default_locale is the customer's default content locale: the locale that plain string fields (like "name") read and write. GET /locales — Map of active locale codes to language names Response: { "data": { "da": "Danish", "en": "English" }, "default_locale": "da" } ---------------------------------------- FEEDBACK (no scope required) ---------------------------------------- Send feedback on the Open API straight to the Monotree team — feature requests, bug reports, anything. Any valid token works. POST /feedback — {body! (max 5000 chars)} → 201 Response: { "data": { "id": 1, "type": "open-api", "body": "It would be great if GET /users supported filtering by group.", "created_at": "2026-07-06T10:00:00+00:00" } } ---------------------------------------- USERS (scope: read:users, write:users) ---------------------------------------- Reading returns only registered users (those who accepted an invite). Creating and deleting mirror the CMS user management (write:users): users are created as regular employees and optionally invited to the app; only manually created users can be deleted. Profile pictures can be set for any employee (write:users), e.g. from an HR / WFM system that holds the photos. GET /users — List users (paginated). Filters: ?group_id={id}, ?since={date} (created on/after) GET /users/{id} — Get a single user POST /users — Create a user (returns 201 with the user) DELETE /users/{id} — Soft delete a user (returns 204) POST /users/{id}/avatar — Set the user's profile picture (multipart/form-data, returns 200 with the user) Response: { "data": { "id": 42, "name": "John Doe", "first_name": "John", "last_name": "Doe", "title": "Head of Engineering", "email": "john@example.com", "type": "default", "avatar_url": "https://images.monotree.com/avatars/john-doe.jpg", "is_manager": false, "groups": [ { "id": 1, "name": "Kitchen Staff", "type": "department", "is_manager": false, "created_at": "2026-01-15T10:00:00+00:00" } ], "hired_at": "2025-06-01", "date_of_birth": "1994-08-12", "is_away": false, "last_active_on": "2026-03-22T14:05:12.000000Z", "registered_at": "2025-06-01T09:00:00+00:00", "created_at": "2025-06-01T09:00:00+00:00" } } Field notes: - hired_at is the effective hire date: a manual override set in the CMS wins over the synced value. - date_of_birth is null unless the customer collects it (e.g. via an onboarding field). - title is the employee's job title, set per user in the CMS. It is null unless the customer fills it in, and nothing syncs it from the HR system. The profession group(s) remain the synced position: filter groups[] by type "profession". Departments (type "department") are the organizational/location unit; a user can belong to several. - is_manager (top level) is true when the user manages at least one group. is_manager on each groups[] entry tells which ones. To resolve an employee's manager(s), fetch the managers of their department group(s) via GET /groups. - avatar_url is a direct URL to the user's profile picture, or null. Embedded user objects on posts, comments, announcements and similar resources include the same field. Create request body (POST /users): first_name (string, required) last_name (string, required) email (string, required — lower-cased and trimmed; must not belong to an active user) phone (string, optional) phone_country_code (string, optional — ISO 3166-1 alpha-2, e.g. "NO"; used to format the number for SMS invites) locale (string, optional — one of the platform's languages, see GET /locales) hired_at (date, optional — defaults to today) groups (array of group ids, optional — departments, professions and custom groups; the memberships count as manually added, so admins can change them in the CMS) send_invite (boolean, optional, default false — emails the employee an invite to the app; without it the user exists but is not notified and can be invited from the CMS later) curl -X POST https://customer.monotree.com/api/open/v1/users \ -H "Authorization: Bearer mono_your_token_here" \ -H "Content-Type: application/json" \ -d '{ "first_name": "Jane", "last_name": "Doe", "email": "jane@example.com", "phone": "12345678", "phone_country_code": "NO", "groups": [1], "hired_at": "2026-09-01", "send_invite": true }' Create rules: - The response is the same user object as GET /users/{id}, with registered_at null. A new user only shows up in GET /users after accepting the invite — keep the id from the create response if you need to delete the user before that. - An email already used by an ACTIVE user is rejected with 422 (errors.email). An email belonging to a DELETED user restores that account instead of creating a duplicate: the fields you send are written over it, the response is still 201 and carries the existing id. - Users are always created as type "default" (regular employees) — bots have their own endpoints. Delete rules (DELETE /users/{id}): - Soft delete, same as deleting in the CMS: the user loses access and disappears from the app; creating a user with the same email later restores the account. - Only manually created users can be deleted — a user synced from an external system (HR / rota portal) belongs to that sync and returns 422. Bots, system accounts and already deleted users return 404. Set profile picture (POST /users/{id}/avatar, multipart/form-data): media (image file, required — jpg, png, gif, webp, bmp or svg; the same rule as the app's own profile picture upload) curl -X POST https://customer.monotree.com/api/open/v1/users/42/avatar \ -H "Authorization: Bearer mono_your_token_here" \ -F "media=@./jane.jpg" Profile picture rules: - A plain POST with the file as the multipart body — not a JSON PUT. Returns 200 with the same user object as GET /users/{id}; avatar_url already points at the new picture. - Works for any employee, including invited-but-unregistered and deactivated users. Bots return 404 — set theirs with PUT /bots/{id} (avatar_url). Deleted users return 404. - Replaces whatever picture the user has, including one the employee chose themselves in the app, and deletes the previous file. Colleagues are not notified. ---------------------------------------- GROUPS (scope: read:groups, write:groups) ---------------------------------------- Returns all groups except system groups. Created groups are live immediately (not drafts) and can be targeted. GET /groups — List groups (paginated). Filters: ?type=department|profession|custom, ?since={date} (created on/after) GET /groups/{id} — Get a single group POST /groups — Create a group (write:groups, returns 201). Body: name (required, max 255), type (department|profession|custom, default department), is_open (boolean, custom groups only) Response: { "data": { "id": 1, "name": "Kitchen Staff", "type": "department", "managers": [ { "id": 42, "name": "John Doe" } ], "created_at": "2026-01-15T10:00:00+00:00" } } Field notes: - managers lists the users flagged as manager of the group. It appears on the /groups endpoints only — not when groups are embedded elsewhere (e.g. on a user). - Group types: department (organizational/location unit), profession (job title equivalent, synced from the HR system), custom (free-form). - Names are not unique. Creating "Nørrebro" twice yields two departments. - is_open is only valid on custom groups (employees can join themselves). Sending is_open: true on a department or profession returns 422. ---------------------------------------- SURVEYS (scope: read:surveys) ---------------------------------------- Read-only access to survey templates and the answers collected across their spawns. This release exposes the same data as the CMS Excel export — answers are the focus; full survey/question CRUD is not included. A template defines the survey (questions, schedule, anonymity); each time it runs it produces a spawn (one occurrence, with its own answers). Pass the template id to /surveys/{template_id}/answers to retrieve answers from every spawn of that template in one feed — each row carries its spawn's id and starts_at so you can group client-side. GET /surveys — List survey templates (paginated). Filter: ?category=default|satisfaction|assessment|development GET /surveys/{id} — Get a single survey template GET /surveys/{template_id}/answers — List all answers across every spawn of a template (paginated) GET /surveys/{template_id}/answers/{id} — Get a single answer The answers list is keyed by template id (not spawn id), mirroring the export. Each answer carries its spawn's id and starts_at so you can group client-side. Filters on /surveys/{template_id}/answers: ?since=ISO 8601 — only answers created at or after this time ?until=ISO 8601 — only answers created at or before this time ?spawn_starts_at=YYYY-MM-DD — restrict to a single spawn date ?per_page=N (default 25, max 100 — higher than other resources because export consumers pull bulk) Anonymity: - For surveys with respondent_identity = "anonymous", answers from spawns with fewer than the platform's survey_respondents_threshold (default 3) distinct respondents are suppressed entirely. Matches the privacy contract enforced by the export. - When suppressed, the spawn simply does not appear in results — no error, no marker. - For non-suppressed anonymous answers, "respondent" is null and the underlying user_id is not exposed. Response (/surveys/{template_id}/answers/{id}): { "data": { "id": 9001, "template_id": 42, "spawn": { "id": 1378, "starts_at": "2026-04-01" }, "question": { "id": 17, "title": "Engagement", "body": "How was your week?", "type": "rating", "position": 1 }, "choice": { "id": 88, "body": "Great", "value": 100 }, "body": null, "reason": "lots of customers this week", "is_skipped": false, "is_anonymous": false, "respondent": { "id": 501, "name": "Jane Doe", "email": "jane@example.com", "type": "user", "avatar_url": "https://images.monotree.com/avatars/jane-doe.jpg", ... }, "department": { "id": 12, "name": "Kitchen", "type": "department", "created_at": "..." }, "created_at": "2026-04-01T11:14:33+00:00", "updated_at": "2026-04-01T11:14:33+00:00" } } ---------------------------------------- FORMS + FORM RESPONSES (scope: read:forms, read:formresponses) ---------------------------------------- Read-only access to forms and their responses for external dashboards and analysis. IMPORTANT: whistleblower forms and their responses are completely excluded — never listed, always 404. On anonymous responses, respondent and comment author are null. GET /forms — List forms (read:forms). Filters: ?status=published|draft|all (default published), ?since= (created_at). per_page max 50 GET /forms/{id} — Get a single form (read:forms) GET /forms/{id}/responses — List a form's responses, light payload without comment threads (read:formresponses). Filters: ?status=todo|in_progress|done|null, ?since= (created_at), ?include_deleted=1 (also list deleted/archived responses), ?only_deleted=1 (archive only). per_page max 100 GET /formresponses — Bulk feed across all forms with embedded form + comments (read:formresponses). ?since= filters UPDATED_AT (captures created, edited, status-changed, and newly-commented responses — public and internal comments bump updated_at). ?include_deleted=1 adds deleted (archived) responses so a sync can catch deletions it missed — deleting bumps updated_at, so ?since= picks them up; ?only_deleted=1 returns just the archive. Ordered by updated_at desc. per_page max 100 GET /formresponses/{id} — Single response incl. form + comments (read:formresponses). A deleted response is 404 unless ?include_deleted=1 Form objects carry due_at — the date (YYYY-MM-DD) by which respondents should have answered, null when the form has no due date. It is set per form in the CMS and shown in-app to users who have not yet responded. Every response object (light and detail) mirrors it as form_due_at, and detail payloads also carry it on the embedded form. The ?since= format is ISO 8601, e.g. ?since=2026-05-20T08:00:00Z. Always send an explicit timezone (Z or offset; url-encode + as %2B) — values without one are interpreted in the platform's timezone, and a date-only value like 2026-05-20 means midnight there. Unparseable values return 422. The comparison is inclusive (>=). Form object: { "data": { "id": 12, "title": "Maintenance Request", "description": "Report broken equipment", "instructions": "Fill out all fields", "respondent_identity": "required", // required | optional | anonymous "is_anonymous": false, "is_commentable": true, "allow_multiple_responses": true, "is_published": true, "published_at": "2026-05-01T08:00:00+00:00", "due_at": "2026-06-01", // YYYY-MM-DD, null when the form has no due date "groups": [ { "id": 3, "name": "Kitchen Staff", "type": "department", "created_at": "..." } ], "responses_count": 118, "created_at": "...", "updated_at": "..." } } Response object (light — GET /forms/{id}/responses): { "data": { "id": 4521, "form_id": 12, "form_title": "Maintenance Request", "form_due_at": "2026-06-01", // the parent form's due_at, null when none "is_anonymous": false, "status": "done", // todo | in_progress | done | null "respondent": { ...user object, null when anonymous... }, "assignees": [ ...user objects... ], "department": { "id": 3, "name": "Kitchen Staff", "type": "department" }, "field_responses": [ { "value": "Freezer 2 is above temperature", "field": { "id": 7, "title": "What happened?", "type": "text" } }, { "value": [ { "id": 21, "title": "Urgent" } ], "field": { "id": 8, "title": "Priority", "type": "choices" } } ], "media": [ { "id": 55, "type": "image", "mime_type": "image/jpeg", "filename": "freezer.jpg", "size": 182034, "url": "https://...", "width": 1280, "height": 960, "created_at": "..." } ], "created_at": "...", "updated_at": "...", "deleted_at": null } } choices fields serialize as [{id, title}]; users/department_users as user objects. Hidden responses are never returned. Deleted (archived) responses are excluded by default — pass ?include_deleted=1 on any response endpoint to include them (their deleted_at is set), or ?only_deleted=1 on the listings to get just the archive. Whistleblower and hidden responses stay excluded regardless. Every response object (light and detail) also carries: "assignees": [ ...user objects... ] - the staff members currently assigned to handle the response (empty array when unassigned) "deleted_at": null - always present; set to an ISO 8601 timestamp when the response has been deleted Every response object (light and detail) includes "media" - the files uploaded with the submission. They belong to the response as a whole, not to a specific field. type is image, video or document. url is a direct download link. On anonymous responses the files are stored encrypted, so url is a short-lived signed link (expires after ~5 minutes) - re-fetch the response whenever you need a fresh one. Detail object (GET /formresponses and /formresponses/{id}) adds: "form": { ...full form object... }, "public_comments": [ { "id": 901, "body": "...", "form_response_id": 4521, "form_id": 12, "author": { ...user, null when written by the respondent on an anonymous response... }, "media": [ { ...media object... } ], "created_at": "...", "updated_at": "..." } ], "internal_comments": [ { "id": 33, "body": "...", "form_response_id": 4521, "form_id": 12, "author": { ...user... }, "created_at": "...", "updated_at": "..." } ] public_comments = the thread shared with the respondent (attachments in media); internal_comments = staff-only notes, never visible to the respondent in-app. ---------------------------------------- CERTIFICATES (scope: read:certificates, write:certificates) ---------------------------------------- A certificate is a template issued to employees, optionally with a validity period after which it expires. Categories are managed via Certificate Types. GET /certificates — List certificate templates (paginated) Query params: is_published (true=published only, false=drafts only, omit=all), title (partial match, any locale), since, page, per_page GET /certificates/{id} — Get a single certificate template POST /certificates — Create a certificate template (draft by default) PUT|PATCH /certificates/{id} — Update a certificate template (changes only the fields you send) DELETE /certificates/{id} — Soft delete a certificate template (returns 204) GET /certificates/{id}/users — List holders with their user_state (has_certificate, issued_at, expires_at, is_expired, completables, history) POST /certificates/assign — Assign certificates to users (bulk) POST /certificates/{id}/reset — Reset / expire a certificate for users (returns 204) GET /users/{id}/certificates — List all certificates held by a user GET /users/{id}/certificates/{certificate_id} — Get a single certificate held by a user Create / update request body: title (string, required), description (string, optional), validity_months (integer, optional — months until an issued certificate expires; null = never), reset_days_before_expiry (integer 14-365, optional), certificate_type_id (integer, optional), is_published (boolean, optional — defaults to false / draft). PUT and PATCH accept the same fields, all optional. Assign request body (mirrors the in-app bulk assignment; users already holding an active certificate are skipped): { "assignments": [ { "certificate_id": 12, "users": [ { "user_id": 501, "completed_at": "2026-06-01" }, { "user_id": 502, "completed_at": "2026-06-01" } ] } ] } Returns 204. Each user is issued the certificate as of their completed_at; expiry is derived from the template's validity_months. Reset request body (revoke a certificate for users — history is preserved, not hard-deleted): { "user_ids": [501], "expire": true, "notify": false } user_ids (optional, defaults to all holders), expire (optional — pull any future expiry back to now), notify (optional — only with expire) Returns 204. Response (GET /certificates/{id}): { "data": { "id": 12, "article_id": 94, "title": "Food Safety", "description": "Awarded on completion of the food safety course.", "validity_months": 12, "reset_days_before_expiry": 30, "active_users_count": 14, "is_published": true, "certificate_type": { "id": 3, "title": "Compliance", "is_published": true, "published_at": "2026-05-01T10:00:00+00:00", "created_at": "2026-05-01T10:00:00+00:00", "updated_at": "2026-05-01T10:00:00+00:00" }, "published_at": "2026-06-01T10:00:00+00:00", "created_at": "2026-06-01T10:00:00+00:00", "updated_at": "2026-06-01T10:00:00+00:00" } } Response (GET /users/{id}/certificates) — certificates a user holds, each with issuance dates and earlier issuances under history: { "data": [ { "id": 12, "title": "Food Safety", "description": "Awarded on completion of the food safety course.", "validity_months": 12, "issued_at": "2026-06-01T00:00:00+00:00", "expires_at": "2027-06-01T00:00:00+00:00", "is_expired": false, "certificate_type": { "id": 3, "title": "Compliance", "is_published": true }, "history": [] } ] } ---------------------------------------- CERTIFICATE TYPES (scope: read:certificatetypes, write:certificatetypes) ---------------------------------------- A certificate type is a lightweight category that certificates can be grouped under (e.g. "Compliance", "Safety"). GET /certificatetypes — List certificate types (paginated) Query params: is_published (true=published only, false=drafts only, omit=all), since, page, per_page GET /certificatetypes/{id} — Get a single certificate type POST /certificatetypes — Create a certificate type (draft by default) PUT|PATCH /certificatetypes/{id} — Update a certificate type DELETE /certificatetypes/{id} — Soft delete a certificate type (returns 204) Create / update request body: title (string, required), is_published (boolean, optional — defaults to false / draft). Response: { "data": { "id": 3, "title": "Compliance", "is_published": true, "published_at": "2026-06-01T10:00:00+00:00", "created_at": "2026-06-01T10:00:00+00:00", "updated_at": "2026-06-01T10:00:00+00:00" } } ---------------------------------------- JOURNEYS (scope: read:journeys) ---------------------------------------- A journey is a sequence of content (courses, handbooks, forms, checklists) employees work through: the onboarding flow, an optional offboarding flow, and custom journeys (e.g. a leadership track). Journeys are assigned through the groups their content is targeted at, optionally gated by a days-after-hire trigger. Aggregated completion totals are under STATISTICS (GET /stats/academy/journeys); these endpoints give the per-user status. GET /journeys — List journeys (paginated) Query params: is_published (true=published only, false=drafts only, omit=all), type (onboarding | custom | offboarding), since, page, per_page Ordered onboarding first, then custom journeys in their cms order, then offboarding GET /journeys/{id} — Get a single journey GET /journeys/{id}/users — Every user the journey is assigned to, with their user_state (not paginated; a draft journey returns an empty list) GET /users/{id}/journeys — The published journeys assigned to a user, with the status fields flattened onto each journey (404 for kiosk / bot / shadow / deactivated users) Who is assigned: real, active employees only (no kiosk, bot, shadow, deactivated, unregistered or already-terminated users) — the same pool /stats/academy/journeys counts. Onboarding: every such employee. Custom and offboarding journeys: employees who can access at least one of the journey's items, restricted by the journey's own groups if it has any, and whose days-after-hire trigger date has been reached. Status fields (user_state on /journeys/{id}/users, flattened on /users/{id}/journeys): has_completed (boolean) — the user has completed the journey assigned_at — when the journey became available to the user. Onboarding: registration (or the latest onboarding reset). Offboarding: the termination date. Other journeys: the later of the journey's publish date and the day it started applying to the user — their days-after-hire trigger date, else their hire date (registration if no hire date) due_at — the deadline, if the journey has one. Onboarding: assigned_at + the onboarding weeks limit; offboarding: termination date + the offboarding weeks limit (both cms settings). null when no limit is configured, and always null for custom journeys completed_at — when the user completed the journey, or null is_overdue — true once due_at has passed without a completion Journey fields: id, title, description, type (onboarding | custom | offboarding), is_locked (sequential — items must be completed in order), days_after_hire (when set, the journey only becomes available this many days after the employee's hire date), is_published, published_at, created_at, updated_at. Response (GET /journeys/{id}): { "data": { "id": 7, "title": "Leadership Track", "description": "Everything a new shift lead needs in the first month.", "type": "custom", "is_locked": false, "days_after_hire": 30, "is_published": true, "published_at": "2026-06-01T10:00:00+00:00", "created_at": "2026-05-20T09:00:00+00:00", "updated_at": "2026-06-01T10:00:00+00:00" } } Response (GET /journeys/{id}/users): { "data": [ { "id": 501, "name": "Jane Doe", "email": "jane@example.com", "avatar_url": null, "user_state": { "has_completed": false, "assigned_at": "2026-08-01T09:00:00+00:00", "due_at": "2026-08-15T09:00:00+00:00", "completed_at": null, "is_overdue": true } }, { "id": 502, "name": "Alex Doe", "email": "alex@example.com", "avatar_url": null, "user_state": { "has_completed": true, "assigned_at": "2026-08-03T08:00:00+00:00", "due_at": "2026-08-17T08:00:00+00:00", "completed_at": "2026-08-10T14:22:00+00:00", "is_overdue": false } } ] } Response (GET /users/{id}/journeys): { "data": [ { "id": 3, "title": "Onboarding", "description": null, "type": "onboarding", "is_locked": true, "days_after_hire": null, "is_published": true, "published_at": "2025-01-10T08:00:00+00:00", "created_at": "2025-01-10T08:00:00+00:00", "updated_at": "2026-05-02T11:30:00+00:00", "has_completed": false, "assigned_at": "2026-08-01T09:00:00+00:00", "due_at": "2026-08-15T09:00:00+00:00", "completed_at": null, "is_overdue": true }, { "id": 7, "title": "Leadership Track", "description": "Everything a new shift lead needs in the first month.", "type": "custom", "is_locked": false, "days_after_hire": 30, "is_published": true, "published_at": "2026-06-01T10:00:00+00:00", "created_at": "2026-05-20T09:00:00+00:00", "updated_at": "2026-06-01T10:00:00+00:00", "has_completed": false, "assigned_at": "2026-08-31T00:00:00+00:00", "due_at": null, "completed_at": null, "is_overdue": false } ] } ---------------------------------------- STAFF CARDS (scope: read:staff_cards, write:staff_cards) ---------------------------------------- Staff cards are informational cards about the company or individual employees, shown in the employee app. They can also be used as bonuses or coupons that employees redeem — typically with a QR code and an expiry date. A card is targeted via groups (departments, professions and custom groups), via individually assigned users, or shown to everyone when neither is set. Rich content lives on the card's article (see ARTICLES) — the body convenience field covers simple text-only cards. Cards come in two types: "regular" (the default) and "personal". A personal card belongs to exactly one user (e.g. a digital business card) — it must target exactly ONE user via users and can never target groups. Deleting that user also deletes the card. GET /staff-cards — List staff cards (paginated) Query params: status (published (default) | draft | all), type (regular | personal), since, page, per_page (max 50) Note: expired cards stay in the list — expiry only hides cards in the employee app. GET /staff-cards/{id} — Get a single staff card POST /staff-cards — Create a staff card (draft by default — pass is_published: true to publish immediately) PUT|PATCH /staff-cards/{id} — Update a staff card (changes only the fields you send) DELETE /staff-cards/{id} — Soft delete a staff card (returns 204) Create request body: title (string, required — written to the default content locale) body (string, optional — plain text, becomes a single article paragraph; cannot be combined with article_id) article_id (integer, optional — attach a pre-built standalone article created via POST /articles) translations (object, optional — { "title": { "": "..." } }; writing a translation activates that locale on the card; locale keys must be active on the platform) groups (array of group ids, optional — departments, professions and custom groups can be mixed in one array) users (array of user ids, optional — individually assigned users) type (string, optional — "regular" (default) or "personal"; personal cards require exactly one entry in users and an empty groups) qr_data (string, optional, max 2048 — personal cards only: the platform generates a QR code image from this value (e.g. a vCard link) and attaches it as media tagged "qr_code"; null clears it and removes the generated image) media_ids (array, optional, max 1 — an uploaded image rendered as a QR code in the bottom-right corner of the card; requires the write:media scope and media uploaded by this token. An uploaded QR image and qr_data are alternatives — use one or the other) expires_date (date, optional) and expires_time (HH:MM, optional — after this moment the card disappears from the app) is_visible_for_managers (boolean, optional — also show the card to managers viewing the card owner's profile) is_published (boolean, optional — defaults to false / draft; publishing notifies targeted employees) Updates accept the same fields, all optional. users replaces the full assignment list ([] clears it); expires_date and expires_time accept null to clear. Once a card's article carries rich content, body writes are rejected (422) — use PUT /articles/{id}/elements instead. When switching a card to personal, the RESOLVED state is validated: the card must end up with exactly one user and no groups (clear groups in the same request if needed). Response (GET /staff-cards/{id}): { "data": { "id": 12, "article_id": 240, "title": "Fire safety officer", "translations": { "title": { "en": "Fire safety officer", "da": "Brandvagt" } }, "body": "Contact reception for the fire safety plan.", "author": { "id": 8, "name": "Monotree", "type": "customer" }, "groups": [], "users": [ { "id": 501, "name": "Jane Doe", "email": "jane@example.com" } ], "media": [ { "id": 77, "type": "image", "url": "https://cdn.example.com/qr.png", "tag": "qr_code" } ], "is_published": true, "is_visible_for_managers": false, "type": "personal", "qr_data": "https://example.com/vcard/jane", "expires_date": "2026-12-31", "expires_time": "17:00", "published_at": "2026-07-01T10:00:00+00:00", "created_at": "2026-07-01T10:00:00+00:00", "updated_at": "2026-07-01T10:00:00+00:00" } } ---------------------------------------- ORG CHART (scope: read:org_chart) ---------------------------------------- The organizational chart is curated by admins in the CMS: people are placed by hand (with an optional title such as "CEO") and whole departments can be dropped in as sections that list their members automatically. Requires the org chart feature to be enabled for the company (403 otherwise). GET /org-chart — The whole chart as a nested tree, exactly as employees see it Each box is either type "person" or "department"; direct reports sit under children (in CMS order). Person boxes embed the same user object as /users (id, name, email, avatar_url, groups, ...). headcount = everyone below a box (reports and department members), department members included. Deactivated or terminated people and unpublished departments are left out; their reports move up one level. Person title = the job title on the user profile, falling back to the title typed on the box, then the profession name. Department boxes carry members_count (active members not placed elsewhere in the chart) — list the people with GET /users?group_id=. Drafts are returned too: meta.is_published tells whether employees can see the chart yet. Response (GET /org-chart): { "data": [ { "id": 1, "type": "person", "title": "CEO", "custom_title": "CEO", "headcount": 13, "user": { "id": 11, "name": "John Doe", "email": "john@example.com", "avatar_url": "https://...", "is_manager": true, "groups": [ { "id": 41, "name": "Kitchen", "type": "department", "is_manager": true } ], ... same object as /users }, "department": null, "children": [ { "id": 2, "type": "department", "title": "Kitchen", "custom_title": null, "headcount": 12, "user": null, "department": { "id": 41, "name": "Kitchen", "members_count": 12 }, "children": [] } ] } ], "meta": { "is_published": true, "published_at": "2026-08-28T10:00:00+00:00" } } ---------------------------------------- ACHIEVEMENTS (scope: read:achievements, write:achievements) ---------------------------------------- Achievements are badges employees earn or are given: employee of the month, a completion badge for finishing linked content (courses, handbooks, journeys), an employment badge after a number of months of tenure, or a custom badge handed out by hand. Each achievement has a type, an emoji, a visibility (public — celebrated with department colleagues in the app — or personal, shown only to the employee and their managers) and, for the automatic types, a trigger config. An award is one achievement given to one user on a date; repeat awards (employee of the month twice) are separate awards. Requires the achievements feature to be enabled for the company. GET /achievements — List achievements (paginated) Query params: is_published (true=published only, false=drafts only, omit=all), type (employee_of_the_month | completion | employment | custom), visibility (public | personal), since (filters by created_at), page, per_page (max 50) Ordered newest first GET /achievements/{id} — Get a single achievement POST /achievements — Create an achievement (draft by default) PUT|PATCH /achievements/{id} — Update an achievement (changes only the fields you send) GET /achievements/{id}/awards — Every award of the achievement, newest first (paginated; since filters by the award's created_at). Awards of deleted users are left out POST /achievements/{id}/awards — Award the achievement to users (scope write:achievements) GET /users/{id}/achievements — The awards a user holds on published achievements, newest first (not paginated; 404 for kiosk / bot / shadow users) Personal achievements are included everywhere — a token is an organisation-level integration — with visibility on each item so integrations can honour it before showing an award to colleagues. Drafts are returned by the achievement endpoints (is_published tells); /users/{id}/achievements only lists awards on published achievements, which is what the employee sees in-app. Achievements and awards cannot be deleted through the API. Create body (POST /achievements, scope write:achievements): type (required: employee_of_the_month | completion | employment | custom — cannot be changed later) title (string, required except for employee_of_the_month, which is named "Employee of the month" in every active language when omitted; written in the default content language) description (string, optional) emoji (string, optional, max 16 — defaults: employee_of_the_month 👑, completion 🏆, employment ⭐, custom 🤩) visibility (public | personal, optional — defaults to public for employee_of_the_month and employment, personal for completion and custom) trigger_config (object, completion and employment only; 422 on the other types): completion {completable_type: handbook | course | journey, completable_ids: [...]} where every id is a published handbook or course, or a published onboarding or custom journey (not offboarding); employment {months: integer >= 1} translations (optional: {title: {locale: ...}, description: {locale: ...}}; locales must be active languages, writing one activates it on the achievement; the title in the default content language cannot be emptied here) is_published (boolean, optional — defaults to false / draft) Returns 201 with the achievement object. A trigger_config that is sent has to be complete (422 otherwise). Publishing a completion or employment achievement without a complete trigger_config returns 422 and nothing is created or changed. Once published, employees who meet the trigger are awarded it: completion when they complete the linked content, employment on the daily check once they have been employed that many months (every active employee already past the tenure on the next check). Each award sends the employee a push notification, shows public awards to department colleagues and fires monotree.achievement.awarded. Update (PUT|PATCH /achievements/{id}): the same fields, all optional; only what is sent changes. title and description are written in the default content language; translations merge (locales left out keep their text). Sending a different type returns 422 (the current type is accepted, so a read achievement can be sent back). is_published toggles the published state; a published achievement's trigger_config has to stay complete, so clearing it returns 422. 404 for an unknown id. Award body (POST /achievements/{id}/awards, scope write:achievements): awards (array, required, at least one row), each row: user_id (integer, required — an employee from /users; kiosk, bot and unregistered users return 422) awarded_at (date, required — the day the award is for, e.g. 2026-09-01; a time sent along is dropped; can be back-dated) department_id (integer — must be one of the user's departments; required for employee_of_the_month, optional otherwise) The achievement must be published (a draft returns 422 — an award on a draft would never be announced). Returns 201 with one award object per row, in the order sent (the GET /achievements/{id}/awards object with user embedded). assigned_by is always the company's own user (type customer, named after the company), whichever user the token acts as, so employees see the award as given by the company; on a platform without a company user it is the system user (type system). All or nothing: an invalid row returns 422 keyed by row (e.g. awards.1.user_id) and nobody is awarded. A row identical to an existing award (same user and awarded_at) returns that award instead of creating another; the same user on another day is a new award. New awards on a published achievement are announced like CMS awards (push notification to the employee, public awards shown to department colleagues, monotree.achievement.awarded webhook). Achievement fields: id, title, description, translations ({title: {locale: ...}, description: {locale: ...}}), emoji, type, visibility, trigger_type (completion | tenure | null for the manual types), trigger_config (completion: {completable_type: handbook | course | journey, completable_ids: [...]}; tenure: {months}; null otherwise), awarded_users_count (number of awards), is_published, published_at, created_at, updated_at. Award fields: id, awarded_at, is_manually_added, department (the department the award was given in, e.g. employee of the month for Copenhagen — null when none was chosen), assigned_by (the CMS user who handed it out, the same object as /users — the company's own user, type customer, for awards given through the API; null for automatic awards), achievement (embedded on /users/{id}/achievements and in the webhook), user (embedded on /achievements/{id}/awards and in the webhook), created_at, updated_at. awarded_at is the day the award is for, as midnight in the customer server's timezone with its offset (2026-08-01T00:00:00+02:00): the day (or month, for employee of the month) picked in the CMS, or the employee's own local day for automatic awards. Manual awards can be back-dated. Read the date in the offset given rather than converting to UTC first, or the day can shift. created_at and updated_at are the instants the award was recorded and last changed; use those (and since) for syncing. Response (GET /achievements/{id}): { "data": { "id": 12, "title": "Employee of the month", "description": "Chosen by the department managers.", "translations": { "title": { "en": "Employee of the month", "da": "Månedens medarbejder" }, "description": { "en": "Chosen by the department managers." } }, "emoji": "👑", "type": "employee_of_the_month", "visibility": "public", "trigger_type": null, "trigger_config": null, "awarded_users_count": 37, "is_published": true, "published_at": "2026-06-01T10:00:00+00:00", "created_at": "2026-05-20T09:00:00+00:00", "updated_at": "2026-06-01T10:00:00+00:00" } } Response (GET /achievements/{id}/awards): { "data": [ { "id": 4711, "awarded_at": "2026-08-01T00:00:00+02:00", "is_manually_added": true, "department": { "id": 41, "name": "Copenhagen", "type": "department" }, "assigned_by": { "id": 5, "name": "Sam Doe", "email": "sam@example.com", "avatar_url": null, "is_manager": true, "groups": [ ... ], ... }, "user": { "id": 501, "name": "Jane Doe", "email": "jane@example.com", "avatar_url": null, "is_manager": false, "groups": [ { "id": 41, "name": "Copenhagen", "type": "department", "is_manager": false } ], ... }, "created_at": "2026-08-03T09:12:41+00:00", "updated_at": "2026-08-03T09:12:41+00:00" }, { "id": 4690, "awarded_at": "2026-07-01T00:00:00+02:00", "is_manually_added": true, "department": { "id": 42, "name": "Aarhus", "type": "department" }, "assigned_by": { "id": 5, "name": "Sam Doe", "email": "sam@example.com", ... }, "user": { "id": 502, "name": "Alex Doe", "email": "alex@example.com", ... }, "created_at": "2026-07-02T08:00:00+00:00", "updated_at": "2026-07-02T08:00:00+00:00" } ], "meta": { "current_page": 1, "per_page": 25, "total": 37 } } Response (GET /users/{id}/achievements): { "data": [ { "id": 4711, "awarded_at": "2026-08-01T00:00:00+02:00", "is_manually_added": true, "department": { "id": 41, "name": "Copenhagen", "type": "department" }, "assigned_by": { "id": 5, "name": "Sam Doe", "email": "sam@example.com", ... }, "achievement": { "id": 12, "title": "Employee of the month", "emoji": "👑", "type": "employee_of_the_month", "visibility": "public", "awarded_users_count": 37, "is_published": true, ... }, "created_at": "2026-08-03T09:12:41+00:00", "updated_at": "2026-08-03T09:12:41+00:00" }, { "id": 3980, "awarded_at": "2026-07-14T09:30:12+02:00", "is_manually_added": false, "department": null, "assigned_by": null, "achievement": { "id": 8, "title": "Food safety certified", "emoji": "🏆", "type": "completion", "visibility": "personal", "awarded_users_count": 112, "is_published": true, ... }, "created_at": "2026-07-14T07:30:12+00:00", "updated_at": "2026-07-14T07:30:12+00:00" } ] } ---------------------------------------- STATISTICS (scope: read:stats) ---------------------------------------- Platform analytics and usage metrics. All responses are cached server-side. GET /stats/users — User counts and activity summary (cached 1h) GET /stats/engagement — Engagement time series (cached 24h) Query params: granularity (day|week|month, default: week), scope (number of periods, default: 12), group_ids (comma-separated) GET /stats/adoption — Adoption rate by department (cached 1h) Query params: group_ids (comma-separated, optional) GET /stats/content — Content activity this week vs last week (cached 1h) Response (/stats/users): { "data": { "total": 1250, "registered": 1100, "active_yesterday": 340, "active_7d": 780, "active_30d": 1050, "new_yesterday": 5, "new_7d": 32, "new_30d": 95 } } Response (/stats/engagement?granularity=week&scope=4): { "data": [ { "year": 2026, "week": 10, "total_users": 1250, "app_users": 1100, "active_users": 340, "app_time": 52800, "sessions": 890 } ] } Response (/stats/adoption): { "data": { "adoption_rate": { "average": 72.5, "departments": [ { "id": 1, "name": "Kitchen", "active": 18, "not_active": 7, "total_employees": 25, "average": 72.0 } ] } } } Response (/stats/content): { "data": { "posts": { "this_week": 45, "last_week": 38, "total": 1250 }, "comments": { "this_week": 120, "last_week": 95, "total": 4800 }, "reactions": { "this_week": 230, "last_week": 210, "total": 8900 }, "chat_messages": { "this_week": 560, "last_week": 480, "total": 22000 }, "todo_tasks_completed": { "this_week": 85, "last_week": 72, "total": 3400 } } } Academy statistics (mirror the CMS academy figures; kiosk users always excluded): GET /stats/academy/onboarding — Onboarding completion totals Query params: group_ids (comma-separated, optional) GET /stats/academy/courses — Completion rates per published course Query params: group_ids (comma-separated, optional) GET /stats/academy/journeys — Completion totals per published journey (onboarding included) Query params: group_ids (comma-separated, optional) GET /stats/academy/quizzes/{quiz_id} — Per-question quiz performance (respondents, passed, pass_rate) GET /stats/academy/users/{user_id}/courses — A single user's course progress (has_passed, completed_at, chapters_completed_count, awaits_manual_completion) GET /stats/academy/users/{user_id}/onboarding — A single user's onboarding status (onboarding_completed_at, is_overdue, onboarding_days) Per-user endpoints resolve active employees only — kiosk/bot/shadow/banned users return 404. Response (/stats/academy/onboarding): { "data": { "completed": 312, "not_completed": 88 } } Response (/stats/academy/courses): { "data": [ { "id": 12, "title": "Food Safety Basics", "completed": 210, "not_completed": 90 } ] } ---------------------------------------- MANAGER HUB (scope: read:managerhub) ---------------------------------------- Department-level reporting: the team roster and onboarding / journey / course / certificate progress for one or more departments. Requires the manager hub feature to be enabled for the company (403 otherwise). Every endpoint requires ?department_ids= (comma-separated department group ids, e.g. 12 or 12,40). Figures are pooled across the given departments; a member of several is counted once. Non-numeric → 422. GET /managerhub/team — Roster: every member with status (active | inactive = no activity for 21+ days | invited), last_active_at, next_shift_at, seniority_months, engagement_pct (share of the last 30 days active, null without tracking) + stats.engagement { department_pct, org_baseline_pct } and stats.seniority { department_avg_months, company_avg_months } GET /managerhub/progress/summary — stats.overall_completion { department_pct, org_baseline_pct }, stats.onboarding_time { department_avg_days, company_avg_days }, then users / onboarding / journeys / courses / certificates each as { completed_count, total_count, completed_pct } GET /managerhub/progress/onboarding — completed_pct, completed_user_count, total_user_count, users[] with completed_count, total_count, is_completed, time_spent_days (completed) or days_overdue + days_used (still onboarding) GET /managerhub/progress/journeys — per published journey assigned to the department(s): id, title, completed_user_count, total_user_count, completed_pct GET /managerhub/progress/courses — same shape per published course (completed once all chapters are passed) GET /managerhub/progress/certificates — same shape per certificate unlocked by a department course (holder counts) Response (GET /managerhub/team?department_ids=12): { "data": { "members": [ { "id": 4821, "first_name": "Jane", "last_name": "Doe", "full_name": "Jane Doe", "avatar_url": "https://…", "status": "active", "last_active_at": "2026-06-18T07:12:00+00:00", "next_shift_at": "2026-06-20T08:00:00+00:00", "seniority_months": 14, "engagement_pct": 73 } ], "stats": { "engagement": { "department_pct": 64.0, "org_baseline_pct": 61.0 }, "seniority": { "department_avg_months": 11.0, "company_avg_months": 9.5 } } } } ---------------------------------------- BOTS (scope: read:bots, write:bots) ---------------------------------------- Bot users are non-human senders used for direct messages, wall posts and incoming webhooks. Create a bot once, then reference it as the sender when sending direct messages, as the author (bot_user_id) when creating a wall post, or when creating an incoming webhook. GET /bots — List bot users (paginated) GET /bots/{id} — Get a single bot user POST /bots — Create a bot user PUT /bots/{id} — Update a bot user (name, avatar_url) Create body: { "name": "Notification Bot", "avatar_url": "https://example.com/bot.png" } (avatar_url is optional and defaults to the customer app icon.) Response: { "data": { "id": 873, "name": "Notification Bot", "avatar_url": "https://example.com/bot.png", "created_at": "2026-06-13T10:00:00+00:00" } } ---------------------------------------- DIRECT MESSAGES (scope: write:chat_messages) ---------------------------------------- Send a direct message to a single user's private chat, delivered by a bot user. The 1:1 room between the bot and the user is created on first use and reused afterwards. Resolve the target user via the Users endpoints (e.g. match by email). POST /users/{id}/messages — Send a direct message to the user POST /chat-messages/{id}/pin — Pin a bot-sent message at the top of its room DELETE /chat-messages/{id}/pin — Remove the pin Body: { "bot_user_id": 873, "text": "Hi Alex, Sam has replied to your thread. Follow up before Thursday: https://example.com/threads/123", "media": [ { "url": "https://example.com/photo.jpg", "type": "image" } ] } (media is optional and follows the same rules as incoming webhooks — one type per message, max 10, publicly reachable URLs.) Response: { "data": { "id": 5512, "room_id": 240, "text": "Hi Alex, ...", "bot_user_id": 873, "pinned_at": null, "pinned_until": null, "created_at": "2026-06-13T10:00:00+00:00" } } Notes: - bot_user_id must be a bot user (create one via POST /bots). A non-bot sender → 422. - The target must be a regular, registered user (not a kiosk or bot), otherwise 404. Pin a message: Pin one of the bot's messages at the top of its room. Every member sees it above the conversation in the app and the CMS until it is unpinned or lapses. POST /chat-messages/5512/pin { "pinned_until": "2026-09-01T08:00:00+00:00" } (pinned_until is optional — omit it for a pin that stays until removed. When given it must be in the future, otherwise 422.) DELETE /chat-messages/5512/pin — Remove the pin (a no-op on a message that isn't pinned) Both return the message in the shape above, with pinned_at (and pinned_until when set) filled in while the pin is active. Notes: - Only messages authored by a bot user can be pinned: direct messages always are, incoming webhook messages are when the webhook posts as its bot. Any other message id → 404. - A room holds one pinned message. Pinning another message in the same room replaces the current pin. - The pin is attributed to the sending bot, so the clients show it as pinned by the bot. ---------------------------------------- NOTIFICATIONS (scope: write:notifications) ---------------------------------------- Put a notification in employees' activity feed in the app, targeted by group, by user, or both. Every recipient gets their own notification. Feed only: no push notification is sent. When a url is included, tapping the notification opens it. POST /notifications — Send a notification to the activity feed of groups and users Body: { "title": "Your shift on Friday changed", "body": "You now start at 08:00. Open the rota to confirm.", "groups": [12, 40], "users": [4012], "url": "https://rota.example.com/shifts/991", "source": "Rota" } Fields: - title (required): the main line. Max 80 characters, one line. - body (required): the text under the title. Max 255 characters. - groups: group IDs. Required unless users is sent. - users: user IDs. Required unless groups is sent. - url (optional): opened on tap. Max 100 characters. An https:// link (opens in the browser) or an app link such as someapp://shifts/991 (handed to the app that owns the scheme). http://, javascript:, data:, file: and similar schemes, mailto:, tel:, links without a scheme, and links with spaces, quotes or < > → 422. Without a url the notification can't be tapped. - source (optional): name of the sending system, e.g. "Rota". Max 20 characters, one line. Title and body are shown as plain text (no Markdown or HTML). Title, url and source are stored together, and quotes and backslashes take extra room: with many of them and every field near its limit, the request can be rejected with 422 on title even though each field is within its own limit. Who receives it: - groups: an employee must be in at least one of the listed groups of each group type sent. Two departments reach people in either; a department and a profession reach only people in both. - users: exactly the listed users, whatever their groups. - both: everyone matched by groups plus everyone in users; an employee matched both ways gets one notification. - Deactivated employees, employees who haven't registered yet, and bots are skipped. Employees who have turned notifications off don't get it. An unknown or deleted group or user ID → 422. Response: { "data": { "recipients": 42 } } recipients is the number of employees matched. The notifications are created in the background right after the response. Employees who have turned notifications off are counted but don't get one. With 0 recipients nothing is sent. Notes: - Every request sends: sending the same notification twice gives every recipient two notifications. Only retry a request that failed. - Notifications can't be edited or deleted through the API. ======================================== PAGINATION ======================================== All list endpoints support ?page= and ?per_page= (max 50, default 25). All list endpoints support ?since= (ISO 8601 date/datetime) to filter to records created after a given timestamp. Useful for incremental sync. List endpoints on announcements, events, calendar entries, posts and surveys support ?status= (published|draft|all, default: published) to control publication-state filtering. An unrecognised value returns 422. Single-item endpoints are unaffected. Response format for list endpoints: { "data": [ ... ], "links": { "first": "https://customer.monotree.com/api/open/v1/walls/1/posts?page=1", "last": "https://customer.monotree.com/api/open/v1/walls/1/posts?page=4", "prev": null, "next": "https://customer.monotree.com/api/open/v1/walls/1/posts?page=2" }, "meta": { "current_page": 1, "last_page": 4, "per_page": 25, "total": 92, "from": 1, "to": 25 } } To fetch all pages, follow links.next until it is null. ======================================== INCOMING WEBHOOKS ======================================== Incoming webhooks let external systems send messages into Monotree chat rooms or create posts on walls. No bearer token needed — the webhook token is embedded in the URL. POST https://customer.monotree.com/api/open/v1/incoming/{token} The payload is the same for all actions — just a "text" field: { "text": "Your message here" } Actions: chat_message (send to a chat room), direct_message (send to a single user's private chat), wall_post (create a post on a wall), calendar_entry (add to the calendar). For chat_message, direct_message and wall_post, the payload is a simple "text" field. For calendar_entry, the payload is: { "title": "...", "starts_at": "YYYY-MM-DD", "starts_time": "HH:MM", "ends_at": "YYYY-MM-DD", "ends_time": "HH:MM", "calendar_event_type_id": 2 } (only title and starts_at are required; calendar_event_type_id is required when there is no published event type named "Default" to fall back on). direct_message is delivered to one user's private chat by a bot user. It is configured with a target user id and always requires a bot user as the sender. Create and manage these webhooks at scale via the incoming-webhook management API (below) or the CMS Incoming Webhooks tab. Attaching media on chat_message and wall_post: Incoming webhooks can include images, videos, or documents by passing a "media" array of URL + type pairs. Because webhooks have no bearer token, the two-step upload + media_ids flow used by the regular API does not apply — instead, Monotree fetches each URL and creates the Media records server-side. { "text": "Image post from webhook", "media": [ { "url": "https://example.com/photo.jpg", "type": "image" }, { "url": "https://example.com/report.pdf", "type": "document" } ] } Rules: - wall_post may mix image / video / document. chat_message and direct_message take one type per payload; mixed → 422. - Maximum 10 items per payload. - URLs must be publicly reachable from Monotree. Failed downloads → 422 with a descriptive error. - Per-URL download timeout: 15 seconds. Example: curl -X POST https://customer.monotree.com/api/open/v1/incoming/YOUR_TOKEN \ -H "Content-Type: application/json" \ -d '{"text": "Alert: Kitchen cooler temperature is 12°C"}' Example with image: curl -X POST https://customer.monotree.com/api/open/v1/incoming/YOUR_TOKEN \ -H "Content-Type: application/json" \ -d '{"text": "See photo", "media": [{"url": "https://example.com/cooler.jpg", "type": "image"}]}' ======================================== INCOMING WEBHOOK MANAGEMENT ======================================== Programmatically create and manage incoming webhooks — the same hooks configured in the CMS Incoming Webhooks tab. Useful for provisioning per-user direct_message webhooks at scale. Scopes: read:incoming_webhooks (list/read), write:incoming_webhooks (create/update/revoke). GET /incoming-webhooks — List the incoming webhooks created by your token (paginated) GET /incoming-webhooks/{id} — Get a single incoming webhook POST /incoming-webhooks — Create an incoming webhook PUT /incoming-webhooks/{id} — Update an incoming webhook (name, config, is_active, bot) POST /incoming-webhooks/{id}/revoke — Revoke an incoming webhook (permanent) Create body (direct message to a user): { "name": "Reply reminder for Alex", "action": "direct_message", "config": { "user_id": 4012 }, "bot_user_id": 873 } bot_user_id is optional — a bot user is created automatically if omitted. direct_message always requires a bot. The other actions are supported too: chat_message ({ "room_id": ... }), wall_post ({ "wall_id": ... }), calendar_entry. Response (the trigger url + token are returned ONCE on create and cannot be retrieved again): { "data": { "id": 47, "name": "Reply reminder for Alex", "action": "direct_message", "config": { "user_id": 4012 }, "is_active": true, "use_bot_user": true, "bot_user": { "id": 873, "name": "Notification Bot", "avatar_url": "..." }, "token_hint": "a1b2", "url": "https://customer.monotree.com/api/open/v1/incoming/xxxxxxxxxxxx", "token": "xxxxxxxxxxxx", "created_at": "2026-06-13T10:00:00+00:00" } } Incoming webhooks are scoped to the token that created them — you cannot read or change another token's (or the CMS's) webhooks. Removing a webhook is a revoke (the url stops working); history is preserved. ======================================== OUTGOING WEBHOOKS ======================================== Outgoing webhooks send event notifications to your URL when things happen in Monotree. Available events: - monotree.post.created — A post was created - monotree.comment.created — A comment was added to a post - monotree.comment.updated — A comment on a post was edited - monotree.comment.deleted — A comment on a post was deleted - monotree.announcement.published — An announcement was published - monotree.formresponse.created — A form response was submitted - monotree.formresponse.updated — A form response was updated. Fires when the response's status, deadline_at, or is_hidden flag changes, when assignees are added or removed, and when the response is deleted, restored, or permanently deleted — check the payload's deleted_at (always present, null while the response exists) to detect deletions. Does NOT fire on initial creation (use formresponse.created) or for comments (use formresponse.comment.created). Filterable by form. Whistleblower forms never dispatch. - monotree.formresponse.comment.created — A public comment was added to a form response. Internal staff notes do not trigger webhooks. The author is null when the comment was written by the respondent on an anonymous form. Filterable by form. Whistleblower forms never dispatch. - monotree.formresponse.comment.deleted — A public comment on a form response was deleted. Filterable by form. Whistleblower forms never dispatch. - monotree.formresponse.alarm_triggered — A form response matched a configured field alarm rule. One delivery per matched rule, idempotent per response + rule pair. Payload includes the full form response and the triggered alarm. Filterable by form. Whistleblower forms never dispatch. - monotree.user.registered — A user registered (accepted invite) - monotree.onboarding.completed — A user completed onboarding - monotree.achievement.awarded — A user was given an achievement, automatically (completion or tenure trigger), by hand in the CMS or through POST /achievements/{id}/awards. Fires only for published achievements while the achievements feature is on; never for CMS re-evaluation backfills. Filterable by achievement. - monotree.announcement.confirmed — An employee confirmed they have read a confirmable announcement. Once per employee per announcement, and there is no un-confirm, so it is announced exactly once. Not filterable. Company events can be confirmable too, but those confirmations fire no webhook yet. Every delivery carries customer — the sending customer's Monotree identifier (for example acme), the same value on every event including the CMS test event — so one receiver can serve several Monotree customers and pick the right secret before verifying. Payload format: { "id": "550e8400-e29b-41d4-a716-446655440000", "event": "monotree.post.created", "version": "v1", "customer": "acme", "timestamp": 1711270800, "payload": { "id": 123, "body": "Post content here", "wall_id": 1, "wall_name": "General", "author": { "id": 1, "name": "John Doe", "email": "john@example.com" }, "created_at": "2026-03-22T10:00:00+00:00", "updated_at": "2026-03-22T10:00:00+00:00" } } Form response payloads (formresponse.created / formresponse.updated / formresponse.alarm_triggered) carry the full form response including field_responses — one entry per answered field, with value shaped by field type: text/date/datetime/time/number/rating/boolean → the submitted value; choices → array of { id, title }; users/department_users → array of user objects. respondent is null when the response is anonymous. assignees lists the staff members assigned to handle the response. form_due_at is the parent form's due date (YYYY-MM-DD, null when the form has none). deleted_at is always present and null while the response exists — a formresponse.updated delivery with deleted_at set means the response was deleted (a permanent delete also prunes the field responses and assignees, so those arrays may be empty in that delivery). media lists the files uploaded with the submission (see FORMS + FORM RESPONSES) — note that signed urls for anonymous-response files expire ~5 minutes after dispatch; re-fetch GET /formresponses/{id} for a fresh link. Achievement award payload (achievement.awarded) — the award object from GET /achievements/{id}/awards with both achievement and user embedded: { "id": 4711, "awarded_at": "2026-08-01T00:00:00+02:00", "is_manually_added": true, "department": { "id": 41, "name": "Copenhagen", "type": "department" }, "assigned_by": { "id": 5, "name": "Sam Doe", "email": "sam@example.com", ... }, "achievement": { "id": 12, "title": "Employee of the month", "emoji": "👑", "type": "employee_of_the_month", "visibility": "public", "awarded_users_count": 37, ... }, "user": { "id": 501, "name": "Jane Doe", "email": "jane@example.com", "groups": [ ... ], ... }, "created_at": "2026-08-03T09:12:41+00:00", "updated_at": "2026-08-03T09:12:41+00:00" } awarded_at is the awarded day as midnight in the customer server's timezone, with its offset (see ACHIEVEMENTS) — read the date in that offset. Announcement confirmation payload (announcement.confirmed) — the confirmation with the employee and the announcement embedded, keyed by type: { "id": 8842, "type": "announcement", "created_at": "2026-09-21T09:12:41+02:00", "user": { "id": 501, "name": "Jane Doe", "email": "jane@example.com", "groups": [ ... ], ... }, "announcement": { "id": 456, "title": "Fire drill briefing", "is_confirmable": true, "confirmations_count": 12, ... } } type is always announcement here and names the key the confirmed item sits under. payload.id is the confirmation and is never reused, so it is a stable idempotency key. created_at is the moment the employee confirmed, in the customer server's timezone with its offset. Confirmations made before the endpoint existed are not replayed, and a delivery that fails three times is not redelivered — catch up with GET /confirmations?since=. Form response comment payloads (formresponse.comment.created / formresponse.comment.deleted): { "id": 901, "body": "Could you re-check the freezer reading?", "form_response_id": 4521, "form_id": 12, "author": { "id": 5, "name": "Sam Doe", "email": "sam@example.com" }, "created_at": "2026-05-20T08:00:00+00:00", "updated_at": "2026-05-20T08:00:00+00:00" } The comment author is null when written by the respondent on an anonymous form. Signature verification: Every delivery includes two headers: Signature and Timestamp. Every attempt — retries included — is signed with a fresh Timestamp; the timestamp inside the body is the original dispatch time and is not what is verified. Retries: a failed attempt (non-2xx, or no response within 3 seconds) is retried 1 minute and again 10 minutes after the first attempt, then the delivery is marked failed and is not redelivered automatically. The signature is HMAC-SHA256(timestamp + "." + raw_json_body, your_webhook_secret) as a hex string. Reject requests where the timestamp is more than 5 minutes old to prevent replay attacks. ======================================== OUTGOING REPORTS ======================================== Outgoing reports deliver aggregated data to your URL on a schedule. They use the same signed delivery infrastructure as outgoing webhooks (HMAC-SHA256 signature, Timestamp header, authentication, retries, and delivery logs). Reports are configured in the Outgoing Reports tab. Each report has a type and a schedule. Available report types: - todo_list_stats — Aggregated daily todo list completion statistics by department Schedules: - daily — Delivered every day at 07:00 CET - weekly — Delivered every Monday at 07:00 CET Payload format (todo_list_stats): { "id": "550e8400-e29b-41d4-a716-446655440000", "event": "monotree.todoliststats.daily", "version": "v1", "customer": "acme", "timestamp": 1711270800, "payload": { "data": { "total_lists": 42, "total_tasks": 150, "completed_tasks": 95, "departments": [ { "id": 1, "name": "Kitchen", "list_count": 5, "total_tasks": 45, "completed_tasks": 32, "completion_percentage": 71.11, "todo_list_templates": { "data": [ { "id": 1, "title": "Morning Checklist", "list_count": 2, "task_count": 15, "completed_count": 10, "task_templates_count": 3, "completion_percentage": 66.67, "todo_tasks": { "data": [ { "id": 101, "title": "Check cooler temperatures", "task_count": 5, "completed_count": 3, "completion_percentage": 60, "tasks": { "data": [ { "id": 1001, "user": { "name": "John Doe" }, "checked_at": "2026-03-25T07:30:00Z" } ] }, "meta": { "total_tasks": 5, "completed_tasks": 3, "completion_percentage": 60 } } ] } } ] } } ] } } } Reports support the same authentication options as outgoing webhooks (Bearer token, Basic Auth, API Key, custom headers). Signature verification works identically — see the Outgoing Webhooks section. Use the Test button in the Outgoing Reports tab to send a test report with yesterday's data. ======================================== RATE LIMITING ======================================== 60 requests per minute per token (configurable per server). Response headers: X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset On 429 responses: Retry-After header with seconds to wait. Incoming webhooks have no token; their requests are counted per sending IP address. ======================================== ERROR RESPONSES ======================================== 401 Unauthorized: { "error": "unauthorized", "message": "Invalid or inactive API key." } 403 Forbidden: { "error": "forbidden", "message": "This token does not have the required scope: write:posts" } 404 Not Found: Resource not found (no body) 422 Validation Error: { "error": "validation_error", "message": "The given data was invalid.", "errors": { "body": ["The body field is required."] } } 429 Rate Limit: { "message": "Too Many Attempts." } ======================================== HTTP STATUS CODES ======================================== 200 — Request succeeded 201 — Resource created successfully 204 — Resource deleted (no content) 401 — Missing or invalid API token 403 — Token lacks required scope 404 — Resource not found 422 — Validation error 429 — Rate limit exceeded ======================================== CHANGELOG ======================================== v0.44.0 — September 2026 - New outgoing webhook event monotree.announcement.confirmed, sent when an employee confirms they have read a confirmable announcement. The payload carries the confirmation with the employee and the announcement embedded, keyed by its type - Added GET /announcements/{id}/confirmations (read:announcements) — who has acknowledged one briefing — and GET /confirmations?since=, every announcement confirmation newest first, for catching up after a failed webhook delivery - Confirming is once per employee per announcement, and there is no un-confirm: a confirmation is announced exactly once and its id is a stable idempotency key. Company events can be made confirmable too, but their confirmations are not exposed yet v0.43.0 — September 2026 - Added POST /notifications (new write:notifications scope) to put a notification in employees' activity feed, targeted by groups, users or both. Each recipient gets one feed notification (no push) with a title, a body and an optional https:// or app link that opens on tap - The response gives the number of employees matched; the notifications are created in the background. Employees who have turned notifications off, deactivated employees and bots are skipped v0.41.1 — September 2026 - Wall posts that an employee has scheduled for later are no longer returned: they are left out of GET /walls/{wall_id}/posts under every status, and GET /walls/{wall_id}/posts/{id} → 404 until the post goes live v0.41.0 — September 2026 - Added POST /achievements and PUT|PATCH /achievements/{id} (new write:achievements scope) to create and update achievements: type, title, description, emoji, visibility, trigger config, translations and published state. Achievements are created as drafts unless is_published is true; requires the achievements feature - The type cannot be changed after creation. Completion and employment achievements need a complete trigger_config before they can be published, and publishing one starts awarding employees who meet the trigger - Added POST /achievements/{id}/awards (write:achievements) to award a published achievement to employees, in a department (required for employee_of_the_month). assigned_by on these awards is the company's own user (type customer); an identical award (same user and day) is returned instead of duplicated, and new awards are announced like CMS awards — push notification, colleague feed and the monotree.achievement.awarded webhook - Deleting achievements and awards is not part of the API v0.40.0 — September 2026 - Added Achievements endpoints (read:achievements): GET /achievements, GET /achievements/{id}, GET /achievements/{id}/awards and GET /users/{id}/achievements — achievements, every award of one, and the awards a user holds. Personal achievements are included; requires the achievements feature - New outgoing webhook event monotree.achievement.awarded, sent when a user is given an achievement (automatically or by hand), filterable by achievement. The payload is the award with the achievement and the user embedded. awarded_at is the awarded day as midnight in the customer server's timezone, with its offset — read the date in that offset - Every outgoing webhook and report delivery now carries customer in the envelope — the sending customer's Monotree identifier, the CMS test event included — so one receiver can serve several Monotree customers (additive) - Outgoing webhook retries now run 1 minute and 10 minutes after the first attempt (was ~10 s and ~100 s), and every attempt is signed with a fresh Timestamp, so a retry passes a receiver's replay window. The per-attempt timeout is 3 seconds v0.39.0 — September 2026 - Users carry a title — the employee's job title, set per user in the CMS (null when unset, never synced from an HR system). It is on the user object everywhere it appears (additive) - GET /org-chart labels a person box with that job title, falling back to the title typed on the box and then to the profession name. custom_title still returns the box's own title v0.38.0 — September 2026 - Added POST /users/{id}/avatar (write:users) to set an employee's profile picture from an uploaded image (multipart media field). It replaces any picture the employee chose themselves; colleagues are not notified - The response is the user object with the new avatar_url, the same field GET /users already exposes v0.37.0 — September 2026 - POST /walls/{wall_id}/posts and the wall_post incoming webhook accept a mix of image, video and document media in one post. A single-type batch is still stored as an image, video or file gallery; a mix (or more than one video) as a mixed gallery. Chat message media is still one type per batch v0.36.0 — August 2026 - Added POST /groups (write:groups) to create departments, professions and custom groups. Created groups are live immediately. type defaults to department; is_open is only valid on custom groups v0.35.1 — August 2026 - POST /calendar-entries no longer fails with 500 when the company has no published event type named "Default" to fall back on — calendar_event_type_id is required in that case and a missing one → 422 with a message pointing at GET /calendar-event-types - The calendar_entry incoming webhook accepts an optional calendar_event_type_id and follows the same rule v0.35.0 — August 2026 - Added the Org Chart endpoint (read:org_chart scope): GET /org-chart returns the organizational chart as a nested tree of person and department boxes, exactly as employees see it (deactivated people are left out and their reports move up) - Department boxes carry a members_count; list the people themselves with the existing GET /users?group_id=. The response meta tells whether the chart is published to employees yet v0.34.0 — August 2026 - POST /walls/{wall_id}/posts accepts an optional bot_user_id — the post is then attributed to that bot user instead of the token's user, matching the direct message endpoint and wall_post incoming webhooks. A non-bot id → 422 v0.33.0 — August 2026 - Forms now carry a due_at date (YYYY-MM-DD, null when unset) — the date by which respondents should have answered, set per form in the CMS and shown in-app to users who have not yet responded (additive) - Form responses mirror it as form_due_at next to form_title — in the response endpoints and the formresponse.* webhook payloads; detail payloads also carry it on the embedded form (additive) v0.32.0 — August 2026 - Bot messages can now be pinned (write:chat_messages): POST /chat-messages/{id}/pin pins a bot-sent message at the top of its room (optional pinned_until expiry), DELETE /chat-messages/{id}/pin removes it — one pinned message per room - Chat message responses (POST /users/{id}/messages and the pin endpoints) now carry nullable pinned_at and pinned_until fields (always present, additive) - Added user management endpoints (write:users scope): POST /users creates an employee — optionally with group memberships and an app invite (send_invite) — and DELETE /users/{id} soft deletes one, mirroring the CMS user management - Creating a user whose email belongs to a deleted account restores that account instead of creating a duplicate; an email in use by an active user is rejected with 422 - Only manually created users can be deleted — users synced from an external HR or rota system return 422 - Deleted (archived) form responses are now retrievable everywhere: ?include_deleted=1 works on GET /forms/{id}/responses and lets GET /formresponses/{id} return a deleted response instead of 404 (previously bulk-feed only) - Added ?only_deleted=1 to GET /forms/{id}/responses and GET /formresponses to list just the archive — historical form data without paging through live responses - Added Journeys endpoints (read:journeys scope): list journeys, and read per-user journey status — GET /journeys/{id}/users lists everyone a journey is assigned to and GET /users/{id}/journeys lists a user's journeys, each with assigned_at, due_at, completed_at, has_completed and is_overdue - due_at / is_overdue follow the CMS onboarding and offboarding weeks limits; custom journeys carry no deadline (due_at is null). Assignment uses the same user pool as /stats/academy/journeys, so the two surfaces agree v0.31.0 — July 2026 - Form responses now carry a nullable deleted_at field (always present) and an assignees array with the staff members assigned to handle the response — in the response endpoints and the formresponse.* webhook payloads (additive) - The formresponse.updated webhook now also fires when a response is deleted, restored, or permanently deleted — the delivery's deleted_at tells you the response is gone - GET /formresponses accepts ?include_deleted=1 to include deleted (archived) responses, letting a since-based sync catch deletions it missed v0.30.0 — July 2026 - Form responses now include a media array with the files uploaded with the submission (images, videos and documents), each carrying a direct download url — in the response endpoints and the formresponse.* webhook payloads (additive) - On anonymous responses the files are stored encrypted, so url is a short-lived signed link (expires after ~5 minutes) — re-fetch the response for a fresh one v0.29.0 — July 2026 - Users now include first_name and last_name as separate fields alongside name, plus date_of_birth (null unless collected) - hired_at now returns the effective hire date: a manual CMS override wins over the synced value - Users expose manager info: a top-level is_manager flag and a per-membership is_manager flag on each entry in groups[] - Groups returned by the /groups endpoints now include a managers array (id + name) — not included where groups are embedded in other payloads - Docs: corrected the users type example value ("default", not "user") and documented the existing since filter on users and groups v0.28.0 — July 2026 - Added Staff Cards endpoints (read:staff_cards, write:staff_cards): full CRUD for the informational cards shown in the employee app — mirroring the CMS staff cards management. Cards are created as drafts by default - Cards are targeted via groups (departments, professions and custom groups mixed in one array) and/or individually assigned users; publishing (is_published: true) notifies targeted employees, exactly like the CMS publish button - Cards support a translatable title, an expiry (expires_date/expires_time), manager visibility, and a QR code image via media_ids - Personal cards: type "personal" creates a card belonging to exactly one user (no groups) — e.g. a digital business card. qr_data generates a QR code image server-side, attached as media tagged "qr_code". List filterable via ?type= - Rich card content is managed through the card's article_id via the articles endpoints — or attach a pre-built standalone article at create time v0.27.0 — July 2026 - Added Forms and Form Responses endpoints (read:forms, read:formresponses): list forms, page a form's responses, single responses with full context, and a GET /formresponses?since= bulk feed for sync - Detail payloads embed the form, the shared public_comments thread (incl. media attachments) and staff-only internal_comments - The bulk feed's since filters updated_at — public comments now bump their response's updated_at, like internal notes already did - Whistleblower forms completely excluded (never listed, always 404); anonymity preserved (respondent/author null) - Form response comment payloads (API + formresponse.comment.* webhooks) now include a media array (additive) v0.26.0 — July 2026 - Added Feedback endpoint: POST /feedback sends feedback on the Open API straight to the Monotree team — no scope required, any valid token works v0.25.0 — July 2026 - Added Handbooks and Courses endpoints (read:/write:handbooks, read:/write:courses): CRUD for both containers and their chapters — create a draft, add chapters (each owning an article), publish when ready. Publishing notifies targeted employees - Added Articles endpoints (read:articles, write:articles): the shared rich-content structure. Read an article's typed elements and bulk-save the complete element list via PUT /articles/{id}/elements; read-only elements (reference, gif) are never deleted by bulk saves - Element types: paragraph (markdown), header, image_gallery, video_gallery, document — media is referenced from the existing POST /media flow - Added GET /articles/{id}/markdown — any article flattened to a single markdown document (?locale= supported) - Announcements, events, calendar entries and certificates now expose their article_id, so the articles endpoints can manage their rich content too - Articles are fully translatable: every text field carries a field-keyed translations object on read and write — writing a translation activates that locale on the owning content. Handbooks and courses accept the same translations object for their own title/subtitle - The body convenience field on announcements, events and calendar entries now returns 422 instead of silently replacing rich article content. Existing integrations are unaffected: creating with body and updating content whose article was built by body itself keep working unchanged - Create standalone articles via POST /articles and attach them with article_id when creating announcements, events, and calendar entries — content is complete the moment it goes live - Added is_published to POST /announcements and POST /calendar-entries (default true — behavior unchanged): create drafts with is_published: false and publish later via update. Events already supported this v0.24.0 — July 2026 - Added Calendar Event Types endpoints (read:calendar_event_types, write:calendar_event_types): list, create, and update the event types that categorize calendar entries — names are translatable per locale via a translations object - Calendar entries now accept calendar_event_type_id on create and update; when omitted on create, the default event type is used - Calendar entry responses now include the entry's calendar_event_type - PATCH /calendar-entries/{id} is now accepted alongside PUT - Added GET /locales (read:locales): the platform's active languages as a map of locale code to name, plus the default content locale — the valid keys for translatable fields v0.23.0 — June 2026 - Added Manager Hub endpoints (read:managerhub): the department reporting view from the mobile Manager Hub — team roster (/managerhub/team) and onboarding / journey / course / certificate progress (/managerhub/progress/*) - Every request names the department(s) to report on via a required department_ids parameter; figures share their queries with the in-app Manager Hub - Read-only and department-scoped; requires the manager hub feature v0.22.0 — June 2026 - Added direct messages (write:chat_messages): POST /users/{id}/messages sends a direct message to a single user's private chat, delivered by a bot user (the 1:1 room is created on first use) - New direct_message incoming webhook action — deliver a webhook payload to one user's private chat; a bot user is always the sender - Added Bots endpoints (read:bots, write:bots): create and manage the bot users that send direct messages and power incoming webhooks - Added Incoming Webhook management endpoints (read:incoming_webhooks, write:incoming_webhooks): create, read, update, and revoke incoming webhooks programmatically — the trigger URL and token are returned once on create v0.21.0 — June 2026 - Added Webhook Management endpoints (read:webhooks, write:webhooks): create, read, update, revoke, activate/deactivate, and test outgoing webhook endpoints programmatically — the same capabilities as the CMS Outgoing Webhooks tab - Endpoints are scoped to the token that created them; removing an endpoint is a revoke (history is preserved). Fetch the signing secret once via GET /webhooks/{id}/secret v0.20.0 — June 2026 - Added form response comment webhook events: monotree.formresponse.comment.created, monotree.formresponse.comment.deleted - Fire when a public comment is created or deleted on a form response. Internal staff notes do not trigger webhooks - Filterable by form (select one or more forms when subscribing); whistleblower forms never dispatch - The comment payload's author is null when written by the respondent on an anonymous form v0.19.0 — June 2026 - Added Certificates endpoints (read:certificates, write:certificates scopes): full CRUD for certificate templates, plus assign, reset/expire, list holders, and a user's certificates - Added Certificate Types endpoints (read:certificatetypes, write:certificatetypes scopes): full CRUD for certificate categories - Assigning mirrors the in-app bulk assignment; "removing" a certificate from a user is the reset/expire operation (history is preserved, matching the platform) - Certificate templates and types are created as drafts by default; pass is_published: true to publish - Update endpoints accept both PUT and PATCH v0.18.0 — June 2026 - Added a status query parameter to list endpoints — published (default), draft, or all — on announcements, events, calendar entries, posts, and surveys - published returns only currently-live content; draft returns unpublished items (including those awaiting a scheduled publish); all returns everything. An unrecognised value returns 422 - Breaking: GET /walls/{wall_id}/posts and GET /surveys previously returned drafts; they now return only published content by default. Pass status=draft or status=all to include drafts - Single-item endpoints (GET /{resource}/{id}) are unchanged — they still return an item regardless of its publication state v0.17.0 — June 2026 - Breaking: events are now created as drafts by default. Previously POST /events always published immediately - Added is_published to POST / PUT /events — set true to publish, false to unpublish (back to draft) - Added publish_at to POST / PUT /events — schedule a draft to publish at a future time. Must be a future timestamp; takes precedence over is_published - Send publish_at: null on update to cancel a pending schedule; scheduling an already-published event returns 422 - Event responses now include a scheduled_at field — the time a scheduled draft will go live, or null - Drafts and scheduled events are excluded from GET /events but remain fetchable via GET /events/{id} v0.16.0 — June 2026 - Form response webhook payloads (monotree.formresponse.created, monotree.formresponse.updated) now include choice and user field selections in field_responses — previously those field types were delivered with an empty value - choices fields serialize as an array of { id, title }; users and department_users fields serialize as an array of user objects, matching the respondent shape - Documented the full form response webhook payload, including the per-field-type value shapes v0.15.0 — June 2026 - Added monotree.formresponse.alarm_triggered webhook event (filterable by form) - Fires when a submitted form response matches a configured field alarm rule - Each alarm rule fires its own delivery — idempotent per response + rule pair (never fires twice for the same match) - Payload includes the full form response and the triggered alarm (rule name, operator, value, matched field) - Whistleblower forms are excluded, matching other form response events v0.14.0 — June 2026 - Added Academy statistics endpoints (read:stats scope) that mirror the CMS academy figures - Aggregate: GET /stats/academy/onboarding, GET /stats/academy/courses, GET /stats/academy/journeys (all accept an optional group_ids filter) - Quiz performance: GET /stats/academy/quizzes/{quiz_id} — per-question respondents, passed and pass rate - Per-user progress: GET /stats/academy/users/{user_id}/courses and GET /stats/academy/users/{user_id}/onboarding for HR / LMS integrations v0.13.0 — May 2026 - Added Surveys read endpoints (read:surveys scope): GET /surveys, GET /surveys/{id} - Added Survey answers endpoints keyed by template id: GET /surveys/{template_id}/answers and GET /surveys/{template_id}/answers/{id} — mirrors the CMS Excel export - Each answer embeds its spawn (id + starts_at), question, and choice so consumers can interpret rows without separate lookups - Filters: since, until (created_at), spawn_starts_at; per_page raised to 100 for bulk pulls - Anonymous surveys: spawns below survey_respondents_threshold (default 3) are suppressed entirely, matching the export's privacy contract v0.12.0 — May 2026 - User responses now include an avatar_url field — direct URL to the user's profile picture, or null if none is set - Affects GET /users, GET /users/{id}, and embedded user objects on posts, comments, etc. v0.11.0 — April 2026 - Added Media upload endpoints (write:media scope): POST /media and GET /media/{id} - Extended POST /walls/{wall_id}/posts with optional media_ids array — upload first, then attach by id - Incoming webhook wall_post and chat_message actions now accept an optional media array of URL + type pairs (server fetches URLs) - Supported media types: image, video, document. Max 10 per post/message, same type across the batch v0.10.0 — April 2026 - Added monotree.formresponse.updated webhook event (filterable by form) - Fires when a form response's status, deadline, visibility/archive flag, or assignees change - Does not fire on initial creation (use formresponse.created) or on unrelated activity like comments/notes - Whistleblower forms are excluded, matching the existing formresponse.created behavior v0.9.0 — April 2026 - Added Comments CRUD endpoints on posts (read:comments, write:comments scopes) - New endpoints: GET/POST /posts/{post_id}/comments and GET/PUT/DELETE /posts/{post_id}/comments/{id} - Added monotree.comment.created, monotree.comment.updated and monotree.comment.deleted webhook events (filterable by wall) - Comment updates are restricted to the author within 30 minutes of creation v0.8.0 — April 2026 - Outgoing webhooks now support resource filtering — optionally select specific forms or walls per event type - Form response webhooks can be scoped to specific forms (multi-select) - Post created webhooks can be scoped to specific walls (multi-select) - Default remains "all" — no filter means you receive all events of that type - Added filters field to webhook create/update API v0.7.0 — April 2026 - Added Calendar Entries CRUD endpoints (read:calendar_entries, write:calendar_entries scopes) - Create, update, and delete calendar entries with optional body and group targeting - Added Create calendar entry action for incoming webhooks v0.6.0 — April 2026 - Added Events CRUD endpoints (read:events, write:events scopes) - Create, update, and delete company events with date, time, location, and group targeting - Events are automatically published and added to the calendar on creation v0.5.0 — March 2026 - Added Statistics read-only endpoints (read:stats scope) - User summary, engagement time series, adoption rate, content activity - All statistics responses are cached server-side (1h–24h) v0.4.0 — March 2026 - Added Walls read-only endpoints with group associations (read:walls scope) - Post endpoints now nested under walls: /walls/{wall_id}/posts - Added Timestamp header to outgoing webhooks for replay attack prevention - Webhook signatures now include the timestamp: HMAC-SHA256(timestamp + "." + payload, secret) - Added bot user avatars for incoming webhooks (defaults to app icon) - Outgoing webhooks now use revoke instead of delete - Incoming webhooks now use unified text field for all actions - Added Pagination section to documentation with meta and links format - Added incoming webhook rate limiting documentation v0.3.0 — March 2026 - Added Users and Groups read-only endpoints (read:users, read:groups scopes) - Added monotree.user.registered, monotree.onboarding.completed, monotree.formresponse.created webhook events - Added IP whitelist per token, configurable rate limit, usage counters - Added content author selection (bot user or yourself) on incoming webhooks - Added Test Webhook buttons for outgoing and incoming webhooks - Added signature verification documentation with Node.js example v0.2.0 — March 2026 - Added Announcements CRUD endpoints (read:announcements, write:announcements scopes) - Added Create post on wall action for incoming webhooks - Added monotree.announcement.published webhook event v0.1.0 — March 2026 - Initial beta release of the Open API - Bearer token authentication with scoped permissions - Posts CRUD endpoints (read:posts, write:posts scopes) - Outgoing webhooks with HMAC-SHA256 signing for post events - Incoming webhooks for sending messages to chat rooms - API request logging and webhook delivery logging - Rate limiting at 60 requests per minute per token - CMS admin interface for managing tokens, webhooks, and logs ======================================== MCP SERVER (AI ASSISTANTS) ======================================== AI assistants (Claude, ChatGPT, Gemini, Claude Code, Cursor, custom agents) connect to Monotree through an MCP server, one per company: https://customer.monotree.com/mcp (streamable HTTP; beta; enabled per company by Monotree) Authentication (Authorization: Bearer): - Sign in with Monotree (OAuth 2.1 + PKCE, dynamic client registration, scope mcp:use). Only admins, editors and managers may connect; the assistant acts as that person with their CMS visibility. Discovery: /.well-known/oauth-protected-resource, /.well-known/oauth-authorization-server. Registration accepts callbacks on claude.ai, claude.com, chatgpt.com, chat.openai.com, gemini.google.com and http://localhost. - Open API token (mono_...): acts as the admin who created it, company-wide, tools filtered by scope. "Create MCP token" in CMS > Open API > AI Connectors creates one with the right scopes. Tokens with an IP restriction are rejected from hosted clients. Raw call (no session handshake needed; Accept must include text/event-stream): curl -X POST https://customer.monotree.com/mcp \ -H "Authorization: Bearer mono_your_token_here" \ -H "Content-Type: application/json" \ -H "Accept: application/json, text/event-stream" \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_handbooks","arguments":{}}}' Tool errors keep HTTP 200: check result.isError and read result.content[0].text. Rules: create_* tools make unpublished drafts (create_group is live). add_chapter, add_quiz, compose_article change published content immediately unless is_published: false. assign_course / assign_journey dry-run until confirm: true. Results carry cms_url. Optional locale on write tools must be an active language (resource monotree://locales). 60 requests/min per person or token. Every call is logged. Tools (46) and scope: Content: list_handbooks, get_handbook (read:handbooks) · list_courses, get_course (read:courses) · get_article, search_content (read:articles) · create_handbook (write:handbooks) · create_course (write:courses) · add_chapter (write:handbooks or write:courses) · add_quiz (write:courses) · compose_article (write:articles) People: search_users, get_user (read:users) · list_groups, preview_audience, list_external_departments, list_external_groups (read:groups) · get_org_chart (read:org_chart) · create_group (write:groups, live) Communication: list_walls (read:walls) · list_posts (read:posts) · list_events (read:events) · create_post (write:posts) · create_announcement (write:announcements) · create_event (write:events) Training: get_journey, create_journey, assign_course, assign_journey (read:/write:courses) · list_expiring_certificates (read:certificates) · get_user_progress (read:stats) Forms: list_forms, get_form (read:forms) · get_form_responses, get_form_alarms (read:formresponses) · list_todo_lists, get_todo_list (read:todolists) · get_todo_compliance (read:stats) Insights: get_academy_stats, get_academy_overview, get_engagement_stats, get_content_reach, audit_content (read:stats) · manager_hub_summary (read:managerhub) · get_survey_results (read:surveys) Media: upload_media (write:media) Fallback: call_open_api (signed-in admins only; raw Open API request) Resources: monotree://locales, monotree://org-structure (read:groups for tokens). Full MCP documentation: https://docs.monotree.com/mcp/