---
title: Tags
subtitle: Labels for events, states, and context
---


{% use-cases items="Behaviour Tracking, Engagement, Cohort Analysis, Personalization" /%}

## Introduction

Biomarkers and scores capture what the body does. Tags capture everything else—a symptom, a medication, a shift, a subscription—as discrete, time-bound records ready to query, stream, and correlate with health signals.

Tags can come from anywhere—the Sahha SDK reading device health platforms, Sahha's platform integrations with Garmin, Oura, and other connected services, or direct API calls from your own app or backend. Whatever the source, every tag arrives in the same shape: a name, an optional value, a type, and a time range.

{% callout title="Related products" %}
Need numeric metrics like steps, sleep duration, or heart rate? See [Biomarkers](/docs/products/biomarkers).
Need raw sensor samples? See [Data Logs](/docs/products/logs).
Need long-term behavioural labels? See [Archetypes](/docs/products/archetypes).
{% /callout %}

---

## Key Features

{% cards smCols=2 %}

{% card title="Auto-Collected" description="Reserved tags arrive automatically from the Sahha SDK (reading HealthKit and Health Connect) and from server-to-server integrations with Garmin, Oura, and other connected platforms—all normalized to the same names so your code never branches on source" /%}
{% card title="Events and States" description="One schema models both moments in time (a dose, a test result) and windows of time (a shift, a subscription)—including open-ended states that stay active until you close them" /%}
{% card title="Analytics-Ready" description="A strict category > name > value taxonomy with value as a top-level indexed field, so dose-response and severity queries work without parsing free-text notes" /%}
{% card title="Idempotent" description="Identity is derived from name, type, source, and start time—re-upload the same tuple to update or close a tag, with no risk of duplicates from retries or follow-ups" /%}
{% card title="Custom + Reserved" description="Send any custom category, name, and value alongside Sahha's reserved namespace—one schema, one pipeline, one set of webhooks for both" /%}
{% card title="Real-time Streaming" description="Every tag streams via webhooks the moment it lands—useful for triggering nudges, segmentation, or downstream analytics in real time" /%}

{% /cards %}

---

## How It Works

A tag is a structured record with a `name` (what's being tracked), an optional `value` (the variant, severity, or dose), a `type` (`event` or `state`), and a time range. The same schema is used whether the tag is a clinical signal collected by the SDK or a business event posted from your backend.

**Events** are points in time—a moment was recorded and nothing more. `endDateTime` must be `null`.

**States** span windows. A **closed state** has both `startDateTime` and `endDateTime` set. An **open state** has `endDateTime: null`, meaning the window is still active; close it later by re-uploading the same tag with an `endDateTime` set.

**Identity is the tuple, not the `id`.** Each tag's identity is derived from `profile + name + type + source + startDateTime`. The `id` field is server-generated; any value supplied on upload is ignored. Re-uploading a tag with the same identity tuple updates the existing record, so retries after transient errors and follow-up edits never create duplicates.

Tags flow into Sahha through three channels: the mobile SDK, Sahha's platform integrations with services like Garmin and Oura, and direct API calls from your backend or app. Reserved names must follow the schema below regardless of channel; custom names can use any shape. Reserved tags with a non-matching shape are rejected per-item (200 OK with the rejection reported in `warnings`)—the rest of the batch still lands. Tags flow out via the API and webhooks in the same shape they came in.

---

## List of Tags (Reserved)

Sahha maintains a curated namespace of tag names with a **fixed schema**. Reserved tags can come from any source—the Sahha SDK (reading HealthKit and Health Connect), Sahha's platform integrations with Garmin, Oura, and other connected services, or your own app or backend—as long as the tag's `type`, `category`, and `value` match the entry below. Names inside this namespace arrive in a consistent shape across platforms and may power native analytics in future releases.

The namespace will grow over time. Currently it covers two categories: `symptom` and `reproductive`.

### Symptom

Every symptom tag is logged with the same value set—a five-level severity scale—and arrives as a point-in-time event.

{% data-table height=500 searchable=true badges="Type" badgeColors="event:blue,state:emerald" %}

| Name | Type | Possible Values |
| ---- | ---- | --------------- |
| **abdominal_cramps** | event | `unknown`, `not_present`, `mild`, `moderate`, `severe` |
| **acne** | event | `unknown`, `not_present`, `mild`, `moderate`, `severe` |
| **appetite_changes** | event | `unknown`, `not_present`, `mild`, `moderate`, `severe` |
| **bladder_incontinence** | event | `unknown`, `not_present`, `mild`, `moderate`, `severe` |
| **bloating** | event | `unknown`, `not_present`, `mild`, `moderate`, `severe` |
| **breast_pain** | event | `unknown`, `not_present`, `mild`, `moderate`, `severe` |
| **chills** | event | `unknown`, `not_present`, `mild`, `moderate`, `severe` |
| **constipation** | event | `unknown`, `not_present`, `mild`, `moderate`, `severe` |
| **coughing** | event | `unknown`, `not_present`, `mild`, `moderate`, `severe` |
| **diarrhea** | event | `unknown`, `not_present`, `mild`, `moderate`, `severe` |
| **dizziness** | event | `unknown`, `not_present`, `mild`, `moderate`, `severe` |
| **dry_skin** | event | `unknown`, `not_present`, `mild`, `moderate`, `severe` |
| **fainting** | event | `unknown`, `not_present`, `mild`, `moderate`, `severe` |
| **fatigue** | event | `unknown`, `not_present`, `mild`, `moderate`, `severe` |
| **fever** | event | `unknown`, `not_present`, `mild`, `moderate`, `severe` |
| **generalized_body_ache** | event | `unknown`, `not_present`, `mild`, `moderate`, `severe` |
| **hair_loss** | event | `unknown`, `not_present`, `mild`, `moderate`, `severe` |
| **headache** | event | `unknown`, `not_present`, `mild`, `moderate`, `severe` |
| **heartburn** | event | `unknown`, `not_present`, `mild`, `moderate`, `severe` |
| **hot_flashes** | event | `unknown`, `not_present`, `mild`, `moderate`, `severe` |
| **loss_of_smell** | event | `unknown`, `not_present`, `mild`, `moderate`, `severe` |
| **loss_of_taste** | event | `unknown`, `not_present`, `mild`, `moderate`, `severe` |
| **lower_back_pain** | event | `unknown`, `not_present`, `mild`, `moderate`, `severe` |
| **memory_lapse** | event | `unknown`, `not_present`, `mild`, `moderate`, `severe` |
| **mood_changes** | event | `unknown`, `not_present`, `mild`, `moderate`, `severe` |
| **nausea** | event | `unknown`, `not_present`, `mild`, `moderate`, `severe` |
| **night_sweats** | event | `unknown`, `not_present`, `mild`, `moderate`, `severe` |
| **pelvic_pain** | event | `unknown`, `not_present`, `mild`, `moderate`, `severe` |
| **rapid_pounding_or_fluttering_heartbeat** | event | `unknown`, `not_present`, `mild`, `moderate`, `severe` |
| **runny_nose** | event | `unknown`, `not_present`, `mild`, `moderate`, `severe` |
| **shortness_of_breath** | event | `unknown`, `not_present`, `mild`, `moderate`, `severe` |
| **sinus_congestion** | event | `unknown`, `not_present`, `mild`, `moderate`, `severe` |
| **skipped_heartbeat** | event | `unknown`, `not_present`, `mild`, `moderate`, `severe` |
| **sleep_changes** | event | `unknown`, `not_present`, `mild`, `moderate`, `severe` |
| **sore_throat** | event | `unknown`, `not_present`, `mild`, `moderate`, `severe` |
| **vaginal_dryness** | event | `unknown`, `not_present`, `mild`, `moderate`, `severe` |
| **vomiting** | event | `unknown`, `not_present`, `mild`, `moderate`, `severe` |
| **wheezing** | event | `unknown`, `not_present`, `mild`, `moderate`, `severe` |

{% /data-table %}

### Reproductive

Reproductive tags use a mix of `event` and `state` types, each with its own closed value set (or no value for presence-only flags).

{% data-table height=500 searchable=true badges="Type" badgeColors="event:blue,state:emerald" %}

| Name | Type | Possible Values |
| ---- | ---- | --------------- |
| **menstrual_flow** | event | `unknown`, `not_present`, `light`, `medium`, `heavy` |
| **intermenstrual_bleeding** | event | — |
| **infrequent_menstrual_cycles** | event | — |
| **irregular_menstrual_cycles** | event | — |
| **persistent_intermenstrual_bleeding** | event | — |
| **prolonged_menstrual_periods** | event | — |
| **pregnancy** | state | — |
| **lactation** | state | — |
| **ovulation_test** | event | `inconclusive`, `negative`, `high`, `positive` |
| **cervical_mucus** | event | `unknown`, `dry`, `sticky`, `creamy`, `watery`, `egg_white`, `unusual` |
| **sexual_activity** | event | `unknown`, `protected`, `unprotected` |
| **contraceptive** | state | `unknown`, `implant`, `injection`, `intravaginal_ring`, `iud`, `oral`, `patch` |
| **pregnancy_test** | event | `inconclusive`, `negative`, `positive` |
| **progesterone_test** | event | `inconclusive`, `negative`, `positive` |

{% /data-table %}

### Reserved Additional Properties

The `additionalProperties` field is free-form on every tag—use any keys you need on custom tags. The keys below are populated by the SDK on specific reserved tags, so custom code shouldn't redefine them on the tags they appear on.

| Key | Emitted on | Values |
| --- | ---------- | ------ |
| `cycle_start` | `menstrual_flow` | `"true"` or `"false"` |

---

## Custom Tags

Outside the reserved namespace there is **no fixed schema**—pick any `category`, `name`, and `value` your product needs. Custom tags use the same record shape, the same API, and the same webhook stream as reserved ones; they're stored and returned exactly as sent.

Typical custom use cases:

- **Workforce & shifts** — `night_shift`, `on_call`, `field_deployment`
- **Subscription & lifecycle** — `premium_tier`, `trial`, `lapsed`, `onboarding_complete`
- **Clinical & coaching** — `medication`, `therapy_session`, `intervention_arm`
- **Habits & lifestyle** — `caffeine`, `alcohol`, `meal`, `meditation`

Built-in analytics interpret reserved names only. Custom tags are first-class for storage and delivery, and any reporting you build on top can mix both freely.

---

## Use Cases

{% cards smCols=2 %}

{% card title="Symptom & Wellness Journaling" description="Let users log how they feel against the body's signals—correlate flares with sleep, activity, or stress" /%}
{% card title="Clinical & Reproductive Health" description="Capture medications, treatments, and reproductive events with timestamped accuracy, ready for study analysis" /%}
{% card title="Workforce & Operations" description="Tag shifts, on-call windows, or deployment periods to see how operating conditions affect recovery and readiness" /%}
{% card title="Subscription & Cohort Analysis" description="Track plan tiers, trial status, or program enrolment as ongoing states for segmentation and retention insights" /%}
{% card title="Feature Impact" description="Tag exposure to product features as states and measure their effect on health outcomes over time" /%}
{% card title="Coaching & Intervention" description="Trigger nudges based on what the user is doing right now—not just what the body is doing" /%}

{% /cards %}

---

## Output Schema

Every tag returns a consistent JSON structure regardless of source or category.

{% api-example %}

{% api-panel %}
{% api-property field="id" type="UUID" description="Server-generated. Ignored on upload—identity is derived from name, type, source, and startDateTime under the profile" /%}
{% api-property field="type" type="string" description="event (point in time) or state (window of time)" /%}
{% api-property field="category" type="string" nullable=true description="Optional bucket—reserved (symptom, reproductive) or any custom value" /%}
{% api-property field="name" type="string" description="The primary signal—reserved name or custom" /%}
{% api-property field="value" type="string" nullable=true description="Optional severity, dose, or variant. May be null for presence-only flags" /%}
{% api-property field="source" type="string" description="Which system produced the tag. SDK and integrations set this automatically; backend uploads typically use a reverse-DNS string (e.g. acme.workforce)" /%}
{% api-property field="startDateTime" type="datetime" description="When the tag began (ISO 8601)" /%}
{% api-property field="endDateTime" type="datetime" nullable=true description="When the tag ended. Must be null for events; may be null for open states" /%}
{% api-property field="receivedAtUtc" type="datetime" description="UTC timestamp when Sahha received the tag" /%}
{% api-property field="additionalProperties" type="object<string,string>" nullable=true description="Auxiliary metadata. Sahha uses reserved keys only; custom keys are stored and returned verbatim" /%}
{% /api-panel %}

```json {% title="Example: closed state" %}
{
	"id": "f3b1a8e0-4c5d-4e6f-9a8b-1c2d3e4f5a01",
	"type": "state",
	"category": "work",
	"name": "night_shift",
	"value": "12_hour",
	"source": "acme.workforce",
	"startDateTime": "2024-02-09T19:00:00+00:00",
	"endDateTime": "2024-02-10T07:00:00+00:00",
	"receivedAtUtc": "2024-02-10T07:05:02+00:00",
	"additionalProperties": {
		"role": "icu_nurse",
		"overtime": "false"
	}
}
```

{% /api-example %}

### More examples

An **open state**—still active, no end yet. Close it later by posting a follow-up tag with the same `name` and an `endDateTime`.

```json {% title="Example: open state" %}
{
	"id": "f3b1a8e0-4c5d-4e6f-9a8b-1c2d3e4f5a02",
	"type": "state",
	"category": "subscription",
	"name": "premium_tier",
	"value": "annual",
	"source": "acme.billing",
	"startDateTime": "2024-01-28T00:00:00+00:00",
	"endDateTime": null,
	"receivedAtUtc": "2024-01-28T00:00:16+00:00",
	"additionalProperties": {
		"autoRenew": "true",
		"priceUsd": "99"
	}
}
```

An **event with a dose**—`type: "event"` requires `endDateTime: null`. The `value` field carries the dose at the top level so analytics can aggregate without parsing notes.

```json {% title="Example: event with dose" %}
{
	"id": "f3b1a8e0-4c5d-4e6f-9a8b-1c2d3e4f5a03",
	"type": "event",
	"category": "medication",
	"name": "insulin_bolus",
	"value": "6_units",
	"source": "acme.clinical",
	"startDateTime": "2024-02-11T12:30:00+00:00",
	"endDateTime": null,
	"receivedAtUtc": "2024-02-11T12:30:10+00:00",
	"additionalProperties": {
		"timing": "before_breakfast"
	}
}
```

---

## FAQ

{% faq %}

{% faq-item question="When should I use a Tag vs a Biomarker?" %}
Biomarkers are for numeric metrics (steps, heart rate, sleep duration). Tags are for categorical signals—anything described by a name, a variant, or a severity rather than a number. Symptom severity, a positive test result, an active subscription, and a 12-hour shift are all tags.
{% /faq-item %}

{% faq-item question="Can my app or backend write tags that use reserved names?" %}
Yes. Reserved names aren't off-limits to your code—they just have a fixed schema. As long as the tag's `type`, `category`, and `value` match the entry for that name in the [reserved list](#list-of-tags-reserved), you can write it from anywhere: the SDK, your app, or your backend. Posts with a different shape are rejected for that tag only and may be excluded from native analytics.
{% /faq-item %}

{% faq-item question="What happens if a reserved tag has the wrong shape?" %}
Only that tag is rejected—other tags in the same batch still land. The upload returns `200 OK` with `null` at the rejected slot in the positional `ids` array and an entry in `warnings` explaining why (for example, a value outside the reserved enum). Custom (non-reserved) tags are never rejected by this validator.
{% /faq-item %}

{% faq-item question="Can I create my own custom tags?" %}
Yes. Outside the reserved namespace there's no fixed schema—use any `category`, `name`, and `value` you need. Custom tags are stored and returned exactly as sent and stream via webhooks alongside reserved tags. Sahha's built-in analytics interpret reserved names only, but any reporting you build is free to use both.
{% /faq-item %}

{% faq-item question="How do I close an open state?" %}
Re-upload the same tag with `endDateTime` set. As long as `name`, `type`, `source`, and `startDateTime` match the original, Sahha treats it as the same record and updates it in place. To keep the open and closed records as separate timeline entries instead, change one of those identity fields (typically `startDateTime`) so they form a distinct tuple.
{% /faq-item %}

{% faq-item question="Where can tags come from?" %}
Tags flow in from three places: the Sahha SDK reading device health platforms, Sahha's platform integrations with services like Garmin and Oura, or direct API calls from your own app or backend. Reserved or custom, every tag uses the same shape and pipeline. Use the `source` field on each tag to record where it came from.
{% /faq-item %}

{% faq-item question="What happens if I post the same tag twice?" %}
Sahha derives each tag's identity from `profile + name + type + source + startDateTime`, not from the `id` field (which is server-generated and ignored on upload). Re-uploading the same identity tuple updates the existing record, so retries after transient errors and follow-up edits never create duplicates.
{% /faq-item %}

{% /faq %}

---

## Getting Started

{% action-cards %}

{% action-card icon="code" href="/docs/connect/api" title="API" description="Write and read tags via REST for any profile" /%}

{% action-card icon="cube" href="/docs/connect/sdk" title="SDK" description="Reserved tags are collected automatically; post custom tags from your app" /%}

{% action-card icon="bell" href="/docs/connect/webhooks" title="Webhooks" description="Stream every tag in real time as it lands" /%}

{% /action-cards %}

---

## Support

For help with Tags, reach out in the [Slack community](https://join.slack.com/t/sahhacommunity/shared_invite/zt-1w0fmfbvk-qUwQ83tJgXyjT9XSxJvKIw) or contact [support@sahha.ai](mailto:support@sahha.ai).
