Call the helpdesk from your own code
Open conversations, record events on a customer's timeline, read the data and delete it, from your server code in the same process.
The object buildHelpdesk() returns is the API. Your server code imports it and calls it in process, so there is no API token to issue, no webhook to register and no REST client to install. The examples below import it from lib/helpdesk.ts, as in the quickstart. What buildHelpdesk returns lists every member with its signature, and How it works explains why there is no REST API.
Open a conversation for a customer
To open a conversation when a payment fails, sign an identity token for the customer and hand createConversation a request that carries it. This needs identityTokenSecret in the config, as in Sign customers in from another origin. A webhook request carries no session, so identify returns null for it and the token decides who the customer is. If your identify reads the session from somewhere other than the request, such as Auth.js auth() inside a server action a signed-in user triggered, that user wins over the token, so call createConversation from a webhook or a job:
import { signIdentityToken } from 'better-helpdesk';
import { helpdesk } from '@/lib/helpdesk';
export async function POST(request: Request) {
const event = await verifyBillingWebhook(request); // however your provider does it
if (event.type !== 'invoice.payment_failed') return new Response(null, { status: 204 });
const { user, invoice } = event;
const token = signIdentityToken(
{ sub: user.id, email: user.email, email_verified: user.emailVerified, name: user.name },
process.env.HELPDESK_IDENTITY_SECRET!,
{ expiresInSeconds: 60 }
);
const { conversation } = await helpdesk.createConversation(
new Request(request.url, { headers: { 'x-helpdesk-identity': token } }),
{
inbox: 'billing',
type: 'question',
subject: `Payment failed for invoice ${invoice.number}`,
body: `The card on file was declined for invoice ${invoice.number}.`,
}
);
return Response.json({ reference: conversation && helpdesk.reference(conversation) });
}The conversation lands on the customer's contact, creating it if they never wrote in, and takes the same path as one from the widget: agents are notified, AI triage runs when ai is set, and onEvent hears conversation.created. The body is stored as the customer's first message.
Give billing an entry in inboxes without receipt, or the customer is mailed a receipt for a message they did not send. The inbox and type must exist in your config, the input takes the same fields as the widget's request, and the call counts against the customer's hourly limit, customerRateLimit.
Open a conversation from the team
When support starts the conversation, after a phone call or to reach a customer before they write in, open it in an agent's name. The agent's message is stored as the first one and emailed to the customer, who answers in the widget or by mail:
const { conversation } = await helpdesk.openConversation({
agentUserId: user.id, // a host user who is an agent
inbox: 'support',
type: 'question',
email: 'ops@example.test',
subject: 'Incident INC-42',
body: 'We are looking into the outage you reported by phone.',
priority: 'urgent',
});It takes the same fields as the agent route: a contactId or an email, with a name for a contact that has to be made. The conversation opens pending with the customer's turn, assigned to that agent unless assigneeId says otherwise, and onEvent hears conversation.created with the agent's message. An agentUserId that is not an active agent throws a HelpdeskError with status 400.
Write what your systems learn
When something happens elsewhere that the thread should record, such as an incident acknowledged on your pager, a deploy that fixes the reported bug or an issue closed in your tracker, add a note. An internal note is for the team; a public one goes to the customer like a reply:
await helpdesk.addNote(conversationId, 'PagerDuty: acknowledged by Sam at 14:02.');
await helpdesk.addNote(conversationId, 'The fix is live.', {
agentUserId: user.id,
internal: false,
});Without agentUserId the note is from the system. Both raise message.created and show in open agent UIs at once. For a conversation your own system should move on, change it as an agent would:
await helpdesk.changeConversation(conversationId, { status: 'resolved', tags: ['incident'] });It takes the body of the PATCH route, writes the timeline, and raises conversation.updated with the agent named in agentUserId or with none. Writes through helpdesk.store do none of that.
Put an event on a customer's timeline
To show a "plan upgraded" event on the contact's page in the agent UI:
await helpdesk.track({
externalUserId: user.id,
event: 'plan.upgraded',
props: { from: 'starter', to: 'team' },
});track returns false and records nothing when that user has no contact yet. Pass externalOrgId instead of externalUserId to put the event on the company's timeline; the company is created from that id if it does not exist.
Read the data
helpdesk.store holds the queries the agent UI runs, such as listInbox, listContacts and listActivities, and helpdesk.reference(conversation) formats a reference such as ACME-1042. Writes through the store skip onEvent. For reports across many conversations, query the database directly, as in Report from your own database.
Delete a customer's data
When someone deletes their account, delete what the helpdesk holds about them. deleteContact takes the helpdesk's own contact id, so look it up by your user id first. Then look up their address as well: what they wrote as a visitor or by email before the address was proven sits on a separate contact, which carries the address but not your user id.
const contact = await helpdesk.store.findContactByIdentity('host', user.id, {
verifiedOnly: true,
});
if (contact) await helpdesk.deleteContact(contact.id);
// Addresses are stored trimmed and in lowercase.
const email = user.email.trim().toLowerCase();
for (;;) {
const other = await helpdesk.store.findContactByEmail(email);
if (!other) break;
await helpdesk.deleteContact(other.id);
}findContactByEmail returns the oldest contact with the address, so the loop runs until none is left. Each call deletes the contact's conversations with the files in your storage, what they wrote in conversations they were added to, and the contact itself. For an organization, deleteCompany takes your own org id and deletes the company, its conversations and the contacts who have nothing left outside it:
await helpdesk.deleteCompany(org.id);Both return { conversations }, the number of conversations deleted. To delete resolved conversations on a schedule instead, set retentionDays, as in Run jobs and retention. When someone leaves your support team rather than your product, see Remove someone from the team.
Handle refusals
What the helpdesk refuses, it throws as a HelpdeskError with an HTTP status: 400 for an unknown inbox or type, 401 for a request it cannot tie to anyone, 429 when a limit is reached. Validation errors from the input are Zod errors.
import { HelpdeskError } from 'better-helpdesk';
try {
await helpdesk.createConversation(request, input);
} catch (error) {
if (error instanceof HelpdeskError && error.status === 429) {
// try again in the next hour
} else {
throw error;
}
}Search your own docs from the widget
Let the widget suggest articles from your documentation while a customer types, before they send their question.
React to events
Run your own code when a conversation is opened, a message is written or an agent changes something, to notify Slack, open an issue or assign work.