@graph8/sdk
v0.308.1
Published
graph8 SDK - CRM, enrichment, sequences, campaigns, and tracking for B2B
Downloads
3,871
Maintainers
Readme
graph8
The most comprehensive GTM SDK. One npm install. Every graph8 capability.
AI coding agents (Claude Code, Codex, Cursor): read
docs/agents.md(node_modules/@graph8/sdk/docs/agents.md) first. It covers setup, the two client layers, safety tiers, errors and tested recipes, and links a per-area reference for every operation indocs/api/. It ships with the package, so it matches this version. Runnpx @graph8/sdk init-agentto point Claude Code, Codex and Cursor in your project at it automatically.
Install
npm install @graph8/sdkQuick Start
Client-side (write key)
import { g8 } from '@graph8/sdk';
g8.init({ writeKey: 'YOUR_WRITE_KEY' });
// Track events
g8.track('signup', { plan: 'pro' });
// Identify users
g8.identify('[email protected]', { name: 'John', company: 'Acme' });
// Identify visitor's company from IP
const visitor = await g8.visitors.identify();
// { company_name: 'Acme Corp', industry: 'SaaS', employee_count: '200-500' }
// Listen for high-intent visitors
g8.visitors.onIntent('high', (visitor) => {
showCTABanner(`${visitor.company_name} is checking us out!`);
});
// Open AI copilot
g8.copilot.open({ greeting: 'How can I help?' });
// Open webchat
g8.chat.open();
// Show booking widget
g8.calendar.show({ username: 'thomas', eventType: 'demo-30min' });
// Progressive form enrichment
const known = await g8.forms.lookup('[email protected]');
// { found: true, known_fields: { name: 'John' }, missing_fields: ['phone'] }Server-side (API key)
import { g8 } from '@graph8/sdk';
g8.init({ apiKey: 'YOUR_API_KEY' });
// Contacts: create-or-update in one idempotent call (matches work_email, then linkedin_url).
// Use this when the contact may already exist; it is safe to retry.
const { action } = await g8.contacts.upsert({ list_id: 42, work_email: '[email protected]', first_name: 'Jane' });
// action is "created" or "updated"
// Contacts CRUD. create() is create-only: a duplicate throws a 409 G8Error
// whose detail.hint / detail.upsert point back at upsert().
const { data: contacts } = await g8.contacts.list({ company_name: 'Acme', limit: 10 });
const contact = await g8.contacts.create({ work_email: '[email protected]', first_name: 'Jane' });
await g8.contacts.update(contact.id, { job_title: 'VP Sales' });
// Companies
const { data: companies } = await g8.companies.list({ industry: 'SaaS' });
const { data: teamContacts } = await g8.companies.contacts(companies[0].id);
// Lists
const list = await g8.lists.create('Q2 Outbound', 'contacts');
await g8.lists.addContacts(list.id, [contact.id]);
const { data: listContacts } = await g8.lists.contacts(list.id);
// Enrich a person
const person = await g8.enrich.person({ email: '[email protected]' });
// Search 700M+ contacts
const leads = await g8.enrich.search([
{ field: 'seniority_level', operator: 'any_of', value: ['VP', 'Director'] },
{ field: 'company_industry', operator: 'contains', value: ['SaaS'] },
]);
// List sequences and add contacts
const sequences = await g8.sequences.list();
await g8.sequences.add({ sequenceId: sequences[0].id, contactIds: [123], listId: 637 });
// Campaign management
const campaign = await g8.campaigns.create({ name: 'Q2 Push', category: 'Outbound' });
await g8.campaigns.launch(campaign.id);
// Intent signals
const signals = await g8.signals.company('acme.com');
// { score: 87, intent: 'high', signals: ['pricing_page_3x', 'case_study'] }
// Webhook events (push model — verify each delivery server-side)
app.post('/webhooks/graph8', (req, res) => {
const event = g8.webhooks.constructEvent(
req.rawBody, // raw body string (verify before JSON.parse)
req.header('X-Studio-Signature'),
req.header('X-Studio-Timestamp'),
process.env.G8_WEBHOOK_SECRET,
{ toleranceSeconds: 300 },
);
if (event.event === 'engagement.email_replied') slack.send(`${event.data.contact} replied!`);
res.sendStatus(200);
});React / Next.js
import { G8Provider, useG8 } from '@graph8/sdk/react';
// Layout
export const Layout = ({ children }: { children: React.ReactNode }) => (
<G8Provider writeKey="YOUR_WRITE_KEY">{children}</G8Provider>
);
// Any component. `useG8()` exposes the tracking calls, the progressive-form
// helpers, and the raw client — reach the rest of the surface through `g8`.
export const CTA = () => {
const { track, identify, page, reset, forms, g8 } = useG8();
return (
<button
onClick={() => {
track('cta_click', { placement: 'hero' });
g8.copilot.open({ greeting: 'How can I help?' });
}}
>
Talk to us
</button>
);
};Every endpoint: g8.api (generated from the contract)
The clients above are the ergonomic layer — hand-written, opinionated, and only
as complete as someone made them. g8.api is the complete one: it is
generated from the published OpenAPI contract, so it covers every
Developer API operation (the count and a per-area reference are in
docs/api/README.md), including surfaces with no hand-written
client (Appointments, Radar, App Objects, Ad Campaigns, Sandbox, Landing Pages,
Agent, OpenSearch, ClickHouse and more).
import { g8 } from '@graph8/sdk';
g8.init({ apiKey: process.env.G8_API_KEY });
// Grouped by resource, with full autocomplete on inputs and responses.
const bookings = await g8.api.appointments.listBookings({ query: { take: 50 } }); // `take`, not `limit` — the types say so
const competitors = await g8.api.radar.listCompetitors();
// Or address an operation by its stable, published operationId.
const contacts = await g8.api.call('list_contacts_contacts_get', { query: { limit: 50 } });
// Path, query, headers and body are separate and individually typed.
const contact = await g8.api.contacts.getContact({ path: { contact_id: 123 } });Auto-pagination follows pagination.next_cursor for you:
import { g8 } from '@graph8/sdk';
for await (const contact of g8.api.paginate('list_contacts_contacts_get', { query: { limit: 100 } })) {
console.log(contact);
}Every operation carries the side-effect tier and the API-key scope the API publishes for it, so an integration can warn a human before a call that spends credits or deletes something:
import { g8 } from '@graph8/sdk';
const op = g8.api.operation('list_contacts_contacts_get');
// { method: 'GET', path: '/api/v1/contacts', tier: 'read', scope: 'contacts:read', ... }
const risky = g8.api.operations().filter((o) => o.tier === 'billable' || o.tier === 'destructive');
console.log(`${risky.length} operations have side effects worth confirming`);Contract schemas are available as types under the G8Contract namespace:
import type { G8Contract } from '@graph8/sdk';
function fullName(contact: G8Contract.ContactResponse): string {
return [contact.first_name, contact.last_name].filter(Boolean).join(' ');
}Or let the operation map name them for you, with no import of the schema at all:
import { g8, type ApiOutput } from '@graph8/sdk';
type ContactPage = ApiOutput<'list_contacts_contacts_get'>;
async function firstPage(): Promise<ContactPage> {
return g8.api.call('list_contacts_contacts_get', { query: { limit: 25 } });
}Two things to know before reaching for it:
- It returns the raw response envelope. The hand-written clients unwrap
.dataand rename fields for you;g8.apigives you exactly what the contract declares. Prefer the hand-written client where one exists. - It is generated — never hand-edited.
sdk/js/src/generated/**is produced bynpm run generatefromdeveloper-contract/openapi.json, and CI fails if the two disagree. That is what keeps coverage at 100% as the API grows.
Full API Reference
Core (write key)
| Method | Description |
|--------|-------------|
| g8.init(config) | Initialize SDK |
| g8.track(event, props?) | Track event |
| g8.identify(userId, props?) | Identify user |
| g8.page(props?) | Track page view |
| g8.reset() | Clear identity |
Contacts (API key)
| Method | Description |
|--------|-------------|
| g8.contacts.upsert(contact) | Create or update a contact (PUT /contacts/assert). Matches work_email, then linkedin_url; needs list_id. Idempotent, the recommended way to write a contact |
| g8.contacts.list(params?) | List contacts with filters |
| g8.contacts.get(id) | Get contact by ID |
| g8.contacts.create(contact) | Create a contact. Create-only: a duplicate throws a 409 whose detail.upsert names upsert() |
| g8.contacts.update(id, fields) | Update contact (partial) |
| g8.contacts.delete(id) | Delete contact |
Companies (API key)
| Method | Description |
|--------|-------------|
| g8.companies.list(params?) | List companies with filters |
| g8.companies.get(id) | Get company by ID |
| g8.companies.contacts(id) | Get company's contacts |
| g8.companies.update(id, fields) | Update company (partial) |
| g8.companies.delete(id) | Delete company |
Lists (API key)
| Method | Description |
|--------|-------------|
| g8.lists.list(page?, limit?) | List all lists |
| g8.lists.create(title, type?) | Create a list |
| g8.lists.delete(id) | Delete a list |
| g8.lists.contacts(id, page?, limit?) | Get contacts in list |
| g8.lists.addContacts(id, contactIds) | Add contacts to list |
| g8.lists.removeContacts(id, contactIds) | Remove contacts from list |
Visitor Intelligence (write key)
| Method | Description |
|--------|-------------|
| g8.visitors.identify() | IP to company resolution |
| g8.visitors.score() | Engagement score |
| g8.visitors.onIntent(level, cb) | Real-time intent listener |
Forms (write key)
| Method | Description |
|--------|-------------|
| g8.forms.lookup(email) | Progressive form enrichment |
Copilot (write key)
| Method | Description |
|--------|-------------|
| g8.copilot.open(config?) | Open AI assistant widget |
| g8.copilot.ask(message) | Send message programmatically |
| g8.copilot.registerAction(name, fn) | Register custom action |
| g8.copilot.close() | Close widget |
Webchat (write key)
| Method | Description |
|--------|-------------|
| g8.chat.open(config?) | Open chat widget |
| g8.chat.send(message) | Send message |
| g8.chat.on(event, cb) | Listen for events |
| g8.chat.close() | Close widget |
Calendar (write key)
| Method | Description |
|--------|-------------|
| g8.calendar.show(config) | Show booking modal |
| g8.calendar.embed(selector, config) | Inline embed |
| g8.calendar.slots(user, slug, range) | Get available slots |
| g8.calendar.book(request) | Book programmatically |
Enrichment (API key)
| Method | Description |
|--------|-------------|
| g8.enrich.person(params) | Look up a person (2 credits, charged only when a match is found). REST: POST /enrichment/lookup/person (POST /enrichment/person is an alias) |
| g8.enrich.company(params) | Look up a company (2 credits, charged only when a match is found) |
| g8.enrich.verifyEmail(email) | Verify email (1 credit) |
| g8.enrich.search(filters) | Search 700M+ contacts (no credits) |
Sequences (API key)
| Method | Description |
|--------|-------------|
| g8.sequences.list() | List sequences |
| g8.sequences.add(config) | Add contacts to sequence |
Campaigns (API key)
| Method | Description |
|--------|-------------|
| g8.campaigns.list() | List campaigns |
| g8.campaigns.create(config) | Create campaign |
| g8.campaigns.launch(id) | Launch campaign |
Signals (write key or API key)
| Method | Description |
|--------|-------------|
| g8.signals.company(domain) | Get intent signals |
| g8.signals.stream(domains, cb) | Stream signals (polls 30s) |
With an apiKey, signals use Bearer auth and the key must carry the intent:read
scope. Personal keys and keys minted before scoped keys do not, and get a 403,
which company() throws as a G8Error explaining the fix (and which stops
stream()). With only a writeKey, a failure still resolves to an all-zeros result.
Webhooks (API key)
| Method | Description |
|--------|-------------|
| g8.webhooks.constructEvent(body, sig, ts, secret, opts?) | Verify a delivery's HMAC signature and return the parsed event (throws WebhookSignatureError) |
| g8.webhooks.knownEvents | The known event-type catalog |
The unreleased CRM increment adds crm.record.created, crm.record.updated,
crm.record.archived, and crm.record.restored. Their data contains
object_slug, record_id, and revision, rather than private field values.
The envelope id is a stable event ID; X-Studio-Delivery-Id identifies a
subscription's delivery. Verify the original request bytes before processing,
and deduplicate by the stable identifiers because delivery is at least once.
CRM subscription creation, update, deletion and secret rotation through the API
require explicit webhooks:write and objects:read scopes. Removing CRM events
also requires these grants. Reading CRM subscriptions or their delivery history
requires explicit webhooks:read and objects:read scopes; a subscription list
containing CRM subscriptions enforces the same read grants before returning data.
These checks refresh credentials and apply even during compatibility scope soak.
The backend checks current credential, membership and record access again before
sending. Native custom
records currently follow workspace-level access; app-owned/delegated authority
and configurable Enterprise object grants remain incomplete.
Native custom objects
Use an organization API key with explicit objects:read for schema/record/history
reads, objects:write for record create/PATCH, and objects:delete for record archive.
Schema create/update/archive require objects:manage and current Admin access through
a personal API key. objects.update(slug, { is_archived: false }) restores an object.
Object create accepts description and a typed icon. Update also accepts
display_attribute_slug for an active, single-value label field. Omit presentation
properties to preserve them or pass null to clear them. The current display field
must be replaced or cleared before archiving it or making it multivalue.
Attribute administration in the unreleased increment uses objects.createAttribute,
objects.updateAttribute, and objects.archiveAttribute with the same scope and Admin requirement.
Restore with objects.updateAttribute(objectSlug, attributeSlug, { is_archived: false }).
Use objects.listAttributes(objectSlug, { include_archived: true }) to find archived fields;
normal discovery and record validation use active fields only. Attribute PATCH preserves
omitted fields; default_value: null explicitly clears a default. Requires the matching backend.
Unscoped keys receive 403. objects:* grants all object scopes; grant only the operations the
integration needs.
import { g8 } from '@graph8/sdk';
g8.init({ apiKey: process.env.G8_API_KEY! });
const { data: objects } = await g8.objects.list();
const { data: attributes } = await g8.objects.listAttributes('invoices');
// Assumes this workspace defines an invoices object with a reference attribute.
const invoice = await g8.objects.createRecord('invoices', { reference: 'INV-1042' });
const history = await g8.objects.history('invoices', invoice.id);PATCH preserves omitted attributes. Enabled defaults apply only on create; unknown fields return 422 and unique-value collisions return 409. Standard contacts, companies, and deals continue using their existing SDK resources.
Create or update by a unique key
Use an active, single-value unique attribute to match a native custom record. Supply its nonempty value with the fields to write. Existing matches retain their graph8 ID and omitted values; new records receive configured defaults.
import { g8 } from '@graph8/sdk';
g8.init({ apiKey: process.env.G8_API_KEY! });
// Assumes invoices.reference is a unique text attribute in this workspace.
const invoice = await g8.objects.upsertRecord('invoices', 'reference', {
reference: 'INV-1042',
});
// A conditional update fails with 409 if the record changed or no longer matches.
await g8.objects.upsertRecord('invoices', 'reference', {
reference: 'INV-1042',
}, { expectedRevision: invoice.revision });The REST operation is POST /api/v1/objects/{object_slug}/records/upsert with
{ "matching_attribute": "reference", "values": { "reference": "INV-1042" } }.
It requires objects:write, returns 201 for creation or 200 for an update, and
accepts optional expected_revision. MCP exposes g8_object_record_upsert
with the same fields. Archived records do not match. Invalid or nonunique
matching attributes return 422; another unique-field collision returns 409.
Legacy values requiring a uniqueness backfill also return 409 rather than
creating a duplicate. Repeated upserts can create additional update history;
unique-key matching is not a promise of exactly-once side effects.
App Platform
Hosted apps do not carry a permanent org API key. Instead they exchange a
longer-lived credential for a short-lived graph8 app token, and the client
mints, refreshes, and attaches it for you. Both clients expose a token-bound
request() that reaches the existing Developer API resources with the app
token.
Browser (PropelAuth token exchange)
import { createGraph8AppClient } from '@graph8/sdk';
// Exchange the signed-in user's PropelAuth token for a short-lived graph8 app
// token. The client refreshes it on/near expiry and attaches
// `Authorization: Bearer <app-token>` to every call — no API key in the browser.
const appClient = createGraph8AppClient({
appId: 'dapp_your_app',
tenantOrgId: 'org_customer',
scopes: ['objects:read'],
getPropelAuthToken: () => localStorage.getItem('propelauth_token') ?? '',
});
// Reach the existing resources with the app token via request():
const { data: objects } = await appClient.request<{ data: unknown[] }>(
'/api/v1/app/objects',
{ query: { limit: 10 } },
);Backend (service credentials)
import { createGraph8ServiceClient } from '@graph8/sdk';
// Exchange service client-credentials for a service token. Every call is pinned
// to the one consented tenant via the `X-Target-Org-Id` header the client adds.
const service = createGraph8ServiceClient({
clientId: process.env.G8_APP_CLIENT_ID,
clientSecret: process.env.G8_APP_CLIENT_SECRET,
tenantOrgId: 'org_customer',
scopes: ['objects:read', 'objects:write'],
});
const { data: objects } = await service.request<{ data: unknown[] }>('/api/v1/app/objects');App-token clients use
request()for their consented app-owned objects. Theg8.objectsresource above uses an API key for native workspace objects; it does not substitute for app-token authorization.
Auth Modes
| Mode | Key | Use case |
|------|-----|----------|
| Client-side | writeKey | Browser apps - tracking, visitor ID, widgets |
| Server-side | apiKey | Node.js - enrichment, sequences, campaigns, CRM |
| Both | writeKey + apiKey | Full access |
Get your API key at app.graph8.com/settings under MCP & API > API.
X-Target-Org-Id (agency keys only)
Setting the X-Target-Org-Id header on a call switches an agency key (minted
with is_agency: true) to one of its authorized client orgs (see g8.agency.clients()).
For any other key the server ignores the header without an error: the call runs
against the key's own org. /api-keys and /agency/* reject the header with a 400.
Once g8.agency.me() or g8.agency.clients() has shown that a key is not an agency
key, the SDK logs one console warning the first time that key sends the header. The
SDK never makes an extra request just to check.
License
MIT
Conditional custom-record updates
Custom-record responses expose revision. Pass it when saving an interactive edit:
import { g8 } from "@graph8/sdk";
g8.init({ apiKey: "YOUR_API_KEY" });
const projectId = "your-project-record-id";
const record = await g8.objects.getRecord("projects", projectId);
const updated = await g8.objects.updateRecord(
"projects", record.id, { name: "Updated project" },
{ expectedRevision: record.revision },
);A stale revision returns HTTP 409 with code revision_conflict; reload the record
and reconcile the edit before retrying. Native and app APIs accept
expected_revision in the PATCH body, and the MCP update tool accepts the same
argument. Omitting it retains unconditional PATCH behavior. Older servers may
omit revision; deploy the revision-enabled backend before relying on this
precondition. This applies to custom-object records.
Restore a custom record with g8.objects.restoreRecord(objectSlug, recordId).
The native API uses POST /api/v1/objects/{objectSlug}/records/{recordId}/restore;
MCP exposes g8_object_record_restore. Restoration preserves the original ID and
old history, validates the current schema and references, and returns 409 if a
unique value has been reused. Validation failures leave the record archived.
Ambiguous legacy history returns 422 with archive_snapshot_unavailable rather
than guessing its prior values. Repeating a successful restore adds no revision
or history entry.
Append or remove multivalue items
import { g8 } from "@graph8/sdk";
g8.init({ apiKey: "YOUR_API_KEY" });
const projectId = "your-project-record-id";
const record = await g8.objects.getRecord("projects", projectId);
await g8.objects.updateRecord("projects", projectId, {}, {
appendValues: { tags: ["priority"] },
removeValues: { reviewers: ["former-reviewer-id"] },
expectedRevision: record.revision,
});Native and app PATCH APIs use append_values and remove_values; the MCP
g8_object_record_update tool accepts those same names. Each field must occur
in exactly one of values, append_values, or remove_values. These operations
require a multivalue attribute; values still replaces the entire supplied field.
Append preserves existing order and adds only canonically distinct items. Remove
deletes all matching items. The resulting values must satisfy required, unique,
and reference constraints. Changes are atomic and read the current values under
a record lock, so concurrent appends do not discard each other.
Repeating an append can leave the values unchanged while still advancing the record revision and history. This is not an idempotency guarantee for events. Deploy the matching backend before using these optional request fields.
Record mutation history
import { g8 } from "@graph8/sdk";
g8.init({ apiKey: "YOUR_API_KEY" });
const firstPage = await g8.objects.changes("projects", "your-project-record-id", { limit: 50 });
if (firstPage.next_cursor) {
const olderPage = await g8.objects.changes("projects", "your-project-record-id", {
limit: 50,
cursor: firstPage.next_cursor,
});
console.log(olderPage.entries);
}The matching native and app APIs expose /objects/{object_slug}/records/{record_id}/changes
and /app/objects/{object_slug}/records/{record_id}/changes. The MCP tool is
g8_object_record_changes. These additions require the matching backend deployment.
Entries include the record revision, action, actor identity/type, source, time,
and changed values. Presence flags distinguish a cleared field from a present null.
An empty page means no mutation entries were recorded, not that the record never
changed; the existing value-history endpoint remains available for older facts.
Use the returned opaque cursor for older pages. A storage failure is an error,
not an empty history. This interface does not establish broader object/record ACL parity.
Conditional deal updates (unreleased)
Read the canonical deal record through GET /api/v1/objects/deals/records/{id}
to obtain its CRM revision, then pass expected_revision to
g8.deals.update(id, { name: "Updated", expected_revision: revision }).
A stale edit returns HTTP 409; reload and reconcile before retrying. A successful
conditional update returns revision. If tenant revision tracking is unavailable,
the conditional write returns 503 before mutation. Omitting expected_revision
retains the existing PATCH contract. The MCP g8_update_deal tool accepts the same
field. Deal UI integration and deployed QA verification remain pending.
