Migrating to 2.0¶
SDK 2.0 moves every operation Affinity's V2 API can do onto V2. Most code keeps working; this page lists what changes and what to do. The API versions & routing guide lists the few cases that still use V1, and the API coverage page shows every operation.
Before you upgrade¶
- API version pins. Many moved operations need a newer Affinity API version (webhooks,
reminder get/delete and the rate-limit endpoint need
2026-07-15; reminder update and the V2 file reads need2026-09-17). The SDK sends the version each operation needs, unless you pinned one (affinity_api_version=, CLI--api-version,AFFINITY_API_VERSION, the MCP bundle's "Affinity API Version" setting). A pin older than the operation needs raisesApiVersionTooOldErrorbefore anything is sent: raise or remove it. - Permissions. On V2, only a reminder's creator or owner may change it (the SDK retries through V1 when V2 refuses), and a public list needs "Share accessible Lists globally".
Webhooks¶
| 1.x | 2.0 |
|---|---|
WebhookSubscription |
Webhook |
.webhook_url |
.url |
.disabled |
.status (WebhookStatus.ACTIVE / DISABLED) |
.created_by |
.creator.id (creator may be None) |
WebhookCreate(webhook_url=…) |
WebhookCreate(url=…, subscriptions=[…]): subscriptions is required, [] = every event |
WebhookUpdate(disabled=True) |
WebhookUpdate(status=WebhookStatus.DISABLED) |
webhooks.list() -> list |
one page; webhooks.iter() for all |
update() |
returns the webhook as read back (two requests) |
delete() -> bool |
-> None |
Without permission to manage all webhooks, you see and change only your own. 1.x field names
raise ValidationError instead of being ignored. The CLI has xaffinity webhook
ls|get|create|update|delete; create, update and delete ask for confirmation.
Reminders¶
| 1.x | 2.0 |
|---|---|
ReminderType.ONE_TIME == 0 (int enums) |
string values ("one-time"); member names unchanged, the 1.x integers are still accepted |
ReminderResetType.MEETING |
alias of EVENT ("event") |
Reminder.creator/owner/completer/person (names, emails) |
PersonRef (id only) |
Reminder.company/opportunity (dicts) |
CompanyRef / OpportunityRef |
Reminder.reset_type/reminder_days |
still there (read-only), from Reminder.recurrence |
list(page_size=, page_token=) |
list(limit=, cursor=) (1-100), soonest due first; iter() for all |
ReminderUpdate(content=None) cleared nothing |
still nothing; clear_content=True removes the content |
| a reminder without a person, company or opportunity | ValueError (both APIs require one) |
update() returns the reminder as read back; list_batch() is on the sync client too.
Notes¶
| 1.x | 2.0 |
|---|---|
notes.create() / update() -> Note |
-> NoteV2 (content.html, creator) |
| plain text stored as plain text | stored as HTML; the SDK escapes and wraps plain text, so it shows as typed |
NoteUpdate(content=…) kept the note's type |
NoteUpdate.type says how content is written (plain text unless HTML) |
type=HTML with any tags |
<b>/<i> become <strong>/<em>; tags Affinity rejects raise ValueError naming the allowed ones |
parent_id with entity ids (ids ignored) |
ValidationError |
notes.get() returns NoteV2 (includes=True adds the attached companies, persons and
opportunities); notes.list_v2()/iter_v2()/get_v2() are now notes.feed()/iter_feed()/
get(). notes.list() (one entity's notes) and CLI note ls still use V1, which shows notes
stored as HTML in a markdown-like form. Changing the text of a note that @mentions people is refused by the API.
Lists¶
| 1.x | 2.0 |
|---|---|
lists.get().fields |
None: use lists.get_fields() |
lists.create() |
V2 (you become the owner); with owner_id/additional_permissions, V1 |
entries(list_id).add_opportunity() |
removed: create the opportunity on its list (opportunities.create(OpportunityCreate(list_id=…))) |
Person create/delete, opportunity rename/delete and adding people or companies to a list are
unchanged (V1): Affinity's V2 spec documents them, but the API didn't serve them when tested (2026-10-12).
Person.company_ids and Opportunity.company_ids are now filled from V1 responses.
Files and rate limits¶
files.get/list/iter are unchanged (V1). files.get_download_url() uses V2 when it can (one
request for the details and a URL valid 60 seconds) and V1 otherwise. New:
files.feed()/iter_feed() (every file you can see, newest first, as FileV2). rate_limits.refresh() reads the V2 endpoint; RateLimitSnapshot is unchanged.
CLI and MCP¶
reminder ls --page-sizeis at most 100;reminder update --clear-content;--reset-type event(meetingstill accepted).note create --typeisplain-textorhtml;note update --type.list get <id>no longer has.data.list.fields(use.data.fields).- The MCP server needs CLI 2.0; it adds the webhook commands, and
webhook create/update/deleteneed the user's confirmation.
Filters in 2.0.2¶
From 2.0.2, filters mean what Affinity's own search means, so a filter gives the same answer
whether Affinity or the CLI applies it. This applies to query, every --filter, and Python
code that uses affinity.filters (F, filter strings, lists.entries(...).list(filter=...)).
- Empty multi-value fields count as empty.
is_null(field != *) now matches a field with no items, andis_not_null(field = *) no longer does. - Person and company values compare by name ("Jane Doe", "Acme") in
--filterandaffinity.filters, as they already did inquery. containson person and company fields is refused inquery: usestarts_word, which matches a word in the name that starts with the text ("Jan" finds "Jane Doe").query: a field name two list fields share is refused. Name the field by id instead, for examplefields.affinity-data-year-founded.
These now work where they were refused before: company ls --filter and person ls --filter
(on company and person fields), and query on companies and persons with a condition on their
fields. See Filtering for what goes to Affinity's search.