---
title: "Create an annotation"
method: POST
path: "/api/annotations.create"
tags: ["Web Analytics"]
---

# Create an annotation

`POST /api/annotations.create`

Creates a manual annotation. Requires the web_analytics write permission.

`source` is always stored as `manual` and `source_id` is always empty: only the platform writes automatic rows, so there is no way to attach a caller-supplied key to one. **This endpoint is therefore not idempotent** — a retried call (a CI job re-running a deploy marker, for instance) writes a second annotation. De-duplicate on the caller's side, or delete the extra row.

Omitted `color` and `timezone` are filled in server-side: the default colour, and the workspace timezone falling back to UTC.

## Request body

- CreateAnnotationRequest
  - `workspace_id` string, required
  - `annotated_at` string, date-time, required — The moment to mark, as an RFC3339 instant.
  - `timezone` string — IANA timezone the moment was entered in, kept for display. Defaults to the workspace timezone, then to UTC.
  - `title` string, required
  - `description` string
  - `color` string
  - `source` 'manual' — Only `manual` is accepted. Sending `broadcast` is rejected rather than quietly downgraded: it would be claiming the slot of an automatic row.

## Response `201`

Annotation created

- AnnotationResponse
  - `annotation` Annotation
    - `id` string, required — Server-generated identifier (a UUID with its dashes removed).
    - `annotated_at` string, date-time, required — The moment the annotation marks, as a real instant.
    - `timezone` string, required — IANA timezone the moment was entered in. This is display intent only — annotated_at already fixes the instant, and filtering never uses this field. It is what lets "9am in Tokyo" render back as 9am instead of the reader's local equivalent.
    - `title` string, required — Counted in characters, not bytes.
    - `description` string — Optional longer note. Counted in characters, not bytes.
    - `color` string, required — Six-digit hex colour used for the marker on the charts.
    - `source` 'manual' | 'broadcast', required — Who wrote the row. `manual` is anything typed by an operator or posted to this API; `broadcast` rows are written automatically when a broadcast starts sending. There is deliberately no API source — see the note on annotations.create.
    - `source_id` string — The entity that caused an automatic annotation (a broadcast id today). Always absent on manual rows, and never settable through the API. An annotation outlives the entity it points at: deleting a broadcast leaves its annotation, because the send did happen.
    - `created_at` string, date-time, required
    - `updated_at` string, date-time, required

## Other responses

- `400` — Bad request - unparseable body, validation failed, or the instance runs in demo mode, where every mutating endpoint is closed.
- `401` — Unauthorized - invalid or missing authentication token
- `403` — Forbidden - missing required permission
- `500` — Internal server error

## Changes

- **2026-08-17** `d26605f2fc47` — 1 info
  - endpoint added

[Change history](https://skmtc.dev/pierre-b/apis/notifuse-api/changes/api/annotations.create/post.md)

---

[API](https://skmtc.dev/pierre-b/apis/notifuse-api.md) · [All operations](https://skmtc.dev/pierre-b/apis/notifuse-api/llms.txt) · [OpenAPI document](https://skmtc-service-production.skmtc.workers.dev/v1/apis/pierre-b/notifuse-api/revisions/d26605f2fc47/schema)
