SvelteKit

Add the chat widget to a SvelteKit app with Svelte 5, with a +server.ts route for the token.

You’ll add one component to your root layout and one +server.ts route. The adapter needs Svelte 5.57.1 or newer.

Install

npm install @ticketping/chat-widget@2.0.0-beta.12 jose

v2 is in beta until 2.0.0 is released. Pin the exact version, as above: the next tag moves with every beta. To see the newest beta, run npm view @ticketping/chat-widget dist-tags.next.

The beta adapter is compiled with Svelte 5.57. With an older Svelte, vite build fails with "only_child" is not exported by ".../svelte/src/internal/client/index.js". Upgrade first: npm install -D svelte@^5.57.1.

Add the component

Pass the signed-in user from your root layout’s server load. Most apps set locals.user in hooks.server.ts; use whatever yours already has.

src/routes/+layout.server.ts:

ts
import type { LayoutServerLoad } from './$types'

export const load: LayoutServerLoad = ({ locals }) => ({
  user: locals.user
    ? { id: String(locals.user.id), email: locals.user.email, name: locals.user.name }
    : null
})

Get your publishable key (pk_...) from Settings → Widgets. It’s public.

src/routes/+layout.svelte:

svelte
<script lang="ts">
  import { onMount } from 'svelte'
  import { Ticketping } from '@ticketping/chat-widget/svelte'

  let { data, children } = $props()

  // The adapter is browser-only. Rendering it during SSR throws effect_orphan (a 500 on every page).
  let mounted = $state(false)
  onMount(() => {
    mounted = true
  })

  async function getToken() {
    const res = await fetch('/api/ticketping-token', { method: 'POST' })
    if (!res.ok) throw new Error(`Ticketping token request failed (${res.status})`)
    return res.text()
  }
</script>

{#if mounted}
  <Ticketping publishableKey="pk_..." user={data.user} {getToken} />
{/if}

{@render children()}

The component follows the user prop: it identifies on sign-in, switches users cleanly, and logs out when user becomes null (for example after your sign-out action invalidates the layout). undefined means “still loading” and does nothing. null before anyone was identified is also harmless.

Keep the {#if mounted} guard. The beta’s Svelte entry is compiled for the browser, so SvelteKit can’t render it on the server. vite build still passes without the guard; the 500 only shows up when a page is requested. Check with npm run preview and load a page before you deploy.

The Svelte adapter ships with 2.0. If a prop name here doesn’t match your installed beta, check the package README; the core JavaScript API works the same in every version.

Add the token route

src/routes/api/ticketping-token/+server.ts:

ts
import { env } from '$env/dynamic/private'
import { SignJWT } from 'jose'
import type { RequestHandler } from './$types'

export const POST: RequestHandler = async ({ locals }) => {
  const user = locals.user
  if (!user) return new Response('Unauthorized', { status: 401 })

  const profile: Record<string, string> = {}
  if (user.email) profile.email = user.email
  if (user.name) profile.name = user.name

  const token = await new SignJWT(profile)
    .setProtectedHeader({ alg: 'HS256' })
    .setSubject(String(user.id))
    .setExpirationTime('5m')
    .sign(new TextEncoder().encode(env.TICKETPING_IDENTITY_SECRET))

  return new Response(token, { headers: { 'Content-Type': 'text/plain' } })
}

locals.user is whatever your hooks.server.ts sets. Type it in src/app.d.ts as usual.

If your API runs on another origin

Many SvelteKit apps call a separate backend (for example api.example.com running Django or Rails) with a token instead of a session cookie. Then sign the token in that backend, not in SvelteKit. Skip the +server.ts route, and copy the backend route from Identity verification (for DRF, see Django).

ts
import { PUBLIC_API_URL } from '$env/static/public'

async function getToken() {
  const res = await fetch(`${PUBLIC_API_URL}/api/ticketping-token/`, {
    method: 'POST',
    // Send whatever your API already accepts, for example `Token <key>` or `Bearer <jwt>`.
    headers: { Authorization: `Token ${apiToken}` }
  })
  if (!res.ok) throw new Error(`Ticketping token request failed (${res.status})`)
  return res.text()
}
  • Use a plain fetch with no body. Shared API helpers often add Content-Type: application/json and a JSON body, which the route doesn’t need.
  • The backend must return the JWT as text/plain. A JSON string ("eyJ..." with quotes) is rejected. In DRF, return HttpResponse(token, content_type="text/plain"), not Response(token).
  • The backend’s CORS settings must allow your site’s origin, POST and the Authorization header. Token auth needs no CSRF header.
  • If the signed-in user lives in a client store rather than layout data, pass user={$auth.id ? { id: $auth.id, email: $auth.email } : null}. Your sign-out code must reset that store, which logs the widget out.

Set the secret

Get the identity secret from Settings → API → Identity secret.

.env:

bash
TICKETPING_IDENTITY_SECRET=tpis_...

The route reads it with $env/dynamic/private, so SvelteKit never lets it into client code. Set the same value in your host’s environment for every deployment.

Check that it works

  1. Run npm run build && npm run preview and load a few pages. They must return 200, not 500.
  2. Run npm run dev, sign in, and open the browser console.
  3. Run await (await fetch('/api/ticketping-token', { method: 'POST' })).text() and copy the token.
  4. Paste it into the token validator under Settings → API → Identity secret. It shows the claims, or says exactly what to fix.
  5. Reload and send a message. The dashboard shows it with the user’s name, a Verified badge and a TEST tag.

Use the widget from your components

user may be { id, email, name } or { userId, email, name } (strings or numbers). Other props (appearance, texts, features, locale, hideLauncher, consent) match the init options in Configuration.

Open it from your own buttons

Create the instance yourself and hand it to the component. Then any component can open the widget, start a conversation with a prefilled message, or call the rest of the JavaScript API.

src/lib/ticketping.ts:

ts
import { createTicketping } from '@ticketping/chat-widget/svelte'

// Safe to create during SSR: nothing touches the page until <Ticketping> mounts.
export const ticketping = createTicketping()

src/routes/+layout.svelte:

svelte
<Ticketping client={ticketping} publishableKey="pk_..." user={data.user} {getToken} />

Anywhere else:

svelte
<script lang="ts">
  import { ticketping } from '$lib/ticketping'
</script>

<button onclick={() => ticketping.showNewMessage('Question about order 1234')}>Contact support</button>

Calls made before the widget has finished loading are kept: it opens with your message once it’s ready. The component still owns the lifecycle: it calls init on mount and destroy on unmount.

Read its state

Components rendered inside <Ticketping> can call useTicketping(). It returns isOpen and unreadCount (reactive) plus every method.

svelte
<!-- +layout.svelte -->
<Ticketping client={ticketping} publishableKey="pk_..." user={data.user} {getToken}>
  {@render children()}
</Ticketping>
svelte
<script lang="ts">
  import { useTicketping } from '@ticketping/chat-widget/svelte'

  const ticketping = useTicketping()
</script>

<button onclick={() => ticketping.toggle()}>
  Help {#if ticketping.unreadCount}({ticketping.unreadCount}){/if}
</button>

Ticketping imported from @ticketping/chat-widget (the plain npm entry) is a separate instance and does not control the component; use createTicketping() from the Svelte entry as above.

Copy prompt for your AI coding agent

prompt
Add the Ticketping chat widget (v2) to this SvelteKit app (Svelte 5).

Docs index: https://ticketping.com/llms.txt
This page as Markdown: https://ticketping.com/docs/svelte.md

1. Install the exact beta: @ticketping/chat-widget@2.0.0-beta.12 (no ^, not @next) and jose.
   If package.json has svelte older than 5.57.1, upgrade it first (the adapter is compiled with 5.57;
   older versions fail vite build with "only_child" is not exported).
2. In src/routes/+layout.server.ts, return user: { id: String(id), email, name } for the signed-in user from
   locals (or this app's existing session code), or null.
3. In src/routes/+layout.svelte, render
   <Ticketping publishableKey="pk_..." user={data.user} {getToken} /> from '@ticketping/chat-widget/svelte'
   before {@render children()}, inside {#if mounted} where mounted is set to true in onMount.
   Do not drop the guard: the adapter is browser-only and SSR returns 500 (effect_orphan) without it.
   getToken POSTs to /api/ticketping-token and returns res.text(); throw if !res.ok. Never cache the token.
   Ask me for the publishable key (starts with pk_). It's public.
4. Create src/routes/api/ticketping-token/+server.ts exactly as on the docs page, adapting only the user lookup:
   401 when signed out, otherwise a text/plain JWT from jose,
   new SignJWT({ email, name }).setProtectedHeader({ alg: 'HS256' }).setSubject(String(user.id)).setExpirationTime('5m'),
   signed with the TICKETPING_IDENTITY_SECRET from $env/dynamic/private.
   If this app's API is a separate backend (another origin, token auth), skip this route: sign the token in that
   backend instead and point getToken at it, as in "If your API runs on another origin" on the docs page.
5. Add TICKETPING_IDENTITY_SECRET= to .env (not committed). Never import it from $env/static/public or $env/dynamic/public.
6. Run the production build and load a few pages with the preview server; they must return 200.
7. Tell me how to verify: sign in on localhost, fetch a token from the console, and paste it into the
   token validator at Settings → API → Identity secret.