Customers
Every project has a customer directory that builds itself: whenever someone opens a ticket or responds to feedback, Helmdesk records (or refreshes) a customer keyed by their email. The API lets you enrich those records from your own app — name, company, your own user id, private notes, arbitrary metadata — and pull a customer's full interaction history in one call.
Which project?
Every customer belongs to one project. A key limited to one project implies it — send nothing extra. A key that reaches several projects (the kind you give a coding agent) names the project on each request with the Helmdesk-Project header (slug or id) or ?project=; without it the API answers 400 project.required with the projects it could have meant. Every object in a response carries projectId and projectSlug, so you can always tell what you are looking at.
curl https://helmdesk.dev/api/v1/customers \
-H "Authorization: Bearer sk_live_..." \
-H "Helmdesk-Project: ledgerly"// Pin the project once — or set HELMDESK_PROJECT and omit it.
const helmdesk = new HelmdeskClient({ apiKey, project: 'ledgerly' })
await helmdesk.customers.list()See Authentication for how keys and projects fit together.
Sync customers from your app
Set externalId to your app's user id when you create (or enrich) a customer. From then on your backend can resolve its own users to Helmdesk customers without ever passing an email around:
import { HelmdeskClient } from '@helmdesk/sdk'
const helmdesk = new HelmdeskClient({ apiKey: process.env.HELMDESK_API_KEY! })
// e.g. on signup in YOUR app
await helmdesk.customers.create({
email: user.email,
name: user.name,
company: user.company,
externalId: user.id, // your app's user id
metadata: { plan: 'pro' }, // anything you like, up to 8 KB
})
// Later, anywhere in your app: who is user_42 in Helmdesk?
const { customers } = await helmdesk.customers.list({ externalId: 'user_42' })Emails are unique per project and environment — create returns a 409 if the customer already exists. Customers created automatically from a ticket or feedback start with just email and name; use update to enrich them after the fact.
Who is this person?
get returns the customer plus their interaction history — tickets and feedback conversations joined by email, recent-first, capped at 50 each. One call gives an agent (human or AI) everything the customer has ever asked or said:
const detail = await helmdesk.customers.get(customerId)
detail.history.tickets
// [{ id, number, subject, status, createdAt }, ...]
detail.history.feedbackConversations
// [{ id, title, rating, status, createdAt }, ...]Look up by email
Email is the join key to tickets and feedback, so it is often all you have — the address on a ticket, or in a message someone forwarded. GET /v1/customers/:id accepts an email address in place of the id (percent-encode the @) and returns the same detail + history. The match is case-insensitive within the project and environment; an unknown address is a 404. Updates and deletes still take the id — read it off the result.
curl "https://helmdesk.dev/api/v1/customers/jane%40example.com" \
-H "Authorization: Bearer sk_live_..." \
-H "Helmdesk-Project: ledgerly"const detail = await helmdesk.customers.getByEmail('jane@example.com')
// same as: await helmdesk.customers.get('jane@example.com') — both URL-encode for you
await helmdesk.customers.update(detail.id, { company: 'Acme Corp' })CRM-style notes and updates
internalNote is staff-only — it is returned by the API and shown in the dashboard, but never exposed to the customer. Use it to carry context between interactions. Email is immutable (it is the join key back to tickets and feedback); every other field is patchable, and null clears:
await helmdesk.customers.update(customerId, {
company: 'Acme Corp',
internalNote: 'Churn risk — pricing complaint in March, handled by Sam',
externalId: null, // clears
})
// Deleting only removes the directory entry — tickets and feedback are
// untouched, and a new interaction re-creates the customer.
await helmdesk.customers.delete(customerId)Tags
Tags are how you segment the directory. Use a plain tag for a property a customer either has or does not — vip, beta-tester — and a namespace:value tag for state that has exactly one value at a time: plan:gold, billing:paying.
Tags are normalized before storage — trimmed, whitespace-collapsed, and lowercased — so Paying and paying are one tag rather than two segments that each look right on their own. A tag is at most 64 characters and may contain at most one :; a customer may carry up to 50.
Syncing plan and billing state
Reach for setTags with a namespace rather than add/remove. It replaces only that namespace and leaves every other tag alone, so it converges on the right state from wherever the customer started — your billing webhook never has to look up what plan they were on in order to un-set it.
// In your Stripe webhook handler. Safe to call on every event.
await helmdesk.customers.setTags(customerId, [subscription.plan], {
namespace: 'plan',
})
// plan:gold -> plan:premium. An unrelated `vip` tag is untouched.
// Same idea for a boolean-ish state:
await helmdesk.customers.setTags(customerId, [paid ? 'paying' : 'free'], {
namespace: 'billing',
})Add and remove are still there for properties that genuinely are a set:
await helmdesk.customers.addTag(customerId, 'vip')
await helmdesk.customers.removeTag(customerId, 'beta-tester')
// Replace every tag at once (no namespace):
await helmdesk.customers.setTags(customerId, ['vip', 'plan:gold'])Segmenting
Tags on the list filter are ANDed, not ORed — the useful question is usually “paying customers on the gold plan”, not “anyone who is either”.
const { customers } = await helmdesk.customers.list({
tags: ['billing:paying', 'plan:gold'],
})Search the directory
const { customers, total } = await helmdesk.customers.list({
search: 'acme', // substring match on email, name, or company
page: 1,
limit: 25, // max 100; sorted by most recently seen
})Webhooks
Two events keep your systems in sync: customer.created fires when a customer enters the directory (explicitly via the API, or automatically from their first ticket or feedback), and customer.updated fires on profile changes — including tag changes, so a plan swap is as visible as a rename. See Webhooks.
Scopes & environments
customers:read— list and view customers and their interaction history.customers:write— create, update, and delete customers.- Customers live per environment: an
sk_sandbox_…key only sees and touches the sandbox directory — ideal for integration tests. See Sandbox.
Endpoints
| Endpoint | Purpose |
|---|---|
| GET /v1/customers | List/search (filter by search text, externalId, or tag) |
| POST /v1/customers | Create explicitly (409 on duplicate email) |
| GET /v1/customers/:id | Customer + interaction history (tickets, feedback). Also accepts an email in place of the id |
| PATCH /v1/customers/:id | Partial update (email immutable; null clears a field). Id only |
| DELETE /v1/customers/:id | Remove from the directory (tickets/feedback untouched) |
| POST /v1/customers/:id/tags | Add one tag (idempotent) |
| PUT /v1/customers/:id/tags | Replace all tags, or just one namespace — the sync endpoint |
| DELETE /v1/customers/:id/tags?tag= | Remove one tag |
The MCP server exposes the same surface as tools (list_customers, get_customer, update_customer, …) — so an AI assistant can answer "who is this person and what have they asked us before?" in one step. See MCP Server.