Call the API from a Script

This guide calls the tRPC Platform API from a standalone Node or Bun script. It covers getting a token, a raw fetch version (to see the superjson wire format), the typed @trpc/client version, paginating a list, and handling the error envelope.

Get a token

Programmatic callers authenticate with a session JWT in the Authorization header:

Authorization: Bearer <session JWT>

The token is your session JWT, the same value your session cookie carries. The simplest way to get it is Sign in with Inklura in the API Explorer (see Authentication). Keep it out of source; read it from the environment:

export INKLURA_TOKEN="<session JWT>"

The script acts as the token's user, with that user's permissions. The token names that account's home tenant and site; the scope of each call is resolved on the request and checked against the account's access (see Sessions & Tokens).

Check that the token is accepted before doing real work by calling auth.getSession (shown below). An invalid or expired token is not reported as such: the request is treated as anonymous and the call fails with UNAUTHORIZED (401). auth.getSession requires the auth:read permission: an account without it gets FORBIDDEN (403), even with a valid token.

Raw fetch

Useful for seeing the wire format. Queries are GET with the input URL-encoded into ?input={"json":<value>}; mutations are POST with the body {"json":<value>}. The result is nested at result.data.json, and an error at error.json.

const BASE = "https://manage.inklura.fr/api/trpc";
const auth = { Authorization: `Bearer ${process.env.INKLURA_TOKEN}` };

// Read a response: return the value, or throw the tRPC error.
async function unwrap(res: Response) {
  const body = await res.json();
  if (body.error) {
    const { message, data } = body.error.json;
    throw new Error(`${data?.code} (${data?.httpStatus}): ${message}`);
  }
  return body.result.data.json;
}

// Query (GET) without input: who am I?
const me = await unwrap(await fetch(`${BASE}/auth.getSession`, { headers: auth }));
console.log("session:", me.user.email);

// Query (GET) with input: first page of products
const input = encodeURIComponent(JSON.stringify({ json: { page: 0, limit: 10 } }));
const firstPage = await unwrap(
  await fetch(`${BASE}/products.list?input=${input}`, { headers: auth }),
);
console.log("products:", firstPage.total);

// Mutation (POST): create a client
const created = await unwrap(
  await fetch(`${BASE}/clients.create`, {
    method: "POST",
    headers: { ...auth, "Content-Type": "application/json" },
    body: JSON.stringify({ json: { name: "Acme SARL", email: "contact@acme.example" } }),
  }),
);
console.log("created client:", created.id);

A procedure that takes no input, such as auth.getSession, is called without ?input=: sending an input to it fails validation.

With raw fetch, values that JSON cannot represent (dates in particular) arrive as strings in json, with their original type described alongside in meta. The typed client below converts them back for you.

Typed @trpc/client

For a TypeScript caller, @trpc/client (v11) handles the superjson wrapping and batching for you. Configure the superjson transformer on an httpBatchLink and set the Authorization header there:

import { createTRPCClient, httpBatchLink } from "@trpc/client";
import superjson from "superjson";

// AppRouter: the API's router type if you have it; `any` works too, without type checking.
const client = createTRPCClient<AppRouter>({
  links: [
    httpBatchLink({
      url: "https://manage.inklura.fr/api/trpc",
      transformer: superjson,
      headers() {
        return { Authorization: `Bearer ${process.env.INKLURA_TOKEN}` };
      },
    }),
  ],
});

const session = await client.auth.getSession.query();
const created = await client.clients.create.mutate({
  name: "Acme SARL",
  email: "contact@acme.example",
});

With the transformer configured, you pass and receive plain values, with no manual {"json":…} wrapping. See tRPC Procedures for the calling conventions and batching details.

clients.create requires name (non-empty); email is optional but must be a valid address when given. Your account also needs permission to edit clients: otherwise the call fails with FORBIDDEN.

Paginate a list

products.list takes { page, limit }, with page starting at 0 (limit defaults to 10), and returns { items, total, page, limit, pages }. It also accepts optional filters: search (name, description, reference or SKU), active, categoryId, and the sortBy / sortOrder sort (newest first by default). Walk every page:

async function listAllProducts() {
  const all = [];
  const limit = 50;
  for (let page = 0; ; page += 1) {
    const res = await client.products.list.query({ page, limit });
    all.push(...res.items);
    if (page + 1 >= res.pages || res.items.length === 0) break;
  }
  return all;
}

Pagination is a convention rather than a framework guarantee: parameter names and the first page number vary between procedures, so confirm the shape for the procedure you call. See Pagination & Filtering.

Handle errors

Failures come back as the standard tRPC error envelope; the useful fields are in data (code, httpStatus, path), and input validation failures add data.zodError. The typed client throws a TRPCClientError whose data carries them:

import { TRPCClientError } from "@trpc/client";

try {
  await client.clients.create.mutate({ name: "", email: "not-an-email" });
} catch (err) {
  if (err instanceof TRPCClientError) {
    const data = err.data; // { code, httpStatus, path, zodError }
    console.error(data?.code, data?.httpStatus); // BAD_REQUEST 400
    if (data?.zodError) {
      console.error("field errors:", data.zodError.fieldErrors);
    }
  } else {
    throw err;
  }
}

With raw fetch, read the same fields off error.json.data in the parsed body (as unwrap does above). See Errors & Rate Limits for the code-to-status mapping and the zodError shape.

Related