Products

A product is the catalog entry buyers see. Each product has one or more variants (size, color, SKU-level differences) and every stock row + shipment line + license pin to a variant, not the product. The Python SDK exposes both layers behind fulkruma.products. For HTTP shapes and the full field reference, see API → Products.

Namespace

fulkruma.products       # ProductsResources

Every method below shares the same httpx.Client as the parent.

Methods

create

fulkruma.products.create(body: dict, *, on_behalf_of: str | None = None) -> dict

Creates a product. Only name is required. type defaults to "physical"; pass "digital" for download-only goods (which then unlock the deliveries + licenses surfaces) or "license" for software sold as a key. The SDK auto-mints an idempotency key, so retries are safe.

result = fulkruma.products.create({
    "name": "Pawpado Premium License",
    "type": "digital",
    "description": "GPU portal — 12-month subscription",
    "licenseEnabled": True,
    "maxActivations": 3,
    "sku": "pawpado-premium-12m",
})

print(result["product"]["id"])  # "prod_01HX..."

sku is your handle into your own catalog. Products that Storlaunch mirrors into Fulkruma also carry externalRef + externalSource ("storlaunch"); those are set by that sync only — create and update don't accept them.

get

fulkruma.products.get(product_id: str, *, on_behalf_of: str | None = None) -> dict

Fetches one product + every variant in one response. Raises FulkrumaError(404, "not_found", ...) for missing or cross-workspace IDs.

result = fulkruma.products.get("prod_01HX...")
print(len(result["product"]["variants"]), "variants")

list

fulkruma.products.list(
    *,
    archived: bool | None = None,
    on_behalf_of: str | None = None,
) -> dict

Returns every product in the workspace. Pass archived=True to include archived ones, archived=False (or omit) to exclude. Not paginated.

result = fulkruma.products.list(archived=False)
for p in result["products"]:
    print(p["name"], len(p["variants"]), "variants")

update

fulkruma.products.update(
    product_id: str,
    patch: dict,
    *,
    on_behalf_of: str | None = None,
) -> dict

Partial update. To toggle license-mode on an existing product, pass {"licenseEnabled": True}; the next licenses.issue call will succeed instead of returning 400.

fulkruma.products.update("prod_01HX...", {
    "description": "Updated copy",
    "licenseEnabled": True,
})

archive

fulkruma.products.archive(product_id: str, *, on_behalf_of: str | None = None) -> dict

Soft-delete. Variants archive alongside the parent automatically. Historic shipments/licenses/stock movements still reference the archived product so audit trails keep working.

fulkruma.products.archive("prod_01HX...")

add_variant

fulkruma.products.add_variant(
    product_id: str,
    body: dict,
    *,
    on_behalf_of: str | None = None,
) -> dict

Adds a variant. name is required (e.g. "Size L / Red"); SKU, prices, low-stock threshold, weight are optional.

result = fulkruma.products.add_variant("prod_01HX...", {
    "name": "Size L",
    "sku": "TSHIRT-L",
    "priceCents": 2_500_000,
    "costCents": 1_250_000,
    "lowStockThreshold": 5,
    "isDefault": True,
})
print(result["variant"]["id"])

Variants share the parent product's type — you can't mix physical + digital variants under one product.

update_variant / archive_variant

fulkruma.products.update_variant(
    product_id: str, variant_id: str, patch: dict,
    *, on_behalf_of: str | None = None,
) -> dict

fulkruma.products.archive_variant(
    product_id: str, variant_id: str,
    *, on_behalf_of: str | None = None,
) -> dict
fulkruma.products.update_variant("prod_01HX...", "var_01HX...", {
    "priceCents": 3_000_000,
})

fulkruma.products.archive_variant("prod_01HX...", "var_01HX...")

Archiving the last live variant on a product is allowed but unusual — you typically archive the whole product instead.

Types

Every method returns a plain dict matching the API envelope. The product shape:

{
    "product": {
        "id": "prod_...",
        "accountId": "acc_...",
        "name": "...",
        "sku": "..." | None,
        "description": "..." | None,
        "type": "physical" | "digital" | "license",
        "weight": float | None,
        "licenseEnabled": bool,
        "maxActivations": int | None,
        "externalRef": "..." | None,
        "externalSource": "..." | None,
        "variants": [{...}, ...],
        "archivedAt": "..." | None,
        "createdAt": "...",
        "updatedAt": "..."
    }
}

See API → Products for per-field validation rules.

Common patterns

Create a simple product with one default variant. Most products have one SKU:

def create_simple(fulkruma, name: str, sku: str, price_cents: int) -> str:
    pres = fulkruma.products.create({"name": name})
    product_id = pres["product"]["id"]
    fulkruma.products.add_variant(product_id, {
        "name": "Default",
        "sku": sku,
        "priceCents": price_cents,
        "isDefault": True,
    })
    return product_id

Sync from your own catalog. If your own system owns the source-of-truth catalog, use sku to dedupe:

def upsert_product(fulkruma, sku: str, name: str, price_cents: int) -> str:
    result = fulkruma.products.list()
    existing = next((p for p in result["products"] if p.get("sku") == sku), None)
    if existing:
        fulkruma.products.update(existing["id"], {"name": name})
        return existing["id"]
    pres = fulkruma.products.create({"name": name, "sku": sku})
    pid = pres["product"]["id"]
    fulkruma.products.add_variant(pid, {
        "name": "Default", "priceCents": price_cents, "isDefault": True,
    })
    return pid

Errors

err.status err.code Cause
400 VALIDATION Missing name, bad type, a negative dimension or price.
403 NO_ACCOUNT The credentials resolve to no workspace.
404 NOT_FOUND Product or variant ID missing or in another workspace.

Next