--- title: XRPC Handlers description: Define typed API endpoints — queries for GET, procedures for POST. --- # XRPC Handlers XRPC handlers are typed API endpoints that extend your hatk server's API. They come in two kinds: **queries** (read-only GET requests) and **procedures** (POST requests that can modify data). Each handler maps to a lexicon that defines its parameter types, input/output schemas, and error cases. ## Defining a query Use `defineQuery()` for read-only GET endpoints. The handler receives a typed context object and returns a response via `ctx.ok()`: ```typescript // server/xrpc/getPlay.ts import { defineQuery, NotFoundError, views, type Play, type Profile } from '$hatk' export default defineQuery('xyz.appview.unspecced.getPlay', async (ctx) => { const { ok, params, resolve, lookup, blobUrl } = ctx const { uri } = params const records = await resolve([uri]) if (records.length === 0) throw new NotFoundError('Play not found') const record = records[0] const profiles = await lookup('app.bsky.actor.profile', 'did', [record.did]) const profile = profiles.get(record.did) return ok({ play: views.playView({ record: { uri: record.uri, did: record.did, handle: record.handle, ...record.value, }, author: profile ? { did: profile.did, handle: profile.handle, displayName: profile.value.displayName, avatar: blobUrl(profile.did, profile.value.avatar, 'avatar'), } : undefined, }), }) }) ``` The `params` object is typed from your lexicon's parameter definitions. In this case, `params.uri` is a string because the lexicon declares it. The `ok()` function enforces the output schema at the type level -- if your return value doesn't match, TypeScript will error. ## Defining a procedure Use `defineProcedure()` for POST endpoints that modify data. The request body is available via `ctx.input`, typed from your lexicon's input schema: ```typescript // server/xrpc/doSomething.ts import { defineProcedure } from '$hatk' export default defineProcedure('dev.hatk.unspecced.doSomething', async (ctx) => { const { ok, db, viewer, input } = ctx if (!viewer) throw new Error('Authentication required') const { name, value } = input await db.run( `INSERT INTO my_table (did, name, value, created_at) VALUES ($1, $2, $3, $4)`, viewer.did, name, value, new Date().toISOString(), ) return ok({}) }) ``` ## Context reference Both `defineQuery` and `defineProcedure` handlers receive the same context object: | Field | Type | Description | | --------------------- | ------------------------------------------ | --------------------------------------------------------------------- | | `ok` | function | Wraps your return value with type checking | | `params` | object | Typed parameters from the lexicon schema | | `input` | object | Request body (procedures only), typed from the lexicon's input schema | | `db.query` | function | Run SQL queries against your SQLite database | | `spaceFilter` | function | Permissioned-space gate for hand-written SQL | | `db.run` | function | Execute SQL statements (INSERT, UPDATE, DELETE) | | `viewer` | `{ did: string; handle?: string }` \| null | The authenticated user, or null | | `limit` | number | Requested page size | | `cursor` | string \| undefined | Pagination cursor | | `resolve` | function | Resolve AT URIs into full records | | `getRecords` | function | Fetch records by URI from another collection | | `lookup` | function | Look up records by a field value | | `count` | function | Count records by field value | | `exists` | function | Check if a record exists matching field filters | | `search` | function | Full-text search a collection | | `labels` | function | Query labels for a list of URIs | | `blobUrl` | function | Resolve a blob reference to a CDN URL | | `packCursor` | function | Encode a `(primary, cid)` pair into a cursor string | | `unpackCursor` | function | Decode a cursor back into `{ primary, cid }` | | `isTakendown` | function | Check if a DID has been taken down | | `filterTakendownDids` | function | Filter a list of DIDs, returning those taken down | | `createRecord` | function | Write a record to the viewer's PDS and index locally | | `putRecord` | function | Create or update a record on the viewer's PDS | | `deleteRecord` | function | Delete a record from the viewer's PDS and local index | ### `ctx.ok()` Every handler must return `ctx.ok(data)`. This wraps your response with type checking against the lexicon's output schema. If the shape doesn't match, TypeScript catches it at compile time. ### `ctx.db.query()` and `ctx.db.run()` Run SQL against your SQLite database. Use `db.query()` for SELECT statements that return rows, and `db.run()` for INSERT/UPDATE/DELETE: ::: warning Hand-written SQL and permissioned spaces If your instance indexes [permissioned spaces](/guides/spaces), a raw query over a table a space writes into is refused unless it carries `ctx.spaceFilter` — the error names the table. Queries over any other table, and every typed helper below, are unaffected. `ctx.db.unfiltered` is the explicit door for a result never served to a viewer. ::: ```typescript // Query — returns rows const rows = await db.query( `SELECT CAST(COUNT(*) AS INTEGER) AS play_count FROM "fm.teal.alpha.feed.play" WHERE did = $1`, [params.actor], ) // Run — executes a statement await db.run(`INSERT INTO my_table (did, value) VALUES ($1, $2)`, viewer.did, input.value) ``` ### `ctx.resolve()` and `ctx.lookup()` These helpers fetch records without writing raw SQL: ```typescript // Resolve AT URIs into full records const records = await resolve([uri]) // Look up records by a field value — returns a Map keyed by the field const profiles = await lookup('app.bsky.actor.profile', 'did', [did1, did2]) const profile = profiles.get(did1) ``` ### `ctx.viewer` `viewer` is `{ did: string; handle?: string }` when the request comes from an authenticated user, or `null` for unauthenticated requests. Check it to protect endpoints that require authentication: ```typescript if (!viewer) throw new Error('Authentication required') ``` ## Error handling Import error classes from your generated types to throw standard XRPC errors: ```typescript import { NotFoundError, InvalidRequestError } from '$hatk' // 404 — record not found throw new NotFoundError('Play not found') // 400 — bad request throw new InvalidRequestError('Missing required field') ``` These map to standard XRPC error responses that clients can handle predictably. ## Lexicon pairing Each handler must have a matching lexicon definition. The file path mirrors the NSID: ``` lexicons/dev/hatk/unspecced/getPlay.json → server/xrpc/getPlay.ts ``` The lexicon defines parameter types, input/output schemas, and whether the endpoint is a query or procedure. See the [AT Protocol lexicon docs](https://atproto.com/specs/lexicon) for schema details. ## Generating a handler Use the CLI to scaffold a new handler: ```bash hatk generate xrpc dev.hatk.unspecced.getPlay ``` This creates the handler file with the right imports and structure.