# Call the helpdesk from your own code

URL: https://better-helpdesk.com/docs/app/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](/docs/quickstart). [What buildHelpdesk returns](/docs/reference/exports#what-buildhelpdesk-returns) lists every member with its signature, and [How it works](/docs/how-it-works#one-handler) explains why there is no REST API.

## Open a conversation for a customer [#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](/docs/identity/other-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:

```ts title="app/api/billing/webhook/route.ts"
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](/docs/reference/routes#opening-a-conversation), and the call counts against the customer's hourly limit, `customerRateLimit`.

## Open a conversation from the team [#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:

```ts
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](/docs/reference/routes#opening-a-conversation-as-an-agent): 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 [#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:

```ts
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:

```ts
await helpdesk.changeConversation(conversationId, { status: 'resolved', tags: ['incident'] });
```

It takes [the body of the PATCH route](/docs/reference/routes#agent), 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 [#put-an-event-on-a-customers-timeline]

To show a "plan upgraded" event on the contact's page in the agent UI:

```ts
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 [#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](/docs/database/reporting).

## Delete a customer's data [#delete-a-customers-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](/docs/identity/trust#verified-and-unverified), which carries the address but not your user id.

```ts
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:

```ts
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](/docs/operations/jobs). When someone leaves your support team rather than your product, see [Remove someone from the team](/docs/inbox/team).

## Handle refusals [#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.

```ts
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;
  }
}
```
