v2: The GraphQL API¶
The SDK talks to two different Bloom Growth APIs, both reachable from the same
Client/AsyncClient instance:
client.v1— the REST API at/api/v1. This is the API the SDK has always exposed. Nothing about it changed.client.v2— the GraphQL API at/graphql/. It covers the same entities (users, meetings, issues, headlines, todos, goals, milestones, metrics) through a different transport, with some behavior that does not match v1 one-to-one.
This guide explains how the two fit together, tours every client.v2
namespace and method, and covers the behavior that is specific to v2:
description ("notes") handling, archive/delete semantics per entity, metric
score/week semantics, and GraphQLError.
v1 vs v2¶
v1 (REST, unchanged)¶
client.v1.user, client.v1.meeting, client.v1.todo, client.v1.goal,
client.v1.scorecard, client.v1.issue, and client.v1.headline are the
same operation classes the SDK has always had. The top-level attributes you
may already be using — client.user, client.meeting, client.todo, and so
on — are aliases pointing at the exact same objects as client.v1.*, kept for
backward compatibility:
from bloomy import Client
client = Client(api_key="your-api-key")
client.meeting.list() is client.v1.meeting.list # same bound method, same instance
client.meeting is client.v1.meeting # True
Existing code that uses client.user, client.meeting.create(), and so on
keeps working exactly as before. See the Basic Usage guide and the
v1 API Reference for those operations.
v2 (GraphQL, new)¶
client.v2.user, client.v2.meeting, client.v2.issue, client.v2.headline,
client.v2.todo, client.v2.goal, client.v2.milestone, and
client.v2.metric are new operation classes that send GraphQL documents
instead of REST requests. They:
- Use the same API key and the same underlying HTTP client as v1 — no
separate authentication step. A v2 request posts to an absolute URL, which
overrides the client's
base_urlfor that one request. - Default their GraphQL endpoint to the scheme and host of
base_urlplus/graphql/(e.g.https://app.bloomgrowth.com/graphql/). Passgraphql_urlexplicitly if your GraphQL endpoint lives somewhere else:
from bloomy import Client
client = Client(
api_key="your-api-key",
graphql_url="https://app.bloomgrowth.com/graphql/",
)
client.v2.meeting.details(123)
- Return the same kind of Pydantic models as v1 (typed, IDE-friendly), but
the models themselves are different classes (
bloomy.v2.models.Issue, notbloomy.models.IssueDetails) with different fields. See Models for the field-naming conventions. - Raise
GraphQLError(a subclass ofAPIError) instead of a plainAPIErrorfor failed requests. See GraphQLError below.
import asyncio
from bloomy import AsyncClient
async def main():
async with AsyncClient(api_key="your-api-key") as client:
# v1 (REST) — unchanged
meetings = await client.meeting.list()
# v2 (GraphQL) — new
meeting = await client.v2.meeting.details(meetings[0].id)
issues = await client.v2.issue.list(meeting.id)
asyncio.run(main())
v1 and v2 can be mixed freely in the same script: they share one client, one API key, and one connection pool.
Quick tour of client.v2¶
Every namespace below exposes a sync class (client.v2.<name>) and an async
mirror with identical method signatures (awaited). Full parameter and
return-type documentation lives on each entity's API reference page.
| Namespace | Sync class | Async class | Model(s) |
|---|---|---|---|
client.v2.user |
UserOperations |
AsyncUserOperations |
User |
client.v2.meeting |
MeetingOperations |
AsyncMeetingOperations |
Meeting, MeetingListItem |
client.v2.issue |
IssueOperations |
AsyncIssueOperations |
Issue |
client.v2.headline |
HeadlineOperations |
AsyncHeadlineOperations |
Headline |
client.v2.todo |
TodoOperations |
AsyncTodoOperations |
Todo |
client.v2.goal |
GoalOperations |
AsyncGoalOperations |
Goal, Milestone |
client.v2.milestone |
MilestoneOperations |
AsyncMilestoneOperations |
Milestone |
client.v2.metric |
MetricOperations |
AsyncMetricOperations |
Metric, MetricScore |
client.v2.user¶
| Method | Signature | Description |
|---|---|---|
details() |
details(user_id=None) -> User |
Get a user. Defaults to the current user. |
list() |
list() -> list[User] |
List every user in the organization. |
client.v2.meeting¶
| Method | Signature | Description |
|---|---|---|
list() |
list(user_id=None) -> list[MeetingListItem] |
List meetings a user attends. Defaults to the current user. |
details() |
details(meeting_id) -> Meeting |
Get a meeting, including its attendees. |
attendees() |
attendees(meeting_id) -> list[User] |
List a meeting's attendees. |
client.v2.issue¶
| Method | Signature | Description |
|---|---|---|
details() |
details(issue_id) -> Issue |
Get an issue. |
list() |
list(meeting_id, *, long_term=False, include_solved=False, include_archived=False) -> list[Issue] |
List a meeting's issues. |
create() |
create(meeting_id, title, user_id=None, notes=None, long_term=False) -> Issue |
Create an issue. |
update() |
update(issue_id, *, title=None, user_id=None, notes=None, long_term=None, meeting_id=None) -> Issue |
Update an issue. |
solve() |
solve(issue_id) -> Issue |
Mark an issue as solved. |
reopen() |
reopen(issue_id) -> Issue |
Reopen a solved issue. |
archive() |
archive(issue_id) -> Issue |
Archive an issue. |
restore() |
restore(issue_id) -> Issue |
Restore an archived (short-term) issue. |
client.v2.headline¶
| Method | Signature | Description |
|---|---|---|
details() |
details(headline_id) -> Headline |
Get a headline. |
list() |
list(meeting_id=None, user_id=None, *, include_archived=False) -> list[Headline] |
List headlines for a meeting or a user (mutually exclusive). |
create() |
create(meeting_id, title, user_id=None, notes=None) -> Headline |
Create a headline. |
update() |
update(headline_id, *, title=None, user_id=None, notes=None) -> Headline |
Update a headline. |
archive() |
archive(headline_id) -> Headline |
Archive a headline. |
restore() |
restore(headline_id) -> Headline |
Restore an archived headline. |
client.v2.todo¶
| Method | Signature | Description |
|---|---|---|
details() |
details(todo_id) -> Todo |
Get a to-do. |
list() |
list(meeting_id=None, user_id=None, *, include_completed=False, include_archived=False) -> list[Todo] |
List to-dos for a meeting or a user (mutually exclusive). |
create() |
create(title, meeting_id=None, user_id=None, due_date=None, notes=None) -> Todo |
Create a to-do. Omit meeting_id for a personal to-do. |
update() |
update(todo_id, *, title=None, due_date=None, user_id=None, notes=None) -> Todo |
Update a to-do. |
complete() |
complete(todo_id) -> Todo |
Mark a to-do as complete. |
reopen() |
reopen(todo_id) -> Todo |
Reopen a completed to-do. |
archive() |
archive(todo_id) -> Todo |
Archive a to-do. |
restore() |
restore(todo_id) -> Todo |
Restore an archived to-do. |
client.v2.goal¶
| Method | Signature | Description |
|---|---|---|
list() |
list(meeting_id=None, user_id=None, *, include_archived=False) -> list[Goal] |
List goals for a meeting or a user (mutually exclusive). |
details() |
details(goal_id) -> Goal |
Get a goal, including its milestones. |
create() |
create(meeting_id, title, user_id=None, due_date=None, status=GoalStatus.ON_TRACK, notes=None, milestones=None) -> Goal |
Create a goal, optionally with milestones. |
update() |
update(goal_id, *, title=None, status=None, due_date=None, user_id=None, notes=None) -> Goal |
Update a goal. |
archive() |
archive(goal_id) -> Goal |
Archive a goal. |
restore() |
restore(goal_id) -> Goal |
Restore an archived goal. |
client.v2.milestone¶
| Method | Signature | Description |
|---|---|---|
list() |
list(goal_id) -> list[Milestone] |
List a goal's milestones. |
create() |
create(goal_id, title, due_date, completed=False) -> Milestone |
Create a milestone on a goal. |
update() |
update(milestone_id, *, goal_id, title=None, due_date=None, completed=None) -> Milestone |
Update a milestone. Requires goal_id (keyword-only) — see below. |
complete() |
complete(milestone_id, *, goal_id) -> Milestone |
Mark a milestone as completed. Requires goal_id (keyword-only). |
delete() |
delete(milestone_id) -> None |
Delete a milestone. No goal_id needed. |
client.v2.metric¶
| Method | Signature | Description |
|---|---|---|
details() |
details(metric_id) -> Metric |
Get a metric. |
list() |
list(meeting_id=None, user_id=None, *, frequency=None) -> list[Metric] |
List metrics for a meeting or a user (mutually exclusive). |
create() |
create(meeting_id, title, user_id=None, goal=None, units=MetricUnit.NONE, rule=MetricRule.GREATER_THAN, frequency=MetricFrequency.WEEKLY, min_goal=None, max_goal=None, notes=None) -> Metric |
Create a metric. |
update() |
update(metric_id, *, title=None, user_id=None, goal=None, min_goal=None, max_goal=None, units=None, rule=None, notes=None) -> Metric |
Update a metric. |
archive() |
archive(metric_id) -> Metric |
Archive a metric. There is no restore(). |
scores() |
scores(metric_id, *, start=None, end=None, include_empty=False) -> list[MetricScore] |
List a metric's scores, most recent first. |
set_score() |
set_score(metric_id, value, timestamp) -> MetricScore |
Set a score for a given time. |
update_score() |
update_score(metric_id, score_id, *, value=None, notes=None) -> MetricScore |
Update an existing score's value and/or note. |
clear_score() |
clear_score(metric_id, score_id) -> MetricScore |
Clear a score's value, leaving an empty placeholder. |
Dates and times¶
Parameters such as due_date, timestamp, start, and end accept any of:
- a
datetime(a naivedatetimeis treated as UTC), - a
date(00:00 UTC of that day), - unix seconds as an
intorfloat, - an ISO 8601 string, such as
"2026-09-21"or"2026-09-21T10:30:00+02:00".
Date fields on v2 models (due_date, created_date, completed_date, and so
on) are timezone-aware UTC datetime objects.
Description ("notes") behavior¶
Every entity with a notes field (Issue, Headline, Todo, Goal,
Metric) stores its description as a collaborative note pad (Etherpad), not
as a plain text column. This has one consequence you need to know:
notes= on update() creates a new pad, it does not edit the old one
Passing notes= to create() or update() always creates a brand-new
note pad and points the entity at it (notes_id). It never edits the
previous pad's content in place. Each update(..., notes=...) call
replaces the entity's notes_id with a fresh pad, leaving the old pad
orphaned. There is no way to append to or partially edit an existing note
through this SDK — always pass the full new description text.
issue = client.v2.issue.create(123, "Title", notes="Original text")
print(issue.notes_id) # e.g. "abc123"
updated = client.v2.issue.update(issue.id, notes="Replacement text")
print(updated.notes_id) # a different pad id
print(updated.notes) # "Replacement text"
Reading notes immediately after a write can occasionally return stale or
empty text: pad reads are cached for a second or two after a write. If you
read a just-written note back and it looks empty, retry after a short delay
rather than assuming the write failed.
notes_id is kept on the model alongside notes for reference, but you do
not need it for normal use — pass a new notes= string to update() to
replace the description.
Archive vs. delete semantics per entity¶
Lifecycle operations are not uniform across entities — some archive, some
complete/reopen, and milestones delete outright. There is no cross-entity
delete() for issues, headlines, todos, goals, or metrics; they only
archive/restore.
| Entity | Lifecycle methods | Notes |
|---|---|---|
| Issue | solve() / reopen(), archive() / restore() |
The API stores long-term issues with an archived flag; the SDK reports them as not archived, matching the web app. archive() removes a long-term issue from the long-term list, and restore() returns it to the short-term list. Solve an issue before archiving it: solve() has no effect on an archived issue. |
| Headline | archive() / restore() |
No complete/solve concept. |
| Todo | complete() / reopen(), archive() / restore() |
Independent flags: a to-do can be completed and archived at the same time. |
| Goal | archive() / restore() |
archive() re-reads the goal after the mutation and raises GraphQLError if it is still not archived, working around a server-side bug where the edit can report success without archiving anything. restore() re-attaches meeting links detached by a recent archive. |
| Milestone | delete() |
No archive/restore — delete() permanently removes it (soft-deleted server-side; it never reappears). There is no root query to read a milestone directly, which is why update() and complete() require both milestone_id and a keyword-only goal_id, which they verify actually owns milestone_id before writing anything. |
| Metric | archive() |
No restore(). The GraphQL API does not support un-archiving a metric — editing archived: false on an already-archived metric re-archives it rather than restoring it. Archiving a metric is effectively permanent through this SDK. |
Metric scores and week semantics¶
client.v2.metric scores have a few behaviors worth knowing before you write
data:
set_score()upserts by period for weekly/monthly/quarterly metrics. Calling it twice for timestamps in the same period updates the same score row. ForMetricFrequency.DAILYmetrics, a placeholder score already exists for every day, soset_score()transparently falls back toupdate_score()when it detects that placeholder instead of creating a duplicate.- Weekly scores are stored on the Sunday-anchored start of their week, not
the exact instant you pass. Writing a score with
timestamp=datetime.now()on a Thursday stores it on that week's preceding Sunday at00:00 UTC— readingMetricScore.week_dateback will reflect that anchor, not "now". clear_score()blanks a score's value but keeps the row as an empty placeholder — it does not delete it.scores(include_empty=True)will still return it withvalue=None.rule=MetricRule.BETWEENrequiresmin_goal/max_goalinstead ofgoal. Mixing the two raisesValueErrorlocally, before any request is sent:
from bloomy import Client
from bloomy.v2.models import MetricRule
with Client(api_key="your-api-key") as client:
# OK: single goal for a non-BETWEEN rule
client.v2.metric.create(123, "Response Time", goal=24, rule=MetricRule.LESS_THAN)
# OK: min/max goal for BETWEEN
client.v2.metric.create(
123, "Team Size", min_goal=5, max_goal=10, rule=MetricRule.BETWEEN
)
# Raises ValueError: `goal` cannot be used with rule=BETWEEN
client.v2.metric.create(123, "Bad Example", goal=5, rule=MetricRule.BETWEEN)
GraphQLError¶
v2 operations raise bloomy.GraphQLError (a subclass of APIError, itself a
subclass of BloomyError) instead of a plain APIError, so existing
except APIError / except BloomyError handlers keep working without
changes. GraphQLError adds an errors attribute: the raw list of GraphQL
error objects from the response.
A GraphQLError is raised for:
- A response body carrying a top-level
errorsarray, whether it came back with an HTTP 4xx validation status or an HTTP 200 execution status. - A mutation result shaped
{success, message, errorDetails}wheresuccessisfalse(used by, for example,issue.update()andmilestone.delete()). - A mutation that returns a
nullresult, or an{id}result whose id is0.
Some v2 mutations return only {id}
A few mutations (headline/goal/milestone/metric create and edit)
return only IdModel { id } rather than the
{success, message, errorDetails} shape. There is no success: false
body to check, so the SDK treats a top-level errors array, a null
result, or an id of 0 as a failure. Either way, a failed write raises
GraphQLError.
from bloomy import Client, GraphQLError
with Client(api_key="your-api-key") as client:
try:
client.v2.issue.update(123) # no fields -> ValueError (local, not GraphQLError)
except ValueError as e:
print(f"Validation error: {e}")
try:
client.v2.issue.details(999999999)
except GraphQLError as e:
print(f"GraphQL error: {e}")
print(f"Status code: {e.status_code}")
print(f"Raw errors: {e.errors}")
Next steps¶
- v2 API Reference — full method and model documentation
for every
client.v2namespace. - Basic Usage — v1 (REST) patterns.
- Error Handling — the full
BloomyErrorhierarchy.