Metadata-Version: 2.4
Name: meemo
Version: 0.0.12
Summary: Meemo Python Client - Python client library for Meemo External API
Author-email: GDP Labs <jobs@gdplabs.id>
Requires-Python: <3.14,>=3.11
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: anyio==4.14.2
Requires-Dist: gllm-core<0.5.0,>=0.4.53
Requires-Dist: httpx<1,>=0.28.1
Requires-Dist: pydantic>=2.9.0
Dynamic: license-file

# Meemo

A Python library for interacting with the Meemo External API, providing access to meeting data including details, transcripts, summaries, participants, and recordings.

## Prerequisites

- **Python** >=3.11, <3.14

## Installation

```bash
pip install meemo
```

Or with uv:

```bash
uv add meemo
```

## Quick Start

```python
from meemo import MeemoClient

# Initialize the client
client = MeemoClient(
    client_id="your-client-id",
    client_secret="your-client-secret",
    base_url="https://api-meemo.glair.ai",
)

# List meetings
meetings = client.meetings.list_meetings()
for meeting in meetings.results:
    recording = meeting.recording.status if meeting.recording else "n/a"
    print(f"{meeting.id}: {meeting.title} ({meeting.status}, recording={recording})")

# Get meeting details
detail = client.meetings.get_meeting(123)
print(f"Host: {detail.host.name}")
if detail.duration_seconds is not None:
    print(f"Duration: {detail.duration_seconds}s")
else:
    print("Duration: still in progress")
if detail.recording:
    print(f"Recording: {detail.recording.status} ({detail.recording.description})")

# Get transcript
transcript = client.meetings.get_transcript(123)
for segment in transcript.transcripts:
    print(f"[{segment.speaker}] {segment.text}")

# Get summary
summary = client.meetings.get_summary(123)
print(summary.summary)

# Get summary with seekable video timestamps (for messaging UIs)
rich = client.meetings.get_summary_with_video(123)
print(rich.inline_message_markdown)  # for AIP agents; send verbatim
```

## Async Usage

For use in async frameworks (FastAPI, Django async views, async workers), use `AsyncMeemoClient`:

```python
import asyncio
from meemo import AsyncMeemoClient

async def main():
    client = AsyncMeemoClient(
        client_id="your-client-id",
        client_secret="your-client-secret",
        base_url="https://api-meemo.glair.ai",
    )

    # All methods are async — same API surface as MeemoClient
    meetings = await client.meetings.list_meetings()
    for meeting in meetings.results:
        print(f"{meeting.id}: {meeting.title}")

    detail = await client.meetings.get_meeting(123)
    transcript = await client.meetings.get_transcript(123)
    summary = await client.meetings.get_summary(123)

    # Revoke token when done
    await client.revoke_token()

asyncio.run(main())
```

## Configuration

### Environment Variables

The library supports the following environment variables:

- `MEEMO_CLIENT_ID`: OAuth2 client ID for authentication
- `MEEMO_CLIENT_SECRET`: OAuth2 client secret for authentication
- `MEEMO_BASE_URL`: Base URL of the Meemo instance

A client built with `access_token` does not read `MEEMO_CLIENT_ID` or `MEEMO_CLIENT_SECRET` (see [User Tokens (OAuth)](#user-tokens-oauth)).

### Client Initialization

```python
from meemo import MeemoClient

# Using explicit parameters
client = MeemoClient(
    client_id="your-client-id",
    client_secret="your-client-secret",
    base_url="https://api-meemo.glair.ai",
    timeout=60.0,
    default_headers={"X-Custom-Header": "value"},
)

# Using environment variables
import os
os.environ["MEEMO_CLIENT_ID"] = "your-client-id"
os.environ["MEEMO_CLIENT_SECRET"] = "your-client-secret"
os.environ["MEEMO_BASE_URL"] = "https://api-meemo.glair.ai"
client = MeemoClient()
```

## Authentication

The library uses **OAuth2 Client Credentials** flow. Tokens are automatically obtained and refreshed when they expire (default: 3 hours).

```python
# Token management is automatic, but you can also manage it manually:

# Revoke the current token
client.revoke_token()
```

## User Tokens (OAuth)

A client built from a user's Meemo access token calls the API as that user, so it sees only the meetings that user can read. The one exception is the organization's upcoming calendar events, which every member can read; see [Get Upcoming Meetings](#get-upcoming-meetings). Meemo is the OAuth provider: the user signs in with Google or Microsoft through redirects only (authorization code + PKCE S256). The OAuth client must be registered in Meemo as a confidential, authorization-code external application.

```python
import base64
import hashlib
import secrets

from meemo import MeemoClient, MeemoTokenExpiredError
from meemo.oauth import build_authorize_url, exchange_code, refresh_token, revoke_token

BASE_URL = "https://api-meemo.glair.ai"
CLIENT = {"client_id": "your-client-id", "client_secret": "your-client-secret"}
REDIRECT_URI = "https://your-app.example.com/callback"

# 1. Make a PKCE pair and a state for this sign-in; keep code_verifier and state for the redirect back
code_verifier = secrets.token_urlsafe(64)
code_challenge = base64.urlsafe_b64encode(hashlib.sha256(code_verifier.encode()).digest()).rstrip(b"=").decode()
state = secrets.token_urlsafe(32)

# Send the user's browser here. Meemo redirects back to REDIRECT_URI with ?code=...&state=...
url = build_authorize_url(
    base_url=BASE_URL,
    client_id=CLIENT["client_id"],
    redirect_uri=REDIRECT_URI,
    code_challenge=code_challenge,
    state=state,
    idp="google",  # or "microsoft"
)

# 2. On the redirect back, check state, then exchange the code for the user's tokens
tokens = exchange_code(
    base_url=BASE_URL,
    **CLIENT,
    code=code,
    code_verifier=code_verifier,
    redirect_uri=REDIRECT_URI,
    external_org_id="your-platform-org-id",  # your platform's stable ID for the user's organization
)

# 3. Call the API as the user
client = MeemoClient(base_url=BASE_URL, access_token=tokens.access_token)
try:
    meetings = client.meetings.list_meetings()
except MeemoTokenExpiredError:
    # 4. Renew; Meemo rotates the refresh token, so store the new one
    tokens = refresh_token(base_url=BASE_URL, **CLIENT, refresh_token=tokens.refresh_token)
    save_refresh_token(tokens.refresh_token)  # your storage; the old refresh token no longer works
    client = MeemoClient(base_url=BASE_URL, access_token=tokens.access_token)
    meetings = client.meetings.list_meetings()

# 5. When the user disconnects, revoke their refresh token; Meemo revokes its access token with it
revoke_token(base_url=BASE_URL, **CLIENT, token=tokens.refresh_token)
```

- A user-token client never uses client credentials, and `client.revoke_token()` raises `MeemoInvalidArgumentError`; revoke with `meemo.oauth.revoke_token`.
- An HTTP 401 for an expired token raises `MeemoTokenExpiredError` (a `MeemoAuthError`), so the caller refreshes the token. Any other 401 (an unknown, revoked or malformed token) stays a plain `MeemoAuthError`, since refreshing cannot fix it; send the user through sign-in again. An HTTP 403 stays a plain `MeemoAuthError`: the token is valid but may not call that endpoint.
- A refused code or refresh token raises `MeemoValidationError` with `api_code == "invalid_grant"`; send the user through sign-in again.
- The async helpers are in `meemo.async_oauth` and take the same arguments; `AsyncMeemoClient(base_url=..., access_token=...)` works the same way.

## Meetings API

### List Meetings

```python
# Get all meetings
meetings = client.meetings.list_meetings()
print(f"Total: {meetings.count}")

# Filter by organization
meetings = client.meetings.list_meetings(organization_id=5)

# Filter by title and date
meetings = client.meetings.list_meetings(
    title="standup",
    created_after="2024-01-01",
    created_before="2024-12-31",
    summary_complete=True,
)

# Pagination
meetings = client.meetings.list_meetings(page=2, size=20)

# Resolve a Google Calendar event id to a meeting (None until auto-join creates it)
meeting = client.meetings.find_meeting_by_calendar_event_id(
    "google_event_abc123",
    organization_id=5,
)
if meeting:
    print(meeting.id, meeting.title)
```

`find_meeting_by_calendar_event_id` returns a `MeetingListItem` (the list row,
not the full detail) or `None`, and verifies the returned row's
`calendar_event.event_id` before handing it back, so it answers `None` rather
than an unrelated meeting when talking to an api-service build that predates the
`calendar_event_id` filter. Call `get_meeting(meeting.id)` for full metadata.

All filters are keyword-only (see `MeetingListKwargs` for the accepted keys);
`extra_headers` remains the only positional argument.

#### Search by Summary

`search` runs a ranked lexical search over the meeting title, calendar invite
summary, the AI-generated summary markdown (the `summary` field from
`get_summary` / `summary_markdown` on `GET .../summary/` — **not** `notes`), and
tags — so you can find a meeting by what was discussed in it, not just by its
title. Results come back ordered by relevance instead of start time.

```python
# Find meetings whose summary (or title/tags) mentions the query
meetings = client.meetings.list_meetings(search="pricing model")
for m in meetings.results:
    print(f"{m.id}: {m.title}")

# Combine with any other filter (all active filters use AND semantics)
meetings = client.meetings.list_meetings(
    search="pricing model",
    organization_id=5,
    start_after="2024-01-01",
)
```

Ranking is tiered: title and calendar-summary hits rank above summary-only
hits, which rank above tag-only hits. Summary matching is a contiguous
substring match, so `"budget Q3"` will not match a summary reading
`"Q3 budget"` — title and calendar matching does handle non-adjacent words.

#### Filter by meeting link, calendar name, or description

These filters are case-insensitive contains matches against calendar metadata
(and, for `meeting_link`, also `bot_meeting_url`). They can return many rows;
combine them with other filters using AND semantics. A present-but-blank value
matches nothing.

Requires an api-service build that carries the calendar lookup filters (Meemo
`beta`). An older build drops them as unknown query params and answers with an
unfiltered list, which is indistinguishable from "everything matched" — unlike
`find_meeting_by_calendar_event_id`, these filters do not re-check the returned
rows for you. To probe a build, query a value that cannot exist (a random UUID):
a build that carries the filter answers with an empty page.

```python
# By conference URL (full URL or a meet code such as "abc-defg-hij")
meetings = client.meetings.list_meetings(
    meeting_link="https://meet.google.com/abc-defg-hij",
)

# By calendar event name/summary
meetings = client.meetings.list_meetings(calendar_event_name="Q1 Planning")

# By calendar event description
meetings = client.meetings.list_meetings(calendar_event_description="budget review")
```

A query longer than the stored link (for example the same Meet URL with
`?authuser=0` appended) matches nothing. A generic substring such as
`meet.google.com` can match many meetings.

### Get Meeting Details

```python
detail = client.meetings.get_meeting(123)
print(f"Title: {detail.title}")
print(f"Host: {detail.host.name} ({detail.host.email})")
print(f"Location: {detail.location}")
print(f"Language: {detail.language}")
print(f"Keywords: {', '.join(detail.keywords)}")
print(f"Participants: {detail.participant_count}")
if detail.duration_seconds is not None:
    print(f"Duration: {detail.duration_seconds}s")
else:
    print("Duration: still in progress (end_time is null until the meeting ends)")

if detail.recording:
    print(f"Recording: {detail.recording.status} — {detail.recording.description}")
if detail.transcription:
    print(f"Transcription: {detail.transcription.status} — {detail.transcription.description}")
if detail.summarization:
    print(f"Summarization: {detail.summarization.status} — {detail.summarization.description}")

# Calendar event info (if from calendar integration)
if detail.calendar_event:
    print(f"Calendar: {detail.calendar_event.summary}")
    print(f"Meeting Link: {detail.calendar_event.meeting_link}")
    print(f"Scheduled end: {detail.calendar_event.end_time}")
```

`end_time` and `duration_seconds` stay `None` until the meeting actually ends. For bot meetings, `start_time` is when the bot joined and `end_time` is when it left or failed. The calendar invite's scheduled end is on `calendar_event.end_time`, not the meeting's `end_time`.

`recording`, `transcription`, and `summarization` each have a `status` and a readable `description`. For bot meetings, `recording` follows the bot (pending while joining or waiting, in progress in the call, completed after it leaves). They are optional so older api-service builds that omit them still parse.

These are **processing progress, not the media**: `detail.recording.status == "completed"` says the recording finished, not where to download it — use [`get_recording()`](#get-meeting-recording) for the file itself.

Compare a `status` against the exported `StageStatus` enum rather than a bare string:

```python
from meemo import StageStatus

if detail.transcription and detail.transcription.status == StageStatus.FAILED:
    print(f"Transcription failed: {detail.transcription.description}")
```

`StageStatus` is a `StrEnum` with `PENDING`, `IN_PROGRESS`, `COMPLETED`, `FAILED`, and `CANCELLED`, so it compares equal to the raw strings the API sends. All three stages report `CANCELLED` when a scheduled bot join was cancelled before the bot was sent.

### Get Meeting Transcript

```python
transcript = client.meetings.get_transcript(123)
print(f"Total segments: {transcript.total_segments}")

for segment in transcript.transcripts:
    minutes = int(segment.start_time // 60)
    seconds = int(segment.start_time % 60)
    print(f"[{minutes:02d}:{seconds:02d}] {segment.speaker}: {segment.text}")
```

### Get Meeting Summary

```python
summary = client.meetings.get_summary(123)
print(summary.summary)  # Markdown or legacy JSON
print(f"Notes: {summary.notes}")
print(f"Keywords: {', '.join(summary.keywords)}")

# Handle both new Markdown and legacy JSON formats
if isinstance(summary.summary, str):
    # New Markdown format
    print(summary.summary)
elif isinstance(summary.summary, dict):
    # Legacy JSON format
    print(summary.summary.get("ringkasan", ""))
```

### Get Meeting Summary With Video

Converts the Meemo `(segment:id)` citations in a summary into video timestamp
references for messaging UIs (e.g. GLChat). For raw summary text and keywords,
use `get_summary`.

GLChat has **two mutually exclusive contracts**. Send the field that matches who
is sending the message:

| Sender | Field to send | `media_mapping` |
|--------|---------------|-----------------|
| AIP agent | `inline_message_markdown` (verbatim) | ignore it |
| Native GLChat pipeline | `placeholder_message_markdown` | send it |

An agent cannot supply a `media_mapping`: GLChat derives one from the agent's own
text and mints keys the agent cannot predict, so bracket references coming from
an agent resolve to nothing and are dropped **without an error**. That is why the
agent shape inlines the media markdown per citation instead.

On multi-record meetings the summary here can differ from `get_summary()`, which
always reads the first record. Check `record_id` to see which recording the
citations and video URL are measured against.

```python
rich = client.meetings.get_summary_with_video(123)

print(rich.record_id)  # e.g. 42 — which recording this export describes
print(rich.video_id)  # "video_meeting_123"
print(rich.has_video)        # True when the meeting has a recording (either contract)

# What an AIP agent sends, character for character.
print(rich.inline_message_markdown)

# What a native GLChat pipeline sends, paired with media_mapping.
print(rich.placeholder_message_markdown)
print(rich.media_mapping)

# Resolved citations, unique per distinct set of segment ids.
for citation in rich.citations:
    print(citation.segment_ids, citation.placeholder)

# Convenience: the bare recording URL, parsed out of media_mapping.
# Can be None even when has_video is True, so null-check it before dereferencing.
print(rich.video_url)
```

`has_video` answers "does a recording exist under either contract"; `video_url`
answers "did the pipeline mapping resolve to a URL the SDK could parse". A
`media_mapping` keyed for another `video_id`, or whose value is not `![](...)`
markdown, leaves `has_video` `True` with `video_url` unset.

Both message shapes lead with the video player and are followed by the summary
body. Exactly one player is emitted — a second would register under the same
element key in GLChat and leave the timestamp buttons seeking the wrong video.

Media URLs are signed and time-limited, and are regenerated on every call, so
fetch this close to when the message is actually sent. When the meeting has no
recording, citations degrade to plain labels rather than dead references.

### Regenerate Meeting Summary

Queues the meeting's summary for regeneration and returns at once.

```python
client.meetings.summarize(123)  # None on 202

# Async
await client.meetings.summarize(123)
```

- A `None` result means the request was queued, not that the summary is ready. When it finishes, the `meeting.summarization_completed` webhook fires and `get_summary` returns the new summary. A run that fails sends no webhook, so check the `summarization` stage from `get_meeting`.
- **Not safe to repeat.** Every accepted call starts a new run, so a read timeout is marked not retryable: the request may already have been queued. Check the `summarization` stage before calling again.
- **Write scope.** The token needs the `write` scope (client credentials) or the `external` scope (user-bound). A read-only token gets `MeemoAuthError` (HTTP 403, `permission_denied`), as does a user who can read the meeting but is not its owner or a collaborator. Client credentials can act on any meeting of the application's organizations.
- **Server version.** This endpoint needs a Meemo backend with [meemo#1773](https://github.com/GDP-ADMIN/meemo/issues/1773) (PR [#1874](https://github.com/GDP-ADMIN/meemo/pull/1874)). On an older backend the route does not exist, so `summarize` raises `MeemoNotFoundError`, which looks the same as a missing meeting.

| Error | Raised as | When |
|-------|-----------|------|
| 404 `not_found` | `MeemoNotFoundError` | The meeting does not exist, the caller cannot read it, or it has no recording yet. |
| 409 `summary.already_running` | `MeemoRequestError` | A summary is already queued or in progress. Wait for the webhook, or check the `summarization` stage from `get_meeting`: a run that fails sends no webhook. The SDK cannot force a new run while one is stuck. |
| 428 `summary.transcript_not_ready` | `MeemoRequestError` | Transcription, or the speaker labelling after a live recording, is not finished. Do not retry: the summary is generated automatically once it is. |
| 428 `summary.transcript_failed` | `MeemoRequestError` | Transcription failed, so there is nothing to summarize. Upload the recording again. |

```python
from meemo import MeemoRequestError

try:
    client.meetings.summarize(123)
except MeemoRequestError as e:
    print(f"Not queued ({e.api_code}): {e.message}")
```

### Get Meeting Participants

```python
participants = client.meetings.get_participants(123)
print(f"Total: {participants.total_participants}")

for p in participants.participants:
    if p.id:
        print(f"  {p.name} ({p.email}) - {p.position}, {p.department}")
    else:
        print(f"  {p.name} (external guest)")
```

### Get Meeting Recording

```python
recording = client.meetings.get_recording(123)

# Preferred: iterate over assets (audio and/or video).
if recording.assets:
    for asset in recording.assets:
        print(f"{asset.media_kind}: {asset.url} ({asset.format})")
    print(f"Duration: {recording.duration}s")
else:
    print("No recording available")

# Deprecated back-compat aliases (audio only), populated from the first
# audio asset when not provided directly:
if recording.recording_url:
    print(f"Audio URL: {recording.recording_url} ({recording.format})")

# Convenience aliases for the first video asset when present:
if recording.video_url:
    print(f"Video URL: {recording.video_url} ({recording.video_format})")
```

### Upload Recording

Creates a meeting from an audio or video file and starts its transcription. The file goes straight to Meemo's storage through a presigned URL, so it never passes through the API server, and the SDK streams it, so large files are not read into memory.

```python
# One call: create the meeting, upload the file, and complete the upload
meeting = client.meetings.upload_recording("standup.wav", "Daily standup", organization_id=5)
print(meeting.id, meeting.transcription)

# Bytes need a file name; the content type is guessed from it unless you pass one
meeting = client.meetings.upload_recording(
    data, "Daily standup", 5, filename="standup.mp4", content_type="video/mp4"
)

# Async
meeting = await client.meetings.upload_recording("standup.wav", "Daily standup", 5)
```

To stream the file yourself, run the three steps:

```python
import httpx

started = client.meetings.init_upload(5, "Daily standup", "standup.wav", "audio/wav", size)
# PUT the bytes with exactly these headers before started.expires_at. Do not log upload_url:
# it grants write access until it expires.
httpx.put(started.upload_url, headers=started.upload_headers, content=data).raise_for_status()
meeting = client.meetings.complete_upload(started.record_id)  # before started.complete_by
```

- `upload_recording` takes the same optional fields as `create_meeting` (`topic_id`, `user_token`, `participants`, `speaker_count`, `start_time`), plus `filename` and `content_type`. A path's file name defaults to its base name.
- A path is opened and checked before the meeting is created: an empty file, a directory or another non-regular file raises `MeemoInvalidArgumentError`, and a file that cannot be opened raises `OSError`. The size declared to Meemo comes from the open file, so leave it unchanged until the call returns.
- Meemo checks the extension and the size limit (5 GiB by default) at init, and the duration and whether the file is readable media at completion.
- Transcription runs in the background. Follow it through `get_meeting(meeting.id).transcription`.
- `extra_headers` go to Meemo only. The PUT to storage sends exactly `upload_headers` and nothing else, and the SDK never logs the presigned URL or puts it in an error.

#### Access, errors, and availability

- **Failures after the meeting is created.** If the PUT or the completion fails, `upload_recording` raises the same error it got, with three more fields: `e.meeting_id`, `e.record_id`, and `e.upload_stage` (`"upload"` or `"complete"`). The meeting remains. An upload still waiting at `complete_by` expires and its files are cleaned up. A completion whose response was lost may already have been accepted and continue processing. Other errors, such as an `OSError` while reading the file or a cancelled task, do not carry these fields, but the SDK logs the ids at warning level.
- **Init timeouts.** If `init_upload` (or `upload_recording`'s first step) times out before its response arrives, a meeting may have been created whose ids the SDK never learned. That timeout is marked not retryable.
- **Retrying completion.** Completing an accepted upload again answers the meeting and starts nothing new, so `complete_upload(record_id)` is safe to repeat, and its transport failures stay retryable. `upload_recording` itself never retries.
- **Storage errors.** A PUT that storage answers outside 2xx raises `MeemoRequestError` (any 3xx or 4xx) or `MeemoServerError` (5xx). Storage does not say why, so the message lists possible causes, such as a size or type that differs from what init declared, or an expired URL. A connection failure on the PUT raises the matching transport error, and its message names the storage host, not Meemo. None of these are retryable: `upload_recording` never shows you `upload_url`, so the PUT can't be repeated on its own, and calling `upload_recording` again creates a second meeting. Delete `e.meeting_id`, then call `upload_recording` again.
- **Meemo errors.** `api_code`, `params`, `request_id` and `recovery` are Meemo's:

  | Step | Status | SDK error | `api_code` | Meaning |
  |---|---|---|---|---|
  | init | 400 | `MeemoValidationError` | `upload.cutoff_window` | Uploads are paused for nightly maintenance |
  | init | 400 | `MeemoValidationError` | `validation.*` | Unsupported file name, bad content type, file over the size limit, or an organization, topic or user token the application cannot use |
  | complete | 409 | `MeemoRequestError` | `upload.file_missing` | No file has been PUT yet |
  | complete | 409 | `MeemoRequestError` | `upload.expired` | The upload passed `complete_by` |
  | complete | 409 | `MeemoRequestError` | `upload.failed` | The file was already rejected |
  | complete | 400 | `MeemoValidationError` | `upload.size_mismatch` | The stored file is not the declared size |
  | complete | 400 | `MeemoValidationError` | `upload.invalid_media` | Not readable audio or video |
  | complete | 400 | `MeemoValidationError` | `upload.duration_exceeded` | Too long; `params["max_hours"]` is the limit |
  | complete | 503 | `MeemoServerError` | `upload.unreadable` | Storage cannot be read right now; retryable |
  | complete | 404 | `MeemoNotFoundError` | `not_found` | No upload of yours has this `record_id` |
  | both | 403 | `MeemoAuthError` | `permission_denied` | The token lacks the `write` or `external` scope |

- **Access.** Client credentials see their organizations' uploads; a user-bound token sees only its own. User-bound tokens are not supported by this SDK yet ([meemo#1781](https://github.com/GDP-ADMIN/meemo/issues/1781)).
- **Server version.** These endpoints need a Meemo backend with [meemo#1774](https://github.com/GDP-ADMIN/meemo/issues/1774) (PR [#1920](https://github.com/GDP-ADMIN/meemo/pull/1920)). On an older backend, expect `MeemoNotFoundError` or another `MeemoAPIError` from init.

```python
from meemo import MeemoError

try:
    meeting = client.meetings.upload_recording("standup.wav", "Daily standup", 5)
except MeemoError as e:
    if e.upload_stage == "complete" and e.recovery["retryable"]:
        meeting = client.meetings.complete_upload(e.record_id)
    else:
        raise
```

### Update Meeting

Renames a meeting and replaces its participants. Either field can be sent alone, or both together; the call returns the updated `MeetingDetail`.

```python
# Title only
meeting = client.meetings.update_meeting(123, title="Q1 planning")

# Participants only: this is the complete list, not an addition
meeting = client.meetings.update_meeting(123, participants=["a@example.com", "b@example.com"])

# Both
meeting = client.meetings.update_meeting(
    123, title="Q1 planning", participants=["a@example.com"]
)
print(meeting.title, meeting.participant_count)

# Remove every participant
client.meetings.update_meeting(123, participants=[])

# Async
meeting = await client.meetings.update_meeting(123, title="Q1 planning")
```

- A field left out, or `None`, stays as it is. Calling with neither sends an empty body: nothing changes, but the call still needs write access and moves `modified_at`.
- `participants` replaces the whole list. `[]` (or `()`) clears it. The SDK only checks that it is a list or tuple of strings and sends the emails in the order, letter case and repeats you gave; Meemo validates them, and counts repeated emails once in any letter case.
- An email of exactly one active member of the meeting's organization adds that member. **Any other email adds a guest**, reusing the meeting's guest with that email or creating one, so a well-formed typo succeeds. Check the spelling before you send.
- People who stay keep their status. People who are removed can lose access they had only as a participant, and their speaker labels on the meeting's first recording are cleared. The transcript text stays.
- The title can be up to 255 characters and cannot be blank. Meemo checks it.

#### Access, errors, and availability

- **All or nothing.** If anything is refused, the call raises `MeemoValidationError` (HTTP 400) and neither the title nor the participants change. Meemo refuses a blank or too long title, a malformed or too long email, an email that matches several members (`validation.participant_ambiguous`), and an email that matches several guests of the meeting (`validation.guest_ambiguous`).
- **Finding the refused email.** `e.params["fields"]["participants"]` maps the position of every refused email in the list you sent, as a string, to its messages, and `e.params["fields"]["title"]` holds the title's. `e.api_code` and `e.message` describe only the first refusal.
- **Repeating.** Sending the same list again does not add participants. Permissions and concurrent changes can differ between attempts, though, so a repeat can raise `MeemoNotFoundError` or `MeemoAuthError`. The SDK does not retry for you, and a timeout after the request was sent is marked not retryable: read the meeting before you send it again.
- **Write scope.** The token needs the `write` scope (client credentials) or the `external` scope (user-bound). A read-only token gets `MeemoAuthError` (HTTP 403, `permission_denied`).
- **Access.** Client credentials can act on any meeting of the application's organizations. A user-bound token follows the Meemo web app's rule: the meeting's owner, its collaborators and Django staff can edit it, while an organization admin, a participant, or someone with view-only access can read the meeting but gets `MeemoAuthError` (HTTP 403, `permission_denied`). A meeting the caller cannot read raises `MeemoNotFoundError`. User-bound tokens are not supported by this SDK yet ([meemo#1781](https://github.com/GDP-ADMIN/meemo/issues/1781)).
- **Server version.** This endpoint needs a Meemo backend with [meemo#1772](https://github.com/GDP-ADMIN/meemo/issues/1772) (PR [#1912](https://github.com/GDP-ADMIN/meemo/pull/1912)). On an older backend the meeting route does not accept `PATCH`, so expect a `MeemoAPIError` for an unsupported method (most likely HTTP 405); this has not been tested against an older backend.

```python
from meemo import MeemoValidationError

emails = ["a@example.com", "not-an-email"]
try:
    client.meetings.update_meeting(123, title="Q1 planning", participants=emails)
except MeemoValidationError as e:
    refused = ((e.params or {}).get("fields") or {}).get("participants") or {}
    for position, messages in refused.items():
        print(f"{emails[int(position)]}: {' '.join(messages)}")
```

### Delete Meeting

```python
client.meetings.delete_meeting(123)  # None on 204

# Async
await client.meetings.delete_meeting(123)
```

- If the meeting's bot is in the call, it is removed first. The removal is asynchronous, so the bot can take a moment to actually leave.
- Recordings stay under the server's retention policy.
- Deleting a meeting that is already gone raises `MeemoNotFoundError`. A timeout on `delete_meeting` is marked retryable, and if the retry raises `MeemoNotFoundError`, the first attempt most likely deleted the meeting.

### Cancel Meeting Bot

Stops the meeting's bot and keeps the meeting, for example when an interview is rescheduled or cancelled.

```python
client.meetings.cancel_bot(123)  # None on 204

# Async
await client.meetings.cancel_bot(123)
```

| Meeting state | Result |
|---------------|--------|
| Bot scheduled to join, not yet sent | The join is cancelled and no bot is sent. `recording`, `transcription`, and `summarization` report `StageStatus.CANCELLED`. |
| Bot in the call | The bot is asked to leave. `None` means the leave request was accepted or the bot is already gone; it can take a moment to actually leave, and its status and recording keep updating until it does. |
| Bot already cancelled or already left | `None` again, with no change. Repeating the call is safe. |
| No bot and no scheduled join | `MeemoNotFoundError` |

#### Access, errors, and availability

- **Write scope.** The token needs the `write` scope (client credentials) or the `external` scope (user-bound). A read-only token gets `MeemoAuthError` (HTTP 403, `permission_denied`).
- **Access.** Client credentials can act on any meeting of the application's organizations. A user-bound token follows the Meemo web app's rule: the meeting's owner and collaborators can delete it or cancel its bot, while an organization admin, a participant, or someone with view-only access can read the meeting but gets `MeemoAuthError` (HTTP 403, `permission_denied`). User-bound tokens are not supported by this SDK yet ([meemo#1781](https://github.com/GDP-ADMIN/meemo/issues/1781)).
- **404s look alike.** A missing meeting, a meeting the caller cannot read, and (for `cancel_bot`) a meeting with no bot or scheduled join all raise `MeemoNotFoundError` with `api_code == "not_found"`. The code does not tell them apart. Both 403 cases share `permission_denied` too; only `e.message` differs.
- **Bot removal can fail.** If the bot cannot be removed and is not known to have left, both methods raise `MeemoServerError` with `api_code == "bot.remove_failed"` and `e.recovery["retryable"]` set to `True`. The meeting is kept. The SDK does not retry for you.
- **Server version.** These endpoints need a Meemo backend with [meemo#1770](https://github.com/GDP-ADMIN/meemo/issues/1770) (PR [#1884](https://github.com/GDP-ADMIN/meemo/pull/1884)). On an older backend, `delete_meeting` raises `MeemoRequestError` (HTTP 405), and `cancel_bot` raises `MeemoNotFoundError` because the route does not exist, which looks the same as a meeting with no bot.

```python
from meemo import MeemoAuthError, MeemoNotFoundError, MeemoServerError

try:
    client.meetings.cancel_bot(123)
except MeemoAuthError as e:
    print(f"Not allowed to change this meeting: {e.message}")
except MeemoNotFoundError:
    print("No such meeting, no read access to it, or no bot to cancel")
except MeemoServerError as e:
    if e.api_code != "bot.remove_failed":
        raise
    print(f"Bot not removed, meeting kept; retry later (request {e.request_id})")
```

### Share Meeting

Gives members of the meeting's organization access to it.

```python
from meemo import SharingType

# View-only is the default
grants = client.meetings.share_meeting(123, ["member@example.com"])

# Let the member change the meeting and manage its shares
grants = client.meetings.share_meeting(
    123,
    ["member@example.com"],
    sharing_type=SharingType.COLLABORATOR,
)
for grant in grants:
    print(grant.user.id, grant.user.display_name, grant.user.email, grant.sharing_type)

# Async
grants = await client.meetings.share_meeting(123, ["member@example.com"])
```

| `SharingType` | Sent as | Access |
|---------------|---------|--------|
| `VIEW_ONLY` (default) | `"VIEW_ONLY"` | Can read the meeting. |
| `COLLABORATOR` | `"COLLABS"` | Can also change the meeting and manage its shares. |

- Each email must belong to an active member of the meeting's organization. Meemo matches it to the member's account or identity email in any letter case. The SDK only checks that `emails` is a non-empty list of strings; Meemo decides whether each email is valid and who it belongs to.
- Sharing with the meeting's owner, or with the user of your own token, is refused: they already manage the meeting.
- Sharing again with a member who already has a share updates its type. Every other share is kept.
- The result has one `SharedTo` per email, in the order you sent them, repeated emails included. `user.email` is the member's account email, which can differ from the email you sent when that was an alias, so match results to your input by position, not by comparing emails.

### Unshare Meeting

Removes the shares of the given emails and keeps every other share.

```python
client.meetings.unshare_meeting(123, ["member@example.com"])  # None on 204

# Async
await client.meetings.unshare_meeting(123, ["member@example.com"])
```

- Only the share is removed. Access the member has another way, such as an owner or a participant, stays.
- A share is removed even when its member has since left the organization.
- An email of a member who has no share changes nothing and is not an error.

#### Access, errors, and availability

- **All or nothing.** If any email is refused, the call raises `MeemoValidationError` (HTTP 400) and no share changes. Share refuses an email that matches no active member (`validation.recipient_not_found`), matches several members (`validation.recipient_ambiguous`), or is the owner's (`validation.recipient_is_owner`) or your own (`validation.recipient_is_caller`). Unshare refuses only an email that matches no member or several.
- **Finding the refused email.** `e.params["fields"]["emails"]` maps the position of every refused email in the list you sent, as a string, to its messages. `e.api_code` and `e.message` describe only the first refused email.
- **Repeating is safe.** Share updates the same shares and unshare ignores a share that is already gone, so repeating after a timeout is fine. Permissions can change between attempts, though: a repeat can raise `MeemoNotFoundError` or `MeemoAuthError` where the first attempt would have succeeded, for example after a collaborator removed their own share. The SDK does not retry for you, and a timeout after the request was sent is marked not retryable.
- **Write scope.** The token needs the `write` scope (client credentials) or the `external` scope (user-bound). A read-only token gets `MeemoAuthError` (HTTP 403, `permission_denied`).
- **Access.** Client credentials can act on any meeting of the application's organizations. A user-bound token follows the Meemo web app's rule: the meeting's owner and collaborators can share and unshare, while an organization admin, a participant, or someone with view-only access can read the meeting but gets `MeemoAuthError`. A meeting the caller cannot read raises `MeemoNotFoundError`. User-bound tokens are not supported by this SDK yet ([meemo#1781](https://github.com/GDP-ADMIN/meemo/issues/1781)).
- **Server version.** These endpoints need a Meemo backend with [meemo#1771](https://github.com/GDP-ADMIN/meemo/issues/1771) (PR [#1905](https://github.com/GDP-ADMIN/meemo/pull/1905)). On an older backend the routes do not exist, so both methods raise `MeemoNotFoundError`, which looks the same as a missing meeting.

```python
from meemo import MeemoValidationError

emails = ["member@example.com", "typo@example.com"]
try:
    client.meetings.share_meeting(123, emails)
except MeemoValidationError as e:
    refused = ((e.params or {}).get("fields") or {}).get("emails") or {}
    for position, messages in refused.items():
        print(f"{emails[int(position)]}: {' '.join(messages)}")
```

### Record a Meeting Live

Creates a meeting to record live from a microphone and returns a single-use ticket for its WebSocket. The SDK does not stream audio: your client captures the microphone and streams it over the WebSocket itself, with the same protocol as the Meemo web app.

```python
# Live recording needs a user-bound token (see User Tokens above)
client = MeemoClient(base_url=BASE_URL, access_token=user_access_token)

session = client.meetings.create_live_meeting(
    title="Weekly standup",
    topic_id=3,  # optional: defaults to the organization's default topic
    participants=["member@example.com"],  # optional: an unknown email is added as a guest
)
ws_url = f"{session.ws_url}?ticket={session.ticket}"  # open within session.expires_in seconds

# After the connection drops, or to resume a paused recording, get a new ticket
session = client.meetings.get_live_ticket(session.meeting_id)

# Stop the recording; it is saved and finished in the background
client.meetings.stop_live_meeting(session.meeting_id)  # None on 204

# Async
session = await async_client.meetings.create_live_meeting(title="Weekly standup")
session = await async_client.meetings.get_live_ticket(session.meeting_id)
await async_client.meetings.stop_live_meeting(session.meeting_id)
```

- `LiveSession` has the `meeting_id`, the `ws_url`, the `ticket` and its lifetime in seconds, `expires_in`. A ticket opens one connection only: a reused or expired ticket is closed with code `4401`, and a ticket used on another meeting's WebSocket with code `4403`. The ticket is left out of `repr()`; do not log it, and never put the access token in the WebSocket URL.
- The meeting is created in the token user's active organization and hosted by them.
- `get_live_ticket` and `stop_live_meeting` are safe to repeat, so a timeout on them is marked retryable. Stopping a meeting with no live recording in progress changes nothing and still returns `None`.
- **Access.** Only a token issued for a connected user can record live. A client credentials token raises `MeemoAuthError` (HTTP 403, `api_code == "live_recording.user_token_required"`) on all three methods. The meeting's owner and its collaborators can get a ticket and stop the recording; an organization admin, a participant or someone with view-only access gets `MeemoAuthError` with `permission_denied`, and a meeting the user cannot read raises `MeemoNotFoundError`. Only the owner can start, pause, resume or stop the recording over the WebSocket; a collaborator's socket follows the live transcript.
- **Refused requests** to `create_live_meeting` raise `MeemoValidationError` (HTTP 400): a blank or too long title, a topic outside the user's organization, or a malformed email. `e.params["fields"]` maps each refused field to its messages.
- An email that matches several members (in any letter case) raises `MeemoValidationError` with `api_code == "validation.recipient_ambiguous"`; send a different address for that participant.
- **Ticket unavailable.** `create_live_meeting` and `get_live_ticket` raise `MeemoServerError` (HTTP 503, `api_code == "live_recording.ticket_unavailable"`, retryable) when the ticket could not be stored. `create_live_meeting` rolls the new meeting back, so trying again creates no duplicate.
- **Server version.** These endpoints need a Meemo backend with [meemo#1775](https://github.com/GDP-ADMIN/meemo/issues/1775).

### Get Upcoming Meetings

```python
response = client.calendar.get_upcoming_meetings(limit=5)

if not response.integrated:
    print("No calendar is connected.")
for meeting in response.results:
    print(meeting.start_time, meeting.summary, meeting.meeting_link)

# Async
response = await client.calendar.get_upcoming_meetings(limit=5)
```

`get_upcoming_meetings(organization_id=None, limit=None)` returns confirmed future calendar events that have a meeting link, earliest first. `limit` defaults to 10 on the server and is capped at 100.

#### Access, errors, and availability

- **Client credentials.** Reads the organizations linked to the application. `organization_id` narrows it to one of them, and `limit` applies across all of them.
- **User-bound token.** Reads only the user's active organization, whatever the application is linked to. Every active member role can read it, and the user sees every eligible event in the organization, including events they neither organize nor attend. This matches the Meemo web app's Upcoming Meetings panel. Reading recorded meetings is unaffected and still follows the user's own access.
- **Other organizations.** An `organization_id` other than the user's raises `MeemoAuthError` (HTTP 403, `permission_denied`). A malformed one raises `MeemoValidationError` (HTTP 400, `params["reason"]` is `invalid_organization_id`). A `limit` that is not a positive integer raises `MeemoInvalidArgumentError` before any request is sent.
- **Empty results.** An organization without an active calendar integration returns `integrated=False` and no results. An active integration with no eligible events returns `integrated=True` and no results. `integrated` covers every accessible organization, even when `organization_id` narrows the results, so it can be `True` while `results` is empty.
- **Revoked access.** After the user, their membership, their organization or the external application is deactivated, the next call raises `MeemoAuthError`. An expired token raises `MeemoTokenExpiredError`; see [User Tokens (OAuth)](#user-tokens-oauth).
- **Server version.** A user token needs a Meemo backend with [meemo#1897](https://github.com/GDP-ADMIN/meemo/issues/1897) (PR [#1929](https://github.com/GDP-ADMIN/meemo/pull/1929)). On an older backend the call raises `MeemoAuthError` (HTTP 403), as the endpoint was system-only. Webhooks, webhook events and usage stay system-only.

## Organization Settings

Reads and changes how the meeting bot behaves for an organization: Auto Join per platform, whether the bot joins as a logged-in account, and the bot's display name. These are the settings an admin changes in the Meemo web app.

| Setting | Type | Meaning |
|---------|------|---------|
| `auto_join_google_meet`, `auto_join_ms_teams`, `auto_join_zoom` | `bool` | Auto Join meetings of that platform from the connected calendar |
| `google_meet_use_login`, `ms_teams_use_login` | `bool` | Join as a logged-in account instead of an anonymous bot |
| `baas_bot_name` | `str \| None` | Display name of the meeting bot, up to 255 characters. `None` uses the default name |

```python
# Read the settings
settings = client.organizations.get_settings()
print(settings.auto_join_google_meet, settings.baas_bot_name)

# Change only the settings you pass; the call returns the full settings after the change
settings = client.organizations.update_settings(
    auto_join_google_meet=False, google_meet_use_login=True
)

# Clear the bot name, so the default name is used
client.organizations.update_settings(baas_bot_name=None)

# Act on one organization when the application is linked to several
settings = client.organizations.get_settings(organization_id=5)
client.organizations.update_settings(organization_id=5, auto_join_zoom=False)

# Async
settings = await client.organizations.get_settings()
```

- `organization_id` is sent as a query parameter. It is optional when the application is linked to one organization, and required when it is linked to several; leaving it out then raises `MeemoValidationError` with `api_code == "organization.id_required"`.
- A setting left out stays as it is. Every change is recorded in the organization's activity log with the user, if any, and the application that made it.
- **Access.** A client credentials token acts on the organizations linked to its application. A token issued for a connected user works only when that user is an `Admin` of the organization. Changing settings also needs the `write` scope (client credentials) or the `external` scope (user-bound). A `organization_id` the application cannot reach, a user who is not an `Admin`, or a token without the write scope raises `MeemoAuthError` (HTTP 403, `permission_denied`).
- **Refused requests** raise `MeemoValidationError` (HTTP 400): `validation.unknown_field` for a key that is not one of the settings above, and a `validation.*` code for an invalid value such as a non-boolean Auto Join value or a bot name over 255 characters. `e.params["fields"]` maps each refused setting to its messages.
- **Server version.** These routes need a Meemo backend with [meemo#1783](https://github.com/GDP-ADMIN/meemo/issues/1783) (PR [#1918](https://github.com/GDP-ADMIN/meemo/pull/1918)). On an older backend the routes do not exist, so both methods raise `MeemoNotFoundError`; this has not been tested against an older backend.

## Calendar Setup

Connects the Google or Microsoft calendar an organization's bot auto-joins from: meetings where the connected account is invited, or is the organizer, are auto-joined. These are the steps an admin takes in the Meemo web app under **Preferences → Calendar Integration**.

```python
from meemo import CalendarProvider

# 1. Start connecting; send the admin to the consent URL
consent = client.calendar.connect(
    "https://your-app.example.com/meemo/calendar/callback",
    provider=CalendarProvider.GOOGLE,  # or CalendarProvider.MICROSOFT
)
print(consent.url)

# 2. After consent, list what was granted and complete the connection
integrations = client.calendar.list_integrations()
calendars = client.calendar.list_calendars()
calendar_id = next((c.id for c in calendars if c.primary), calendars[0].id)  # prefer the primary
connected = client.calendar.select_calendar(
    integrations[0].id, calendar_id, connected_email=integrations[0].user_identifier
)

# 3. Check, sync and disconnect
status = client.calendar.get_status()
client.calendar.sync()
client.calendar.disconnect(connected.id)

# Async
status = await client.calendar.get_status()
```

- `callback_url` must start with a prefix the Meemo deployment allows; any other URL raises `MeemoValidationError` with `api_code == "calendar.callback_url_not_allowed"`. An unknown provider raises `calendar.provider_unsupported`.
- `list_integrations` and `list_calendars` return `[]` when no connect is pending. `select_calendar` and `sync` raise `MeemoNotFoundError` when there is nothing to select or sync, and `disconnect` when the calendar is not the organization's.
- `organization_id` is sent as a query parameter on every step. It is optional when the application is linked to one organization, and required when it is linked to several; leaving it out then raises `MeemoValidationError` with `api_code == "organization.id_required"`.
- **Access.** A client credentials token acts on the organizations linked to its application. A token issued for a connected user works only when that user is an `Admin` (or `Trial`) member of the organization. `connect`, `select_calendar`, `sync` and `disconnect` also need the `write` scope (client credentials) or the `external` scope (user-bound). Otherwise they raise `MeemoAuthError` (HTTP 403, `permission_denied`).
- **Provider failures** raise `MeemoServerError` (`calendar.connect_failed`, `calendar.provider_unavailable`, `calendar.list_failed`, `calendar.disconnect_failed`); retry. A failed disconnect keeps the calendar connected. `calendar.not_configured` means calendar integration is not available on that server.
- **Server version.** These routes need a Meemo backend with [meemo#1782](https://github.com/GDP-ADMIN/meemo/issues/1782) (PR [#1923](https://github.com/GDP-ADMIN/meemo/pull/1923)).

## Inviting Members

Invites a person into an organization, the way an admin does in the Meemo web app: the account is created and active straight away, joins with the role you choose, and the invitee is emailed their sign-in details.

```python
from meemo import MeemoClient, MemberRole

# The invite needs a user-bound token of an organization Admin or Trial member (see User Tokens above)
client = MeemoClient(base_url=BASE_URL, access_token=admin_access_token)

invitation = client.members.invite(
    email="new.hire@example.com",
    username="new-hire",
    first_name="Nadia",
    last_name="Hartono",
    position="Analyst",  # optional
    department="Finance",  # optional
    role=MemberRole.MEMBER,  # optional: MemberRole.MEMBER (default) or MemberRole.ADMIN
)
print(invitation.id, invitation.invitee.full_name, invitation.organization.name)

# Async
invitation = await async_client.members.invite(
    email="new.hire@example.com", username="new-hire", first_name="Nadia", last_name="Hartono"
)
```

- The invitee always joins the active organization of the token's user. There is no `organization_id`.
- **Access.** Only a token issued for a connected user who is an `Admin` or `Trial` member of their active organization can invite, and it needs the `external` scope of a user-bound token (or `write`). A client credentials token raises `MeemoAuthError` (HTTP 403, `api_code == "member.invite_user_token_required"`). A user who is neither, an inactive account, or a token without the write scope raises `MeemoAuthError` with `permission_denied`.
- **Refused requests** raise `MeemoValidationError` (HTTP 400, a `validation.*` code): a missing or invalid field, an email that already belongs to a user or was already invited, or a username that is taken. `e.params["fields"]` maps each refused field to its messages. A `role` other than `Admin` or `Member` raises `MeemoInvalidArgumentError` before any request.
- If the invitation email cannot be sent, the call raises `MeemoServerError` with `api_code == "member.invite_email_failed"` and `e.recovery["retryable"] is True`. Nothing is kept, so send the invitation again.
- The returned `Invitation` has `status == 1` (accepted), the `invitee` and `created_by` users, the `organization`, and the `position` and `department`.
- **Server version.** This route needs a Meemo backend with [meemo#1769](https://github.com/GDP-ADMIN/meemo/issues/1769) (PR [#1924](https://github.com/GDP-ADMIN/meemo/pull/1924)).

## Multi-Organization Support

External applications can access meetings from multiple organizations with a single set of credentials.

```python
# Get meetings from all accessible organizations
all_meetings = client.meetings.list_meetings()

# Filter to a specific organization
org_meetings = client.meetings.list_meetings(organization_id=5)
```

## Error Handling

API and transport failures raise typed `MeemoError` subclasses (also
`httpx.HTTPStatusError` / `ValueError` / `TypeError` where that matched before).
`e.message` / `str(e)` is Meemo's message (no request URL). Meemo's own code is
on `e.api_code`.

```python
from meemo import (
    MeemoAPIError,
    MeemoAuthError,
    MeemoClient,
    MeemoInvalidArgumentError,
    MeemoNotFoundError,
)

client = MeemoClient(
    client_id="your-client-id",
    client_secret="your-client-secret",
    base_url="https://api-meemo.glair.ai",
)

try:
    detail = client.meetings.get_meeting(999)
except MeemoAuthError as e:
    print("Authentication failed - check your credentials")
    print(e.api_code, e.request_id)
except MeemoNotFoundError as e:
    print(e.message, e.api_code, e.request_id)
except MeemoAPIError as e:
    print(e.message, e.api_code, e.request_id, e.recovery)
except MeemoInvalidArgumentError as e:
    print(f"Invalid argument: {e}")
```

## Testing

### Unit tests

```bash
cd libs/meemo
uv sync --group dev
uv run pytest tests/unit_tests/ -v
```

### Integration tests (live)

The integration tests exercise a real Meemo instance. Copy `.env.example` to `.env`, fill in your Meemo OAuth credentials, then:

```bash
uv run pytest tests/integration_tests/ -v
```

Optional flags in `.env` or the shell:

- `MEEMO_TEST_CREATE_MEETING=1` — also run the tests that create real meetings (off by default): sync and async create/delete round-trips, and sync and async bots scheduled an hour ahead that are cancelled twice and then deleted. They need a backend with the delete and cancel-bot endpoints ([meemo#1770](https://github.com/GDP-ADMIN/meemo/issues/1770), PR [#1884](https://github.com/GDP-ADMIN/meemo/pull/1884)) and fail on an older one. Run them only against a test environment. Each test deletes what it created; if that delete fails, teardown fails with the meeting ID so you can remove it by hand.
  With the flag set, the sync and async upload round-trips also run: they upload a one-second silent WAV the test generates to meetings they create, through both `init_upload`/PUT/`complete_upload` and `upload_recording`, and delete those meetings. They need a backend with the upload endpoints ([meemo#1774](https://github.com/GDP-ADMIN/meemo/issues/1774), PR [#1920](https://github.com/GDP-ADMIN/meemo/pull/1920)) and fail on an older one.
- `MEEMO_TEST_SHARE_EMAIL` — with `MEEMO_TEST_CREATE_MEETING=1`, also run the sync and async share/unshare and update round-trips. Set it to the email of an active member of the organization the test meeting is created in (the first one `list_available_organizations` returns) who does not own the meeting. Each test changes a meeting it created, so no existing meeting is touched, and deletes the meeting afterwards. The share tests need a backend with the sharing endpoints ([meemo#1771](https://github.com/GDP-ADMIN/meemo/issues/1771), PR [#1905](https://github.com/GDP-ADMIN/meemo/pull/1905)), and the update tests need [meemo#1772](https://github.com/GDP-ADMIN/meemo/issues/1772) (PR [#1912](https://github.com/GDP-ADMIN/meemo/pull/1912)); each fails on an older backend. The update tests first resolve that email with `share_meeting`, which refuses an email that matches no member, so a mistyped address fails before `update_meeting` can add it as a guest identity that cannot be deleted. Unset, they are skipped.

## API Reference

- [Meemo External API Documentation](https://gdplabs.gitbook.io/meemo/resources/external-api-documentation)

## License

MIT License.
