All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
update_opportunity(opportunity_id, **fields)—PATCH /opportunities/{id}, live-verified against the real API. Common fields:name,amount,opportunity_stage_id,closed_date,account_id,owner_id,next_step,next_step_date,description. Distinct fromcreate_deal'sPOST /opportunities. RaisesValueErrorif no fields are given.get_note(note_id)—GET /notes/{id}, returns aNotewith ProseMirror content converted to Markdown (same conversion assearch_notes()). The response-wrapping key was not live-verifiable during development (/notes/search's own daily quota was exhausted — see CLAUDE.md); it follows the{"note": {...}}convention every otherget_*method uses and falls back to the raw body if that key is absent.RoleAssignmentError(new exception, exported from the package root) — raised byupdate_opportunity_roleswhen a requested role isn't present on a post-write read-back. Carries.opportunity_idand.missing_contact_ids.
update_opportunity_rolesnow fails loud instead of silently returning a role that was never persisted. Live-observed: a set-role call returned Apollo's 200 + deal JSON with no error while the role was not actually saved (a read-back minutes later showed 0 roles); the identical call succeeded on retry. The write response can no longer be trusted on its own — the method now always re-reads the opportunity after writing and raisesRoleAssignmentErrorif any requestedcontact_idis missing from the fresh read-back, instead of returning the (possibly stale/wrong) write response.
-
create_tasktakesuser_id,due_atandtitledirectly. Apollo rejects task creation with{"error": "Invalid user or creator id"}unless an owner is supplied, and the only way to set a due date was to know that**fieldswas forwarded verbatim. Both are now named parameters, documented, anddue_ataccepts adatetime(ISO-serialised for you) as well as a preformatted string. Unset optionals stay out of the payload.noteis optional too and is omitted rather than sent as an empty string; Apollo accepts a task without it (verified against the live API). A naivedue_atis rejected with aValueError— it would serialise without an offset and leave the intended instant ambiguous. -
create_linkedin_connect_request(contact_id, note=None, ...)— a LinkedIn connection request that defaults to carrying no message. On alinkedin_step_connecttask the note travels with the invitation, sonotedefaults toNoneand the docstring says plainly that whatever you pass is seen by the recipient; internal context goes intitle, which stays inside Apollo.create_linkedin_connect_taskremains for Apollo's structured outreach message payload.
-
RateLimitErrornow reports what Apollo actually said. The message was the constant"Rate limit exceeded. Apollo limits: 400/hour, 200/min, 2000/day"— identical whether the minute, the hour or the day was spent, and on which endpoint. Apollo meters per endpoint, so that ambiguity actively misled: in one incident/noteswas atdaily 0/2000while/taskson the same key reported1984/2000left, and the message gave no way to tell.It now names the endpoint and the exhausted window, e.g.
Rate limit exceeded on /notes: daily 0/2000 exhausted (buckets: minute 200/200, hourly 400/400, daily 0/2000). Apollo meters per endpoint, so other endpoints may still have budget.The exception carries.endpointand.limitsso a caller can pace itself instead of guessing. -
rate_limit_statusdistinguishes an absent header from a spent bucket. Values are nowint | None; previouslyint(header or 0)reported a missing header as0, i.e. as exhaustion that was never observed. Breaking for callers annotating the return asdict[str, int].
- People search now uses
/mixed_people/api_search; the old/mixed_people/searchis deprecated for API callers (422). Note the new endpoint returns teaser data only (no full name/email/linkedin_url without a credit-consuming reveal). Thefind_contact_by_linkedin_urlauto-creation step is retired accordingly (it warns;create_if_missingis a documented no-op). - Deal name search uses
q_opportunity_name, notq_keywords(which Apollo silently ignores on/opportunities/search).DEAL_SEARCH_FILTERSallowsq_opportunity_nameand rejectsq_keywords. list_contact_tasksnow filters the tasks search bycontact_ids(the/contacts/{id}/tasksroute was removed by Apollo — 404).list_account_jobsresolves the account'sorganization_idand reads/organizations/{org_id}/job_postings(the/accounts/{id}/job_postingsroute was removed).
- Search-filter validation across all
search_*methods: unknown filter keys raise (documented endpoints: contacts/deals/people/accounts) or warn (undocumented activity endpoints), preventing Apollo's silent-drop → unfiltered-default-page footgun. search_*docstrings now document each filter's empirically-verified accepted format (seniority enums,"min,max"employee ranges, location formats, dict ranges, email-status values, canonicallinkedin_url).
- Breaking:
list_contact_callsandlist_account_news— Apollo removed the underlying routes (/contacts/{id}/calls,/accounts/{id}/news) with no working replacement.
create_deal(name, **fields)creates a deal/opportunity viaPOST /opportunities.nameis the only required field; optionalowner_id,account_id,amount,opportunity_stage_id,closed_dateare forwarded as-is. Requires a master API key (non-master keys return 403). Live-verified against the real API.
update_opportunity_roles(...)now sends Apollo's expected nested role shape —{"contact_id": …, "is_primary": …, "role": [{"opportunity_contact_role_type_id": …, "is_primary": …}]}— instead of the flatopportunity_contact_role_type_idon the entry. The flat shape made Apollo 422 withundefined method 'map' for nil, so setting a contact's role on a deal failed every time. The publicRoleAssignmentinterface is unchanged (callers still pass flat entries).search_accounts(**filters)now validates filter keys against an allowlist (q_organization_name,account_stage_ids,account_label_ids,sort_by_field,sort_ascending) and raisesValueErroron unknown keys. Apollo silently ignores unrecognised keys and returns an unfiltered default page that looks like a real match (e.g.query="…"returned ~28k accounts, "Google" first) — fail-loud now prevents wrong-account attribution.
search_tasks()could raiseAttributeErrorwhile handling an unparseable row: the skip path calledraw.get("id")assumingrawwas a dict, so a non-dict row (e.g. a straynull) turned the intended "skip one bad row" into a whole-page crash. The id lookup is now guarded (isinstance(raw, dict)), and iteration tolerates a nulltasksvalue (result.get("tasks") or []).
update_opportunity_roles(...)now types itsrolesparameter aslist[RoleAssignment](aTypedDictwith a requiredcontact_idand optionalopportunity_contact_role_type_id/is_primary) instead of the looselist[dict], giving callers type checking and autocomplete.RoleAssignmentis exported from the package root. Non-breaking — plain dicts still satisfy it structurally.
ApolloClient.update_opportunity_roles(opportunity_id, roles)—POST /opportunities/update_roles. Sets the contact roles on a deal (replaces the full set; read the current roles fromget_deal(...).opportunity_contact_rolesand modify). Returns the updatedDeal. Surfaces the previously curl-only role-management endpoint.ApolloClient.list_custom_fields()—GET /typed_custom_fields. Returns the account/contact/opportunity custom field definitions as a newCustomFieldmodel (id,modality,name,type,picklist_options,mapped_crm_field).CustomFieldmodel, exported from the package root.
normalize_linkedin_url()normalized tohttps://and never addedwww, but Apollo stores and exact-matches LinkedIn URLs ashttp://www.linkedin.com/in/<slug>. As a resultfind_contact_by_linkedin_url()'s URL tier always missed (silently falling through to name search), and anysearch_contacts(linkedin_url=...)filter built from it returned zero. It now produces Apollo'shttp://wwwform, so URL lookups actually match.
create_note()posted{"note": <plaintext>}, which Apollo silently ignores — notes were created with empty content. It now serialisescontentto ProseMirror JSON and posts it in thecontentfield (the format Apollo stores andsearch_notes()reads back). Closes #6.
markdown_to_prosemirror()inutils— inverse ofprosemirror_to_markdown()(title, paragraphs, bullet/ordered lists).create_note(..., title=...)— optional note title (rendered as the ProseMirrornoteTitle).ApolloClient.delete_note(note_id)—DELETE /notes/{id}.
create_note()association args (contact_ids,account_ids,opportunity_ids) are now keyword-only, so the new positionaltitlecan't be confused with them.
ActionItemTasksubclass foraction_itemtask typeOtherTaskfallback subclass —resolve_task()now returnsOtherTaskfor unknown task types instead of raisingValidationErrorOpportunityContactRoleTypemodel for role type definitions (Decision Maker, Buyer, etc.)ApolloClient.list_opportunity_contact_role_types()— lookup endpoint for role type ID → name mapping (undocumentedPOST /opportunity_contact_role_types/search)
Deal.closed_date,Deal.actual_close_date,Deal.next_step_datenow parsed asdatetime(werestr)EmploymentHistory.start_date,EmploymentHistory.end_datenow parsed asdate(werestr)CallSummaryNextStep.due_atnow parsed asdatetime(wasstr)NewsArticle.published_at,JobPosting.posted_atnow parsed asdatetime(werestr)search_conversations()default and max limit corrected from 100 to 25 (Apollo API caps at 25)
- 11 typed Task subclasses with native Pydantic
Discriminator("type")— CallTask, AccountCallTask, ContactCallTask, LinkedInInteractTask, LinkedInViewProfileTask, LinkedInActionsTask, ContactActionItemTask, AccountActionItemTask (plus existing EmailTask, LinkedInConnectTask, LinkedInMessageTask) resolve_task()function for polymorphic task deserialization (raisesValidationErroron unknown/missing types)Tasktype alias — union of all task subclassesBaseTaskbase class — common fields shared by all task types (renamed fromTask)
- Comprehensive client.py test suite — 55 new tests covering all public methods, error handling, and the 3-tier LinkedIn contact matching strategy (client.py coverage: 33% → 98%)
- CHANGELOG.md following Keep a Changelog format
- GitHub release for v0.1.0
Initial public release on PyPI (previously internal at qodev).
- Async API client with context manager support and httpx
- 40+ API methods across contacts, accounts, deals, pipelines, notes, calls, tasks, emails, calendar events, conversations, enrichment, and usage
- Full Pydantic v2 models for all API responses with
extra="allow"for forward compatibility - Task subclass hierarchy — EmailTask, LinkedInConnectTask, LinkedInMessageTask with typed enums
- 3-tier contact matching — LinkedIn URL → name fallback → People DB auto-creation
- Built-in rate limit tracking from response headers (400/hour, 200/min, 2000/day)
- ProseMirror to Markdown conversion for Apollo notes
- Custom exceptions — AuthenticationError, RateLimitError, APIError
- py.typed marker for downstream type checking
- GitHub Actions CI — lint (ruff), typecheck (pyright), test (pytest)
- PyPI publishing via Trusted Publishers (OIDC)