Skip to content

Provisioning via the Admin API

The Porulle SDK is a typed wrapper around your server’s REST API. You generate types from your live server’s OpenAPI spec, so every admin call is type-checked against your exact deployment — including plugin routes you’ve added.

This guide takes you from “fresh API key” to “first product live and inventoried” in under five minutes.

  • A running Porulle server (local or production). If you don’t have one, follow the Quickstart.

  • An admin API key. Create one with the CLI:

    Terminal window
    porulle api-key create --server https://api.acme.com --name admin-cli --scopes "*:*"
  1. Install the SDK and the codegen toolchain.

    Terminal window
    bun add @porulle/sdk
    bun add -d openapi-typescript
  2. Generate types from your live server. The codegen reads /api/doc (which serves your OpenAPI 3.1 spec) and writes a typed paths object.

    Terminal window
    bunx @porulle/sdk generate \
    --url https://api.acme.com/api/doc \
    --out src/api-types.ts
  3. Create the typed client.

    src/api.ts
    import { createPorulleClient } from "@porulle/sdk";
    import type { paths } from "./api-types";
    export const api = createPorulleClient<paths>({
    baseUrl: process.env.PORULLE_BASE_URL!,
    apiKey: process.env.PORULLE_ADMIN_KEY!,
    });

Every catalog item is a sellable_entity. Products, services, gift cards, subscriptions all use the same shape — what differs is the type and the typed fields.

scripts/seed-product.ts
import { api } from "../src/api";
const created = await api.POST("/api/admin/entities", {
body: {
type: "product",
name: "Cotton Tee",
slug: "cotton-tee",
description: "Heavyweight 240gsm cotton, screen-printed.",
fields: {
weight: 240, // grams — declared in commerce.config.ts
material: "cotton",
},
variants: [
{ sku: "TEE-S-BLK", optionValues: { size: "S", color: "black" }, price: 2900 },
{ sku: "TEE-M-BLK", optionValues: { size: "M", color: "black" }, price: 2900 },
{ sku: "TEE-L-BLK", optionValues: { size: "L", color: "black" }, price: 2900 },
{ sku: "TEE-S-WHT", optionValues: { size: "S", color: "white" }, price: 2900 },
{ sku: "TEE-M-WHT", optionValues: { size: "M", color: "white" }, price: 2900 },
],
},
});
if (created.error) {
console.error("Failed:", created.error);
process.exit(1);
}
console.log(`Created entity ${created.data.id} with ${created.data.variants.length} variants`);

Prices are integers in the smallest currency unit (cents for USD, øre for DKK). Currency itself comes from commerce.config.ts — never pass it per request.

A product with no inventory shows as out of stock. Set per-variant levels per location.

scripts/seed-inventory.ts
import { api } from "../src/api";
const entityId = "ent_…"; // from Step 2
await api.POST("/api/admin/inventory/levels", {
body: {
entityId,
locationId: "wh_main", // create locations via /api/admin/locations
levels: [
{ variantId: "var_s_black", available: 50 },
{ variantId: "var_m_black", available: 80 },
{ variantId: "var_l_black", available: 30 },
],
},
});

For multi-warehouse stores, create one call per location. The inventory.afterChange hook fires on each call — useful for syncing to external WMS.

Entities are created in draft status. Move them to published to expose on the storefront.

await api.PATCH("/api/admin/entities/{id}", {
params: { path: { id: entityId } },
body: { status: "published" },
});

The product is now reachable at:

  • GET /api/catalog/entities/cotton-tee — public catalog read
  • GET /api/search?q=cotton+tee — full-text search
  • Whatever storefront you point at the API
// Create a customer
const { data: customer } = await api.POST("/api/admin/customers", {
body: { email: "kai@acme.com", firstName: "Kai", lastName: "Lin" },
});
// Find by email
const { data: existing } = await api.GET("/api/admin/customers", {
params: { query: { email: "kai@acme.com" } },
});

Every SDK call returns { data, error }. Errors are typed — narrow on error.code for behavior.

const { data, error } = await api.POST("/api/admin/entities", { body: {...} });
if (error) {
switch (error.code) {
case "VALIDATION_ERROR":
console.error("Bad input:", error.fields);
break;
case "DUPLICATE_SLUG":
console.error("Slug already taken — pick another");
break;
case "PERMISSION_DENIED":
console.error("API key lacks the entities:create scope");
break;
default:
console.error("Unexpected:", error);
}
return;
}
// data is fully typed beyond this point

The kernel never throws across module boundaries — see the Result Types concept for the discipline.

For mutating endpoints, pass an Idempotency-Key header. Repeating the same key returns the cached response without re-running the mutation.

await api.POST("/api/admin/entities", {
body: {...},
headers: { "Idempotency-Key": "seed-2026-05-cotton-tee" },
});

Keys live for 24 hours.

A complete seed script for a fresh production store:

scripts/seed.ts
import { api } from "../src/api";
async function main() {
// 1. Locations
const { data: warehouse } = await api.POST("/api/admin/locations", {
body: { name: "Main Warehouse", code: "wh_main", type: "warehouse" },
});
// 2. Products
const products = [
{ name: "Cotton Tee", slug: "cotton-tee", price: 2900 },
{ name: "Wool Hoodie", slug: "wool-hoodie", price: 8900 },
{ name: "Canvas Tote", slug: "canvas-tote", price: 1900 },
];
for (const p of products) {
const { data: entity } = await api.POST("/api/admin/entities", {
body: {
type: "product",
name: p.name,
slug: p.slug,
variants: [{ sku: `${p.slug}-default`, price: p.price }],
},
});
await api.POST("/api/admin/inventory/levels", {
body: {
entityId: entity.id,
locationId: warehouse.id,
levels: [{ variantId: entity.variants[0].id, available: 100 }],
},
});
await api.PATCH("/api/admin/entities/{id}", {
params: { path: { id: entity.id } },
body: { status: "published" },
});
console.log(`${p.name} live at /products/${p.slug}`);
}
}
main().catch((e) => {
console.error(e);
process.exit(1);
});
Terminal window
PORULLE_BASE_URL=https://api.acme.com \
PORULLE_ADMIN_KEY=pak_live_… \
bun run scripts/seed.ts