@growstack/sdk
Typed client for the whole Growstack public API. MIT licensed, ESM with TypeScript declarations, using global fetch on Node 20+, Bun, Vercel and edge runtimes.
npm install @growstack/sdk
import { createGrowstack, GrowstackError } from '@growstack/sdk'
const growstack = createGrowstack({ apiKey: process.env.GROWSTACK_API_KEY! })
// For self-hosting: createGrowstack({ apiKey, baseUrl: 'https://marketing.example.com' })
Every instance keeps its own key, base URL and optional fetch(request) implementation. Use a secret key on your server. A public key belongs only in the browser operations allowed by your workspace's tracking policy.
In a signed-in browser, omit apiKey and use the app's origin. The SDK sends no
Authorization header and keeps cookies through Fetch's credentials option,
which defaults to same-origin. Set credentials: 'include' when your deployment
allows cookie-authenticated requests from another origin.
const personal = createGrowstack({ baseUrl: window.location.origin })
const accounts = await personal.connections.list()
Connections and member connection summaries require a session. API keys cannot access them.
Send a transactional message
Requires messages:send, an authorised sender on the key, and a verified sending domain. The message key is the workspace's published Simple message template. Other published template keys work the same way.
const sent = await growstack.messages.create({
body: {
to: '[email protected]',
from: { address: '[email protected]' },
purpose: 'transactional',
template: {
key: 'message',
variables: { subject: 'Your receipt', body: 'Thank you for your order.' },
},
},
}, { idempotencyKey: 'order:example-42:receipt' })
console.log(sent.id)
Upsert a contact
Requires people:write. An email or external identifier finds an existing contact or creates one. Omitted fields stay unchanged; conflicting identities are never merged silently.
const { person, created } = await growstack.people.upsert({
body: {
identifier: { email: '[email protected]' },
name: 'Ana',
country: 'ES',
source: 'shop',
},
})
Sync a product account
Requires people:write. The brand slug and immutable account id identify the
account. Keep its source revision increasing, using one counter or timestamp
kind. Equal or older revisions return ignored: true and leave all data intact.
const { account, ignored } = await growstack.productAccounts.upsert({
path: { brand: 'heralded', externalId: 'workspace-42' },
body: {
revision: 5,
name: 'Example workspace',
status: 'paying',
plan: 'Growth',
currency: 'EUR',
mrr: '79.00',
users: [{ identifier: { email: '[email protected]' }, role: 'owner' }],
},
})
Omitted fields stay unchanged. Supplied users replace membership. Users accept
contact-upsert identifiers; an external identifier may include an email.
Conflicting users remain unlinked and needsReview is true. Review explicitly
with productAccountUsers.review, choosing link or keep_apart.
The latter keeps the product identity without attaching the conflicting email.
Properties and field history
properties manages definitions for contact, company, deal, lead and
product_account. personProperties exposes the same contact definitions.
Shared properties have one key and type across their appliesTo objects.
const definitions = await growstack.properties.list({ path: { object: 'contact' } })
const plan = definitions.data.find(property => property.key === 'plan')
const growth = plan?.optionDefinitions?.find(option => option.label === 'Growth')
const contact = await growstack.people.get({ path: { id: person.id } })
console.log(contact.attributes.plan) // Current label, such as 'Growth'
console.log(contact.attributeOptions?.plan) // { id: '<option UUID>', label: 'Growth' }
Select writes accept a label or an option id in attributes. Multiselects
accept an ordered array of labels or ids. Reads keep labels in attributes
and expose {id, label} values in attributeOptions, using an array for a
multiselect in its stored order. Unmatched values are kept as written and
omitted from attributeOptions; invalidProperties names their fields.
Renaming an option preserves its id and resolves saved references to its old
label. optionDefinitions updates options by id and can archive or merge them.
peopleChanges.list and companiesChanges.list return field changes with
their old and new values, actor and time. Rewriting the same value creates no
change. Tenant exports include definitions and history.
Request a double opt-in subscription
Requires consent:write. Create a topic with double opt-in, or use an existing topic configured that way.
await growstack.topics.create({
body: { key: 'product-updates', name: 'Product updates', doubleOptIn: true },
})
await growstack.subscriptions.create({
body: { address: '[email protected]', topic: 'product-updates', source: 'shop' },
})
This creates a pending subscription. The recipient confirms through the emailed hosted link; the SDK never confirms consent itself. Consent evidence fields details and occurredAt require a secret key. Public keys and signed-in sessions cannot submit them.
Track an event
Requires events:write on a secret key. Browser tracking uses a public key and its workspace's event allowlist.
await growstack.track({
body: { event: 'order.completed', email: '[email protected]', data: { order: 'example-42' } },
}, { idempotencyKey: 'order:example-42:completed' })
Upload a brand logo
Requires brands:manage. Pass a PNG, JPEG or WebP Blob or File, up to 200 KiB,
with its MIME type. The client sends the original bytes and content type.
await growstack.brandsLogo.update({
path: { id: brand.id },
body: new Blob([imageBytes], { type: 'image/webp' }),
})
Open a customer billing page
Requires a secret key with billing:write. The company must be a billing customer
and the brand must be assigned to an issuing company. The customer needs no
invoices yet; the page then shows an empty list. Test mode is refused. Use an HTTPS
return URL. The returned link works once within five minutes; create it when the
customer chooses to open billing.
const portal = await growstack.billingPortalSessions.create({
body: {
companyId: customer.companyId,
brandId: brand.id,
returnUrl: 'https://app.example.com/account',
locale: 'en',
},
})
// Redirect your customer to portal.url.
The page supports English and Romanian, shows invoices and quote PDFs, and takes
invoice payments. It cannot accept quotes. Access ends after twelve idle hours
or when its issuing key is removed or loses billing:write.
Handle errors
try {
await growstack.people.upsert({
body: { identifier: { email: '[email protected]' }, source: 'shop' },
})
} catch (error) {
if (error instanceof GrowstackError) {
console.error(error.status, error.type, error.code, error.message, error.requestId)
// error.issues contains field paths and messages when supplied by the API.
} else {
throw error // Network failures and cancellation retain their original errors.
}
}
An error response outside the API envelope, such as a gateway's HTML 502, throws GrowstackError with its HTTP status, type: 'internal', code: 'unexpected_response' and requestId: null.
Calls, retries and cancellation
Methods accept the generated { path, query, body } input followed by optional { idempotencyKey, signal }, and return the decoded success body. Operations use the resource and action from Growstack's explicit operation map, such as growstack.campaigns.send and growstack.peopleEmailAddresses.list. growstack.raw exposes every operation id, such as raw.peopleUpsert and raw.trackCreate, with the same inputs and transport.
Every POST receives an automatic UUID idempotency key unless you supply one. Generated keys protect retries within one call. Pass your own stable key across separate calls that represent the same action, as in the receipt example. The API's replay window is 24 hours.
GETs and keyed POSTs retry at most three times after the first attempt for network failures, 429, 5xx and 409 idempotency_key_in_use. Each retry keeps the exact key and body. Delays use full jitter over min(8000, 500 × 2^retryIndex) milliseconds, or a longer valid Retry-After in seconds or as an HTTP date. Other 4xx, other 409s and caller cancellation never retry. PUT, PATCH and DELETE never retry.
const controller = new AbortController()
const result = growstack.people.list({ query: { limit: 100 } }, { signal: controller.signal })
// controller.abort() cancels the request or a retry delay.
await result
Iterate record pages
Every operation returning a record page has .all(input, options), including POST queries and nested lists. It yields one record at a time, preserves filters and parent parameters, and stops when hasMore is false.
for await (const person of growstack.people.list.all({ query: { limit: 100 } })) {
console.log(person.id)
}
for await (const row of growstack.people.query.all({
body: { filter: { person: 'country', op: 'eq', value: 'ES' }, limit: 100 },
})) {
console.log(row.person.id)
}
Each POST page gets its own key, reused for retries of that page. If an iterator receives a caller key, it applies to the first page; later pages generate new keys because their request bodies differ. The outbox iterator growstack.events.list.all sends checkpoints as after and stops when caught up, even though the final checkpoint remains available. Use events.list directly to save checkpoints for a later poll.
Develop and publish
The server owns the MIT public document. Export without workers, connections or credentials, then generate with the pinned @hey-api/openapi-ts fetch client:
bun run --cwd apps/server openapi:export
bun run --cwd packages/sdk generate
bun run --cwd packages/sdk build
bun run verify
Commit openapi.json, src/generated and src/bindings.gen.ts. Root verification re-exports and regenerates them, fails on drift, then builds and typechecks the SDK. TypeScript 6.0.3 is a local development dependency because the generator uses its compiler API; the repository's TypeScript 7 still runs root typechecking.
Component authoring uses growstack.components.preview and growstack.components.generate.
Repository publishing creates a version through growstack.library.create or
growstack.libraryVersions.create, then calls growstack.libraryVersions.publish. The result’s
approvalId identifies a required workspace review; publishedAt stays null until approval.
The repository’s bun run components CLI builds script dependencies with Bun and uses these
same operations. See packages/components/README.md.
bun run version:sdk patch|minor|major|<x.y.z> updates the version and changelog. The sdk-v<version> tag triggers the publishing workflow with npm trusted publishing over OIDC. The overseer configures the trusted publisher and pushes the initial sdk-v0.1.0 tag. Provenance is omitted while the source repository is private and must be enabled when it becomes public. The package contains the built SDK, MIT source and documentation, without generation scripts, server code or tests. The source supports the repository's @growstack/source export condition.
Pipelines and deals
growstack.deals creates, reads, queries, edits, reopens and deletes deals.
growstack.pipelines and growstack.pipelineStages manage pipelines and stages;
these writes require an owner or admin session. Stage edits preserve the won and
lost categories. A deal update can supply required fields together with stageId;
expectedStageId refuses a stale move. Query responses include open and weighted
totals in the workspace reporting currency and identify unavailable exchange rates.
growstack.dealSettings reads and updates the reporting currency and lost reasons.