SDKs
We don't ship an official SDK yet — the API is small enough that a thin wrapper is usually better than a dep. Two reference clients below.
TypeScript
Works in Node ≥ 18 and any modern runtime with fetch. Strongly typed responses are left to you — they ride on the same OpenAPI spec at /api-spec.yaml.
type Init = RequestInit & { json?: unknown };
export class ContactFollowUp {
constructor(
private readonly token: string,
private readonly base = "https://app.contactfollowup.com",
) {}
private async request<T>(path: string, init: Init = {}): Promise<T> {
const { json, headers, ...rest } = init;
const res = await fetch(`${this.base}${path}`, {
...rest,
headers: {
Authorization: `Bearer ${this.token}`,
"Content-Type": "application/json",
Accept: "application/json",
...(headers ?? {}),
},
body: json !== undefined ? JSON.stringify(json) : init.body,
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new ContactFollowUpError(res.status, body.error ?? res.statusText, body);
}
return res.status === 204 ? (undefined as T) : ((await res.json()) as T);
}
contacts = {
list: (q?: { q?: string; lifecycle?: string; limit?: number }) => {
const p = new URLSearchParams(
Object.entries(q ?? {}).filter(([, v]) => v !== undefined) as [string, string][],
).toString();
return this.request<{ contacts: Contact[] }>(`/api/contacts${p ? "?" + p : ""}`);
},
create: (body: ContactCreate) =>
this.request<Contact>("/api/contacts", { method: "POST", json: body }),
get: (id: string) => this.request<Contact>(`/api/contacts/${id}`),
update: (id: string, body: Partial<ContactCreate>) =>
this.request<Contact>(`/api/contacts/${id}`, { method: "PATCH", json: body }),
remove: (id: string) =>
this.request<void>(`/api/contacts/${id}`, { method: "DELETE" }),
};
}
export class ContactFollowUpError extends Error {
constructor(public readonly status: number, message: string, public readonly body: unknown) {
super(message);
}
}
export interface Contact {
id: string;
firstName: string;
lastName: string;
email: string | null;
phone: string | null;
lifecycle: "LEAD" | "PROSPECT" | "PATIENT" | "INACTIVE" | "CHURNED";
}
export interface ContactCreate {
firstName: string;
lastName: string;
email?: string;
phone?: string;
lifecycle?: Contact["lifecycle"];
}import { ContactFollowUp } from "./hearthnote";
const hn = new ContactFollowUp(process.env.HEARTHNOTE_TOKEN!);
const c = await hn.contacts.create({ firstName: "Avery", lastName: "Liu" });
await hn.contacts.update(c.id, { lifecycle: "PATIENT" });Python
Requires requests (or swap for httpx).
from __future__ import annotations
import os
from typing import Any, Optional
import requests
class ContactFollowUpError(Exception):
def __init__(self, status: int, message: str, body: Any) -> None:
super().__init__(message)
self.status = status
self.body = body
class ContactFollowUp:
def __init__(self, token: Optional[str] = None, base: str = "https://app.contactfollowup.com") -> None:
self.token = token or os.environ["HEARTHNOTE_TOKEN"]
self.base = base
self.session = requests.Session()
self.session.headers.update({
"Authorization": f"Bearer {self.token}",
"Accept": "application/json",
"Content-Type": "application/json",
})
def _request(self, method: str, path: str, **kwargs: Any) -> Any:
url = f"{self.base}{path}"
res = self.session.request(method, url, timeout=30, **kwargs)
if not res.ok:
body = res.json() if "application/json" in res.headers.get("content-type", "") else {}
raise ContactFollowUpError(res.status_code, body.get("error", res.reason), body)
return None if res.status_code == 204 else res.json()
# contacts ---------------------------------------------------------------
def list_contacts(self, **params: Any) -> dict:
return self._request("GET", "/api/contacts", params=params)
def create_contact(self, **body: Any) -> dict:
return self._request("POST", "/api/contacts", json=body)
def get_contact(self, contact_id: str) -> dict:
return self._request("GET", f"/api/contacts/{contact_id}")
def update_contact(self, contact_id: str, **body: Any) -> dict:
return self._request("PATCH", f"/api/contacts/{contact_id}", json=body)
def delete_contact(self, contact_id: str) -> None:
self._request("DELETE", f"/api/contacts/{contact_id}")
from hearthnote import ContactFollowUp
hn = ContactFollowUp()
c = hn.create_contact(firstName="Avery", lastName="Liu")
hn.update_contact(c["id"], lifecycle="PATIENT")Generating clients
The OpenAPI spec at /api-spec.yaml is the source of truth. Tools that work well against it:
- openapi-typescript (npm) — pure type extraction.
- openapi-fetch (npm) — runtime client with the types above.
- openapi-python-client (PyPI) — full Pydantic-based client.
- oapi-codegen (Go) — pairs nicely with chi or echo.