Libre Biotech

API Reference

Programmatic access to platform data via the public REST API.

Base URLs

The platform provides two API entry points:

APIBase URLDescription
Public APIhttps://librebiotech.org/api/v1/Read-only access to public content (protocols, projects, courses, discussions)
REST APIhttps://librebiotech.org/api.php/v1/Full CRUD access to investigations, processes, samples, files (requires API key)

Authentication

Public API endpoints require no authentication. The REST API accepts API keys via the X-API-Key header:

X-API-Key: YOUR_API_KEY

Create and manage API keys from the user menu → API Keys. Each key can be named for tracking purposes and revoked individually.

User identity model

The platform separates accounts from contributor identity. Both are first-class; each plays a different role, and most integrations need a working model of the distinction before working with members, authorship, or sample submitters.

TableRoleColumns a client sees
users Accounts — anyone who can log in, hold an API key, or own platform actions. id (canonical FK target), email (UNIQUE, human-stable), person_id (FK → people), is_active, is_admin. There is no username column.
people Contributor registry — everyone the platform credits, whether or not they have an account. id, name (display name), email. Every users row has exactly one people row (via users.person_id). A people row may exist without a users row — contributors who never signed up.

Canonical identifiers. users.id is the stable machine identifier. email is the only other UNIQUE-constrained field on users and is the natural human-typable handle. There is no third identifier — in particular, no username, no public short-code.

Display names live on people

Wherever a display name appears in API responses (person_name, author_name, group member rows), the value comes from people.name joined via users.person_id. This matters because people.id is the contributor-FK target across the data model:

  • process_people.person_id — who worked on a process run, with a typed role (Owner, Operator, Contributor, Reviewer, Requester, Supervisor).
  • procedure_versions.person_id — who authored a specific version of a procedure.
  • samples.submitter_person_id — who generated a specific sample (see Samples).

Using people rather than users lets the platform acknowledge contributors who don’t (yet) hold accounts — citizen scientists, co-authors on a published protocol, pilot participants. The contributor layer is a superset of the account layer.

Resolving users

Three paths, each with a different scope. Pick the narrowest one that covers your use case.

MethodEndpointReturnsScope
GET /api.php/v1/me {id, email, person_id, is_admin, person_name} Self. Always available to any authenticated caller.
GET /api.php/v1/users/resolve?email=X {id, email, person_id, person_name} Limited to users who share at least one active group with the caller. Returns 404 for users outside that scope — this is deliberate, so the endpoint cannot be used as a user-enumeration oracle.
GET /api.php/v1/groups/{id}/members Array of active members with role, joined_at, and display name Members-only. Gives the complete roster for a group you belong to.

Typical scripting pattern: call /users/resolve to turn an email into an {id, person_id} pair, then pass id to POST /groups/{id}/members (to add the user to a group) and person_id to submitter_person_id when creating a sample.

POST /api.php/v1/groups/{id}/members immediately creates an active group_members row when a Leader adds a user by user_id or email. The invitee is not notified by the platform and is not asked to accept. Integration consequence: a Leader can attach identity to a group unilaterally — which is appropriate for small-group collaboration but may not fit every workflow. If your integration needs an acceptance flow (e.g. citizen-science pilots where consent is part of the protocol), implement it at the client layer: notify the invitee and gate the POST /members call on their confirmation.

Public endpoints

These endpoints are accessible without authentication and support CORS for browser-based access.

Protocols

MethodEndpointDescription
GET/api/v1/protocolsList public protocols (paginated). Query params: page, per_page, category
GET/api/v1/protocol/{id}Get a single protocol with all versions and steps

Projects

MethodEndpointDescription
GET/api/v1/projectsList public projects (paginated)
GET/api/v1/project/{id}Get a single project with metadata

Courses

MethodEndpointDescription
GET/api/v1/coursesList courses (paginated)
GET/api/v1/course/{id}Get a single course with modules and lessons

Platform

MethodEndpointDescription
GET/api/v1/statsPlatform statistics (investigations, protocols, courses, members, groups)

Cards & Skill Files

MethodEndpointDescription
GET/api.php/v1/platform-cardPlatform skill file as JSON — live statistics, data model, all endpoints, code examples. No auth required. Cached 1 hour
GET/CLAUDE.mdPlatform skill file as Markdown — same content as /api.php/v1/platform-card but in Markdown format. Designed for AI coding assistants

REST API endpoints (authenticated)

These endpoints require an API key and support full CRUD operations. Base URL: /api.php/v1/

Ontology term identifiers (federated-FAIR identity)

Several fields in the API reference ontology terms — units of measurement, assay types, parameter types, etc. A term in our database has three identifying properties, listed below in order of portability:

  1. CURIE (e.g. UO:0000027) — the standard, cross-instance identifier. Same everywhere, forever.
  2. Label (e.g. "degree Celsius") — the human-readable name. Can be ambiguous across ontologies.
  3. Integer ID (e.g. 228) — this server’s internal primary key. Meaningless on any other Libre Biotech instance.

For every ontology-term field {prefix}_term_id in a POST body (e.g. unit_term_id, assay_type_term_id), you can supply any of:

FieldExampleBehaviour
{prefix}_curie preferred "unit_term_curie": "UO:0000027" Portable. If the CURIE exists in our ontology_terms table, its id is returned; the stored label wins. If the CURIE is new, include {prefix}_label alongside to register it.
{prefix}_label "unit_term_label": "degree Celsius" Convenience. Case-insensitive exact match. Lookup only — never mints a new term (that would let instances silently diverge on "Temperature"). 400 on zero matches. 409 on multiple matches, with a list of candidate CURIEs so you can disambiguate.
{prefix}_id escape hatch "unit_term_id": 228 Backward-compat. Validated. Not portable to other Libre Biotech instances. Prefer CURIE unless you’re certain callers run only against this one instance.

Precedence when multiple are supplied: CURIE > label > id. All GET responses include both the internal id (for completeness) and a nested object with label and curie:

"unit_term_id": 228,
"unit_term": { "label": "degree celsius", "curie": "UO:0000027" }

Recommended term sources for common domains: UO (Units of Measurement), OBI (Ontology for Biomedical Investigations), CHMO (Chemical Methods Ontology), EDAM (Bioinformatics), NCBITaxon (organisms), UBERON (anatomy), CL (cell types). This isn’t enforced — Libre Biotech-local CURIEs are accepted — but using standard sources is how your protocol stays portable.

Groups

Groups are the top-level ownership container. Investigations, protocols, and processes are scoped by group. A group has Leaders, Managers, and Members. The creator of a group automatically becomes its first Leader.

MethodEndpointDescription
GET/api.php/v1/groupsList groups the authenticated user is an active member of (same data as /me/groups)
POST/api.php/v1/groupsCreate a group. Body: {name, description?}. Caller needs GROUP_CREATE permission and is auto-added as the first Leader
GET/api.php/v1/groups/{id}Group details (members-only)
PUT/api.php/v1/groups/{id}Rename / update description. Body: any subset of {name, description}. Leader-only
GET/api.php/v1/groups/{id}/membersList active members with their role and display name (members-only)
POST/api.php/v1/groups/{id}/membersAdd a member. Body: {user_id, role?} or {email, role?}. Role ∈ {Leader, Manager, Member}, default Member. Leader-only
DELETE/api.php/v1/groups/{id}/members/{user_id}Remove a member (closes their active membership row). Leader-only. Will refuse to remove the last remaining Leader (409)

Create a group + add members end-to-end

# 1. Create the group — you become its first Leader automatically
curl -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
  -X POST https://librebiotech.org/api.php/v1/groups \
  -d '{"name": "Epigenetics Working Group", "description": "Monthly methods sharing."}'
# → 201; note data.id as the new group_id

# 2. Add a collaborator by email
curl -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
  -X POST https://librebiotech.org/api.php/v1/groups/42/members \
  -d '{"email": "alice@example.org", "role": "Manager"}'

# 3. Or by user_id (useful in scripts that already have the id)
curl -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
  -X POST https://librebiotech.org/api.php/v1/groups/42/members \
  -d '{"user_id": 7}'

# 4. Now the group_id can be used as owner_group_id for procedures,
#    or as group_id when creating investigations under this group.

Common errors:

  • 400 Missing required fields with errors.missing_fields: ["name"] — provide a non-empty group name.
  • 400 role must be one of: Leader, Manager, Member
  • 403 Forbidden: You do not have permission to create groups — caller lacks GROUP_CREATE. Ask an admin.
  • 403 Forbidden: Only group Leaders may … — update, add-member, and remove-member operations require the Leader role.
  • 404 No active user found with email / id — user doesn't exist or is deactivated.
  • 409 User is already an active member of this group — use PUT on the specific member row via the UI, or remove first.
  • 409 Cannot remove the last remaining Leader — promote another member to Leader first.

Investigations

Investigations live under a group, not a project — projects and investigations are independent. The hierarchy is group → investigation → study → process → sample. To create or update an investigation you must be a Leader or Manager in the target group (admins bypass this). Discover valid group IDs with GET /api.php/v1/me/groups.

MethodEndpointDescription
GET/api.php/v1/investigationsList investigations accessible to the authenticated user (paginated)
GET/api.php/v1/investigations/{id}Get investigation details (title, description, status, visibility, dates, license)
GET/api.php/v1/investigations/{id}/exportExport as ISA-Tab (default) or ISA-JSON. Query param: format=isajson
GET/api.php/v1/investigations/{id}/validateValidate investigation for ISA compliance. Returns isValid, completeness (0-100%), errors, warnings, and badge
GET/api.php/v1/investigations/{id}/provenanceGet investigation provenance graph (all processes, samples, and their relationships)
GET/api.php/v1/investigations/{id}/prov-oExport investigation provenance as W3C PROV-O (JSON-LD). Includes prov:Activity, prov:Entity, and prov:Agent nodes
GET/api.php/v1/investigations/{id}/ml-exportML-Ready export as flat samples × features matrix. Query params: format=csv for CSV, format=zip for ZIP bundle with README (default: JSON)
GET/api.php/v1/investigations/{id}/cardInvestigation data card. Query param: format=json for JSON (default: Markdown with YAML frontmatter). Public investigations require no auth
POST/api.php/v1/investigationsCreate investigation. Required: group_id, title. Optional: description, status, visibility (private/group/public), license, doi, submission_date, public_release_date, cover_image. Caller must be Leader/Manager in group_id.
PUT/api.php/v1/investigations/{id}Update any subset of: title, description, status, visibility, license, doi, submission_date, public_release_date, cover_image
DELETE/api.php/v1/investigations/{id}Delete investigation (Leader only)

Create an investigation end-to-end

# 1. Find a group where you're Leader or Manager
curl -H "X-API-Key: $KEY" https://librebiotech.org/api.php/v1/me/groups
# → pick the id from a row where "role": "Leader" or "Manager"

# 2. POST the investigation (no project required; investigations are group-scoped)
curl -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
  -X POST https://librebiotech.org/api.php/v1/investigations \
  -d '{
    "group_id": 1,
    "title": "Bisulphite methylation dynamics in H. armigera",
    "description": "Larval vs adult methylome comparison across two host plants.",
    "status": "Active",
    "visibility": "group",
    "license": "CC-BY-4.0",
    "submission_date": "2026-05-01"
  }'
# → 201 Created with the full investigation row

# 3. Studies, processes, samples, and files all hang off the investigation.
#    Use POST /api.php/v1/investigations/{id}/studies (or POST /studies with
#    investigation_id in body), then POST /api.php/v1/processes, /samples,
#    /files (authenticated) to populate them.

Common validation errors:

  • 400 Missing required fields with errors.missing_fields: ["group_id"] or ["title"].
  • 400 visibility must be one of: private, group, public.
  • 403 Forbidden: You must be a Leader or Manager in the target group — pick a different group from /me/groups or ask a group Leader to add/promote you.

Account & catalog helpers

Read-only helpers required when authoring a procedure via the API: look up your own group memberships and valid catalog IDs.

MethodEndpointDescription
GET/api.php/v1/meAuthenticated user profile: id, email, person_id, is_admin, person_name
GET/api.php/v1/me/groupsCaller's active group memberships. Use any returned id as owner_group_id when creating/updating a procedure
GET/api.php/v1/me/samplesPaginated list of samples where submitter_person_id matches the caller's own person_id. Your contribution log — see Samples
GET/api.php/v1/users/resolve?email=XResolve an email to {id, email, person_id, person_name}. Share-group scoped — see Resolving users
GET/api.php/v1/catalog/equipment-typesAll equipment types. Use id as equipment_type_id in procedure equipment rows
GET/api.php/v1/catalog/material-typesAll material types. Use id as material_type_id in procedure material rows
GET/api.php/v1/catalog/material-productsAll registered catalog products. Optional ?type_id=N filters to products of a specific material type. Use id as material_product_id (or omit for generic-any)

Catalog write

Create new catalog entries via the REST API. Requires role-based permission: callers need the catalog_admin role (or the system_admin role, which holds every permission by convention). Read endpoints are any-authenticated; write endpoints are role-gated.

The three catalog.* permissions (each held by both catalog_admin and system_admin roles):

  • catalog.create_equipment_type
  • catalog.create_material_type
  • catalog.create_material_product
MethodEndpointDescription
POST/api.php/v1/catalog/equipment-typesCreate a new equipment type. Required body: name, category. Optional: description, ontology_term_curie (with ontology_term_label if the CURIE is new — see Ontology term identifiers). Returns 201 with the created row.
POST/api.php/v1/catalog/material-typesSame request shape as equipment types. Typical category values: Chemical, Reagent, Consumable, Enzyme, Kit, Primer, Biological material.
POST/api.php/v1/catalog/material-productsCreate a new material product (a vendor-specific instance of a material type). Required: type_id, name. Optional: vendor, catalog_number, unit_size, description, sds_url, manual_url.

Idempotent-upsert semantic on name collision. When a caller POSTs a name that already exists (UNIQUE on equipment_types.name and material_types.name; application-level (type_id, name) check on material_products), the endpoint returns 409 Conflict with the existing row in errors.existing_resource. The caller's other submitted fields (description, category, etc.) are discarded silently — the existing row wins. This enables idempotent client patterns: on 409, inspect the existing row and use its id directly. See the 409 envelope shape.

Deferred scope (v1): PUT (update) and DELETE are not supported. Catalog rows are effectively append-only at the API level. If a row description needs correcting, the current workaround is UI-side editing (catalog admin pages) or a direct SQL update.

Create an equipment type end-to-end

# Create a new pH meter equipment type
curl -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
  -X POST https://librebiotech.org/api.php/v1/catalog/equipment-types \
  -d '{
    "name": "pH meter",
    "category": "Measurement",
    "description": "Benchtop or handheld electrode-based pH measurement.",
    "ontology_term_curie": "CHMO:0001023"
  }'
# → 201 Created with {"data": {"id": 49, "name": "pH meter", ...}}

# Attempting the same name again
curl -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
  -X POST https://librebiotech.org/api.php/v1/catalog/equipment-types \
  -d '{"name": "pH meter", "category": "Measurement"}'
# → 409 Conflict with errors.existing_resource containing the id=49 row

Common errors:

  • 400 Missing required fields with errors.missing_fields: ["name"] or ["category"].
  • 400 Unknown type_id: N — on POST /catalog/material-products, the type_id doesn't reference a valid material_types row.
  • 403 Forbidden: missing required permission — caller lacks catalog.create_*. Response body includes errors.permission_required naming the missing permission. See 403 envelope shape.
  • 409 Conflict — name collides. Response body includes errors.existing_resource.

Studies

A Study groups related experimental work under an Investigation. Studies define the experimental factors (grouping variables like vendor, tissue_type, treatment group) that show up as ISA-Tab Factor Value[X] columns downstream. Two create shapes are accepted — use whichever fits your client:

MethodEndpointDescription
GET/api.php/v1/studiesList visible studies. Optional ?investigation_id=N filter.
GET/api.php/v1/studies/{id}Get study detail with nested design_type_term: {label, curie} and inlined factors[].
POST/api.php/v1/studiesFlat create. Body includes investigation_id. See payload below.
POST/api.php/v1/investigations/{id}/studiesNested create under a specific investigation. Same body as flat but investigation_id comes from the path.
PUT/api.php/v1/studies/{id}Update scalar fields and/or append to factors[]. PUT appends factors — remove via DELETE /study_factors/{id}.
DELETE/api.php/v1/studies/{id}Delete study (cascades to factors, samples-via-junction, etc.)
GET/api.php/v1/studies/{id}/samplesPaginated list of samples attached to the study. Each item is the same rich shape as GET /samples/{id} (organism + term, material_type, annotations[], study_ids). Query: page, per_page.
POST/api.php/v1/studies/{id}/samplesAttach one or more existing samples. Body: {"sample_ids": [1, 2, 3]}. Idempotent.
DELETE/api.php/v1/studies/{study_id}/samples/{sample_id}Detach a sample from a study. Sample itself is not deleted.
DELETE/api.php/v1/study_factors/{id}Remove a single factor from its study. Caller needs update permission on the study.

Create payload example

POST /api.php/v1/investigations/3/studies
X-API-Key: YOUR_KEY
Content-Type: application/json

{
  "title": "Sushi Truth pilot — retail sashimi mislabel rates",
  "summary": "Claim at purchase vs. BLAST call across 5 Tokyo vendors",
  "design_type": "observational — retail sampling with post-hoc identification",
  "design_type_curie": "OBI:0300311",
  "license": "CC-BY-4.0",
  "submission_date": "2026-04-19",
  "factors": [
    { "name": "vendor",           "type": "Vendor (nominal)" },
    { "name": "claimed_species",  "type": "Species claim (nominal)" },
    { "name": "mislabel_flag",    "type": "Outcome (boolean)" }
  ]
}

Response 201 Created with the full study shape (same as GET /studies/{id}). Same request body accepted at POST /api.php/v1/studies when investigation_id is included.

FieldTypeNotes
investigation_idint, required (flat POST)FK to the parent investigation. Required for flat POST /studies; comes from path for nested POST /investigations/{id}/studies.
titlestring, required
summarystring, optionalDefaults to empty string when omitted (the column is NOT NULL).
design_typestring, optionalFree-text design label (ISA-Tab Study Design Type).
design_type_curie / design_type_label / design_type_idoptionalFederated-FAIR term identity — see Ontology term identifiers. Typically an OBI CURIE. Stored as design_type_term_id; emitted as Study Design Type Term Accession Number in ISA-Tab export.
licensestring, optionalSPDX identifier (e.g. CC-BY-4.0).
submission_date, public_release_dateYYYY-MM-DD, optionalValidated; invalid formats return 400.
factorsarray, optionalInline — see next table. Written in the same transaction as the study.

factors[] — ISA-Tab study factors

Each factor becomes an ISA-Tab Factor Value[name] column on downstream assay tables. For a mislabeling-detection pilot, typical factors are vendor, claimed_species, mislabel_flag.

FieldTypeNotes
namestring, requiredBecomes the ISA-Tab Factor Value[name] column header.
typestring, optionalFree-text typing hint (e.g. "Species claim (nominal)", "concentration (ratio)").
type_curie / type_label / type_idoptionalFederated-FAIR term identity for the factor type. Stored as type_term_id; emitted as Study Factor Type Term Accession.

PUT semantics: factors[] on PUT /studies/{id} appends. To remove a specific factor, DELETE /study_factors/{id}. Mirrors the annotations model on samples.

Known gaps (not in this iteration): study contacts (study_contacts), linked protocols (study_protocols), and declared assay types (study_assay_types) are not yet writable via this controller. Use the web UI for now; file an issue or comment on the follow-up PR if you need them scripted.

Procedures (Protocols)

Full read/write access for protocols, their versions, steps, equipment, materials (with alternatives), and references. Ownership is set by the caller: created_by is the authenticated user, owner_group_id must be a group the caller belongs to (or null for personal). Only the creator (or an admin) may update or delete.

MethodEndpointDescription
GET/api.php/v1/proceduresList procedures visible to the caller (public + own + group-visible). Query params: page, per_page, category
GET/api.php/v1/procedures/{id}Get procedure detail with latest version, steps, equipment, materials (+alternatives), references, and parameters
POST/api.php/v1/proceduresAtomic create: procedure header + initial version with nested steps, equipment, materials, references, parameters arrays. See payload below
PUT/api.php/v1/procedures/{id}Update header metadata (any subset of title, description, category, url, visibility, license, parser_engine, owner_group_id)
DELETE/api.php/v1/procedures/{id}Delete procedure (cascades to versions and all per-version children)
GET/api.php/v1/procedures/{id}/versionsList all versions for a procedure (summary rows)
POST/api.php/v1/procedures/{id}/versionsCreate a new version with nested steps, equipment, materials, references, parameters. Previous versions remain immutable

Create payload example

POST /api.php/v1/procedures
X-API-Key: YOUR_KEY
Content-Type: application/json

{
  "title": "My DNA Extraction",
  "description": "CTAB-based extraction for plant tissue",
  "category": "sample_prep",
  "visibility": "group",
  "owner_group_id": 3,
  "license": "CC-BY-4.0",
  "parser_engine": null,
  "initial_version": {
    "version_number": "1.0",
    "effective_date": "2026-04-18",
    "change_log": "Initial release",
    "measurement_type_curie": "OBI:0000424",
    "technology_type_curie": "OBI:0000366",
    "safety_text": "Wear gloves; chloroform in fume hood.",
    "preparation_notes_text": "Pre-warm CTAB buffer to 60 °C.",
    "timing_text": "~3 hours including overnight incubation.",
    "completion_notes_text": "Expected yield: 10–50 µg. Store at -20 °C.",
    "steps": [
      { "content": "Homogenise 100 mg tissue in liquid nitrogen." },
      { "content": "Add 700 µL CTAB buffer and incubate at 60 °C for 30 min." }
    ],
    "equipment": [
      { "equipment_type_id": 3, "specifications": "60 °C", "notes": "Water bath" },
      { "equipment_type_id": 4, "notes": "Microcentrifuge" }
    ],
    "materials": [
      {
        "material_type_id": 2,
        "material_product_id": null,
        "quantity": "10",
        "unit": "mL",
        "notes": "100% ethanol for precipitation",
        "alternatives": []
      },
      {
        "material_type_id": 28,
        "material_product_id": 5,
        "notes": "Primary: D5005",
        "alternatives": [
          { "material_product_id": 17, "notes": "Larger pack size (200 rxn)" }
        ]
      }
    ],
    "references": [
      { "citation": "Doyle & Doyle 1987", "doi": "10.1007/BF02712670", "ref_type": "paper" }
    ],
    "parameters": [
      {
        "name": "annealing_temperature",
        "description": "Per-cycle annealing temperature",
        "data_type": "number",
        "required": true,
        "default_value": "58",
        "display_order": 0,
        "unit_term_curie": "UO:0000027"
      },
      { "name": "cycle_count", "data_type": "number", "default_value": "35" }
    ]
  }
}

Response is 201 Created with the full procedure detail (same shape as GET /procedures/{id}). Empty or missing arrays (steps, equipment, materials, references, parameters) are allowed — all children are optional. ref_type defaults to paper if omitted or unrecognised.

ISA Assay classification (on the procedure, not per-run)

A procedure’s measurement_type and technology_type describe which ISA Assay Table the procedure’s runs contribute to. The ISA Assay Table itself is derived at export time: Libre Biotech groups every measurement event by the (measurement_type, technology_type) of the procedure_version it ran against, and emits one ISA-Tab Assay Table file per distinct tuple. Users don’t declare Study-level Assays separately — they materialise from the science.

FieldTypeNotes
measurement_type_curie / measurement_type_label / measurement_type_idoptionalFederated-FAIR term — see Ontology term identifiers. Typically an OBI term for the measurement activity (e.g. OBI:0000424 "transcription profiling assay"). Stored as measurement_type_term_id.
technology_type_curie / technology_type_label / technology_type_idoptionalOBI term for the underlying technology (e.g. Sanger sequencing, mass spectrometry, NGS). Stored as technology_type_term_id.

When to set them: on measurement-producing procedures (sequencing, assay runs, QC measurements). Sample-prep / extraction procedures leave them null — those contribute to the ISA Study Table, not to any Assay Table.

What happens if they’re null on a measurement procedure: measurement events still land in the database, but the ISA-Tab export bucket them into an undeclared Assay Table and emits a warning in the export log. This is intentional export-layer softening — the silent-skip failure mode (data vanishes from export with no indication) is strictly worse than an “undeclared” bucket that surfaces the gap.

On platform vs type: technology_type lives on the procedure (e.g. "Sanger sequencing" — procedure-level). technology_platform (e.g. "Applied Biosystems 3730xl") lives on the Process (run-specific, varies by instrument). The exporter folds both into the Study Assay header at export time.

parameters[] — protocol parameter definitions

Runtime inputs whose per-run values are captured against each assay (e.g. annealing temperature, cycle count). Exported as ISA-Tab Parameter Value[name] columns on the Assay Table.

FieldTypeNotes
namestring, requirede.g. annealing_temperature
descriptionstring, optional
data_typeenum, default texttext | number | date | ontology_term | boolean. Invalid values rejected with 400
requiredbool, default falseWhether the capture UI marks the field as required
default_valuestring, optionalPre-fill for the capture UI
display_orderint, defaults to array indexColumn ordering in UI and ISA-Tab export
unit_term_curie / unit_term_label / unit_term_idoptionalFederated-FAIR ontology term — see Ontology term identifiers. Prefer unit_term_curie. Typically a UO term.

The GET response shape adds id (server-assigned) and a joined unit_term: {label, curie} object when unit_term_id is set. Posting the same parameters[] to POST /procedures/{id}/versions writes them onto the new version; parameters are stored per-version, so older versions keep their declared set unchanged.

Ownership and visibility rules

  • created_by is set automatically to the authenticated user; it cannot be overridden.
  • visibility is one of private, group, public. Default is private.
  • owner_group_id must be a group the caller is an active member of (or null). Admins may set any group. A 403 is returned otherwise.
  • PUT and DELETE are restricted to the creator or an admin.
  • GET respects visibility: anyone sees public; creators see their own; group members see group-visible items when they belong to the owner_group_id.

Processes

A process is an instance of running a protocol — a wet-lab run (extraction, PCR, measurement) or a computational step. Processes pin to a specific procedure_version, which freezes the moment the process is created. See the protocols schema reference for the versioning + lock model.

MethodEndpointDescription
GET/api.php/v1/processesList processes (paginated)
GET/api.php/v1/processes/{id}Get process details (title, category, date, procedure, parameters)
GET/api.php/v1/processes/{id}/samplesGet samples linked to this process — returns {input_samples: […], output_samples: […]}. Inputs are samples consumed; outputs are samples produced
GET/api.php/v1/processes/{id}/detailsFull provenance view — inputs, outputs, files, linked people, and procedure context. Heavier than /processes/{id}; use when you want the whole graph in one call
POST/api.php/v1/processesCreate process. See payload below
PUT/api.php/v1/processes/{id}Update process (partial — any subset of title, description, notes, status, category, process_date, procedure_version_id, platform). Null clears nullable fields (notes, process_date, procedure_version_id, platform). Setting procedure_version_id auto-locks the target version if it isn’t already
DELETE/api.php/v1/processes/{id}Delete process and its owned rows (samples, junctions, linked files). Returns 409 Conflict if the process is locked (status=completed) — downgrade the status before deletion

Create payload example

POST /api.php/v1/processes
X-API-Key: YOUR_KEY
Content-Type: application/json

{
  "title": "COI PCR amplification for sashimi batch #7",
  "owner_group_id": 3,
  "assay_type_term_curie": "OBI:0001177",
  "description": "35-cycle PCR on 20 samples, Folmer primers",
  "notes": "Followed procedure 55 v0.1.1. Annealing at 58 °C."
}
FieldTypeNotes
titlestring, required
owner_group_idint, requiredMust be a group the caller belongs to, or any for admins
assay_type_term_curie / assay_type_term_label / assay_type_term_idoptionalFederated-FAIR ontology term — see Ontology term identifiers. Typically an OBI assay type
descriptionstring, optional
notesstring, optionalFree-text operator notes

Additional fields (category, status, process_date, procedure_version_id, platform) can be set via PUT /processes/{id} after creation. Pinning a procedure_version_id locks that version — see procedure_versions.is_locked.

Response: 201 Created with the full process shape — id, every submitted field, and server-managed state (status, approval_status, created_at). The same shape is returned by GET /processes/{id}, so a create round-trip is self-describing.

Samples

A sample is a physical (or computational) entity that enters a Process as input or exits as output. Pilot-registration workflow: create the Sample with its claim + provenance annotations at purchase time (before any wet-lab work), then add blast_call or other post-result annotations afterwards. The created_at ordering Sample → Process → Assay is the audit trail that proves the claim preceded the result.

MethodEndpointDescription
GET/api.php/v1/samplesList samples (paginated). Optional ?submitter_person_id=X filter — see Submitter attribution
GET/api.php/v1/samples/{id}Get sample with organism (free-text + nested ontology term), material_type, status, inlined annotations[], and nested submitter: {person_id, person_name} when attributed (null when unset)
GET/api.php/v1/samples/{id}/lineageFull sample lineage graph (ancestors + descendants with process categories)
GET/api.php/v1/samples/{id}/ancestorsAncestor chain (walking up the lineage)
GET/api.php/v1/samples/{id}/descendantsDescendants (walking down the lineage)
GET/api.php/v1/samples/{id}/prov-oExport sample provenance as W3C PROV-O (JSON-LD)
POST/api.php/v1/samplesCreate sample. Accepts the federated-FAIR organism contract, inline annotations[], and optional submitter_person_id / submitter_email. See payload below
PUT/api.php/v1/samples/{id}Update sample fields (label, description, organism, organism_{curie|label|id}, material_type, submitter_*) and/or append to annotations[] (PUT appends, never replaces — use DELETE /annotations/{id} to remove). Mutating submitter_* requires Leader/Manager in the sample’s owning group, or Admin
DELETE/api.php/v1/samples/{id}Delete sample
DELETE/api.php/v1/annotations/{id}Remove a single annotation from its sample. Caller needs write permission on the sample

Create payload example

POST /api.php/v1/samples
X-API-Key: YOUR_KEY
Content-Type: application/json

{
  "process_id": 275,
  "label": "SashimiPilot-001",
  "description": "Counter-registered at purchase",
  "material_type": "sample",
  "organism": "Thunnus thynnus (claimed)",
  "organism_curie": "NCBITaxon:8245",
  "submitter_email": "alice@example.com",
  "source_sample_ids": [],
  "annotations": [
    { "slot": "collected_at", "value_text": "2026-04-15T10:30:00+09:00" },
    { "slot": "vendor",       "value_text": "Tsukiji Outer Market stall #12" },
    { "slot": "purchase_price","value_num": 1200, "unit_curie": "UO:0000322" },
    { "slot": "photo_ref",    "value_text": "https://github.com/your-org/sashimi-pilot/blob/main/IMG_4312.jpg" }
  ]
}

Response: 201 Created with the full sample shape (id, label, organism, organism_term, material_type, annotations[], created_at). Same shape is returned by GET /samples/{id}, so a round-trip is self-describing.

FieldTypeNotes
process_idint, requiredFK to the Process this sample is an output of (or an input to, for downstream wet-lab workflows). Nullable post-Sprint-7k via web UI; API still requires one.
labelstring, optional
descriptionstring, optional
material_typeenum, optionalsource_material | sample | extract | labeled_extract | bed | plant | seed_lot | soil | compost | water | tree | structure | tool | animal. Default sample when omitted.
organismstring, optionalFree-text organism name (ISA-Tab Source Name). Useful for vendor-label capture even when the CURIE isn’t known yet.
organism_curie / organism_label / organism_idoptionalFederated-FAIR term identity — see Ontology term identifiers. Typically an NCBITaxon CURIE. Stored as organism_term_id; emitted as Source Term Accession in ISA-Tab export.
source_sample_idsint[], optionalParent samples (lineage). Each id becomes a source_samples(from_sample_id, to_sample_id) row.
study_idsint[], optionalAttach the sample to one or more studies in the same transaction. Writes study_samples(study_id, sample_id) rows. Idempotent (already-attached pairs are no-ops). Caller needs STUDY_UPDATE permission on each study. On PUT, semantics are append — detach via DELETE /studies/{study_id}/samples/{sample_id}.
submitter_person_id / submitter_emailoptionalAttribution — who generated or submitted this sample. See Submitter attribution below for resolution rules and precedence. Either field, both, or neither may be supplied; both supplied and disagreeing is a 400.
annotationsarray, optionalInline metadata — see next section. Written in the same transaction as the sample.

Submitter attribution

A sample’s submitter is the person who generated or submitted it — distinct from the operator (process_people, who ran the Process the sample came from). Use submitter attribution when a sample was contributed by someone other than the person running the bench step: citizen-science samples shipped to a central lab, external collaborators, or audience-pilot workflows.

The FK references people.id, not users.id — see User identity model. Submitters do not need a platform account.

Input forms on POST / PUT
FieldTypeBehaviour
submitter_person_id int, optional Direct reference to people.id. Must exist. No share-group check — person_ids are not guessable and the FK cannot leak visibility (the attributed person still cannot read the sample unless they join its owning group). Use this path when you already have the person_id, e.g. from a prior /v1/users/resolve lookup.
submitter_email string, optional Server resolves the email to a users row and then to its person_id. Requires the caller and the resolved user to share at least one active group (same criterion as /v1/users/resolve). Rejections are unified as 400 "submitter_email not resolvable" to prevent enumeration. Use this when your script knows an email but not a person_id.
both supplied Email is resolved; must equal the supplied submitter_person_id or the request is rejected with 400 "submitter_person_id and submitter_email disagree".
neither supplied Submitter stored as NULL. No default-to-caller behaviour — attribution must be explicit.
Output shape

Single-sample GET /v1/samples/{id} responses include a nested submitter object:

"submitter": { "person_id": 7, "person_name": "Alice Chen" }
// or, when unset:
"submitter": null

The object is present (with a null value) when the sample has no submitter, so clients can distinguish “not set” from “field missing in this API version.” Privacy: the submitter object never includes email or user_id — those are reachable only through /v1/users/resolve, which is share-group-scoped.

CSV import

The web-UI CSV import (?action=sample_import_new) accepts the same submitter_person_id and submitter_email columns on sample rows. Resolution follows the same rules as the API. Re-import semantics: submitter is set on first create and preserved thereafter — re-importing a row with a different submitter will not overwrite an existing attribution (use the API PUT to correct). Invalid resolutions surface as per-row warnings in the import report; the import continues with the sample created but the submitter unset.

Authorization (PUT)

Mutating submitter_* on an existing sample requires the caller to be a Leader or Manager in the sample’s owning group (derived via samples.process_id → processes.owner_group_id), or a platform Admin. The sample creator alone is not sufficient — this prevents silent re-attribution by anyone who can create samples.

Other PUT-allowed fields (label, description, organism, etc.) retain the standard SAMPLE_UPDATE permission check. Only submitter_* fields trigger the stricter gate.

Listing your own submitted samples

GET /api.php/v1/me/samples returns the paginated list of samples where submitter_person_id matches the caller’s own person_id. The list surface is not ACL-filtered — your contribution log is yours to see even after you leave a group — but individual GET /v1/samples/{id} calls on the returned IDs still enforce read permission.

The generic list endpoint accepts GET /api.php/v1/samples?submitter_person_id=X for filtering by any submitter.

Cascade on contributor deletion

If a people row is deleted, samples.submitter_person_id is set to NULL; the sample itself is preserved. Losing attribution is acceptable; losing the sample (with its scientific content — claim, BLAST call, lineage) is not.

File attachments on samples

Samples support direct file attachments (photos, receipts, documents) through the web UI sample detail page. Attached files go via the polymorphic file_attachments table (entity_type='sample', entity_id, file_id, role). The file itself lives in files and can be referenced by multiple entities without duplication.

  • Upload: POST /?action=sample_file_upload (multipart/form-data; sample_id + files[] + optional role). Caller needs SAMPLE_UPDATE on the target sample. Files are stored under storage/sample/{id}/<sha256>.<ext>.
  • Detach: POST /?action=sample_file_detach (sample_id + file_id). Removes only the junction row — the underlying file is preserved since other entities may reference it.
  • Roles: free-form VARCHAR(100); conventional values from the UI dropdown are attachment (default), photo, receipt, document. REST callers and imports can use any string.
  • Display: attachments render as a thumbnail grid on the sample detail page. Image MIME types show inline; other types show a file-icon card with filename and size.

API-level upload is not yet wired. POST /api.php/v1/files returns 501 pending a follow-up PR. For scripted attachment flows today, upload via the web UI or a session-authenticated curl against ?action=sample_file_upload.

annotations[] — polymorphic per-sample metadata

Annotations are the general-purpose key/value-with-ontology-typing slot for anything that isn’t a first-class sample column. Vendor, collection timestamp, GPS coords, BLAST call, mislabel flag, photo references — all go through the same shape.

FieldTypeNotes
slotstring, requirede.g. collected_at, vendor, blast_call, mislabel. Free-form VARCHAR(64) — establish stable slot names within your project.
value_textstring, optionalFree-text value (e.g. ISO-8601 timestamp, vendor name, URL).
value_numnumber, optionalNumeric value (e.g. purchase price, concentration).
term_curie / term_label / term_idoptionalFederated-FAIR ontology term value (e.g. term_curie: "NCBITaxon:12345" for a blast_call). Complementary to or instead of value_text.
unit_curie / unit_label / unit_idoptionalUnit for numeric values. Typically a UO CURIE (e.g. UO:0000322 for JPY).
positionint, optionalDisplay ordering. Defaults to the array index.

PUT semantics: annotations[] on PUT /samples/{id} appends. The PUT never deletes or updates in-place — removing an annotation uses DELETE /annotations/{id}. This keeps the PUT body’s meaning literal ("add this metadata") with no hidden destructive side-effects on the caller’s unlisted annotations.

Why labels can’t mint new terms: same federated-FAIR rule applies to annotation terms as to organism — term_label without a term_curie is lookup-only; you can’t register new ontology terms via the label path. See Ontology term identifiers.

Files

MethodEndpointDescription
GET/api.php/v1/filesList files (paginated)
GET/api.php/v1/files/{id}Get file metadata (name, size, MIME type, checksum)
POST/api.php/v1/filesUpload file (multipart/form-data)
DELETE/api.php/v1/files/{id}Delete file

Response format

All REST API endpoints return JSON with a consistent envelope:

// Success
{
  "success": true,
  "message": "Success",
  "data": { ... }
}

// Error
{
  "success": false,
  "error": "Error message"
}

// Paginated
{
  "success": true,
  "message": "Success",
  "data": {
    "investigations": [ ... ],
    "pagination": {
      "total": 45,
      "page": 1,
      "per_page": 25,
      "total_pages": 2,
      "offset": 0,
      "has_more": true
    }
  }
}

ML-Ready export response

The /ml-export endpoint returns a different structure optimised for ML consumption. See the Data Export page or the For AI Researchers page for the full schema and code examples.

Error handling

StatusMeaning
200Success
201Created (returned after POST)
400Bad request (missing or invalid parameters). Response includes errors.missing_fields array when applicable, or errors.immutable_fields_ignored when the caller submitted only immutable fields to a PUT/PATCH endpoint
401Unauthorised (missing or invalid API key)
403Forbidden (valid key but insufficient permissions for this resource). Role-based-permission failures include the structured envelope below
404Resource not found
409Conflict (typically a UNIQUE name collision on create). Role-gated create endpoints return the existing row in errors.existing_resource — see envelope below
429Rate limited (too many requests). Response includes Retry-After: 60 header
500Server error

403 Forbidden envelope (role-based permissions)

When an endpoint is gated by a role-based permission (e.g. the catalog write endpoints), a caller who lacks the required permission receives a structured envelope. Clients can depend on errors.permission_required programmatically (e.g. to prompt a user to request the role) and on errors.permission_description as a human-readable reason.

{
  "success": false,
  "error": "Forbidden: missing required permission",
  "errors": {
    "permission_required": "catalog.create_equipment_type",
    "permission_description": "Create new equipment types in the catalog"
  }
}

Older permission failures (e.g. “You must be a Leader or Manager in the target group” for POST /investigations) use a plain prose 403 without the structured envelope. New role-gated endpoints use the structured shape going forward.

409 Conflict envelope (duplicate on create)

Endpoints that would create a duplicate row (e.g. a catalog entry with a name that already exists) return the existing row in the response envelope. This lets clients detect “someone else already created this” without a second GET round-trip, and supports idempotent-upsert patterns.

Important: the existing row wins. If the caller’s body has a colliding name but other fields (description, category, etc.) that differ from the existing row, those other fields are discarded silently — the endpoint does not merge or update on 409. Inspect errors.existing_resource and either accept its values or plan a follow-up update (v1 of the catalog-write API does not support PUT on catalog rows).

{
  "success": false,
  "error": "Equipment type with name 'pH meter' already exists",
  "errors": {
    "conflict_field": "name",
    "existing_resource": {
      "id": 49,
      "name": "pH meter",
      "category": "Measurement",
      "description": "...",
      "ontology_term_id": null,
      "created_at": "2026-04-21 04:01:29"
    }
  }
}

400 Immutable fields envelope (PUT / PATCH)

When a caller submits a PUT or PATCH body whose fields are all outside the endpoint’s mutable whitelist (e.g. trying to update group_id on an investigation, which is immutable post-create), the endpoint returns 400 naming the filtered fields. Clients can depend on errors.immutable_fields_ignored[] programmatically.

{
  "success": false,
  "error": "No mutable fields in request body; these fields are not mutable on investigation and were ignored: group_id",
  "errors": {
    "immutable_fields_ignored": ["group_id"]
  }
}

Per-entity mutability whitelists are published under each entity’s section above. For Investigation.group_id specifically, ownership transfer between groups requires DELETE + recreate in the target group — it is deliberately not a PUT-updatable field.

CORS

Public API endpoints include CORS headers allowing browser-based access from any origin. This enables JavaScript applications to integrate with the Libre Biotech API directly.

Rate limiting

Two surfaces, two limits:

  • REST API (/api.php/v1/*, X-API-Key auth): 1000 requests per hour per API key. Enforced by middleware; rolling hourly window. Responses include X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset headers.
  • Public API (/api/v1/*, no auth or session auth): 60 requests per minute per IP when anonymous, 300 per minute when logged in.

When rate limited, the API returns a 429 status with a Retry-After header indicating seconds until the window resets.

Examples

Create a protocol end-to-end

Walking through the full flow a new consumer needs to compose: discover IDs, then POST the protocol, then fetch it back.

# 1. Confirm your identity and find your groups
curl -H "X-API-Key: $KEY" https://librebiotech.org/api.php/v1/me/groups
# → pick an id for owner_group_id (or leave null for a personal protocol)

# 2. Look up equipment + material type IDs from the catalog
curl -H "X-API-Key: $KEY" https://librebiotech.org/api.php/v1/catalog/equipment-types
curl -H "X-API-Key: $KEY" https://librebiotech.org/api.php/v1/catalog/material-types

# Optional: find specific products registered under a material type
curl -H "X-API-Key: $KEY" \
  "https://librebiotech.org/api.php/v1/catalog/material-products?type_id=28"

# 3. POST the protocol (atomic: header + version + all nested children)
curl -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
  -X POST https://librebiotech.org/api.php/v1/procedures \
  -d '{
    "title": "EZ Methylation-Gold Bisulfite Conversion",
    "description": "Convert 100–500 ng gDNA using the Zymo EZ kit (D5005).",
    "category": "sample_prep",
    "visibility": "group",
    "owner_group_id": 1,
    "license": "CC-BY-4.0",
    "initial_version": {
      "version_number": "1.0",
      "effective_date": "2026-04-18",
      "change_log": "Initial release",
      "safety_text": "CT Conversion Reagent is corrosive — PPE required.",
      "timing_text": "~3.5 hours",
      "steps": [
        { "content": "Prepare CT Conversion Reagent per kit instructions." },
        { "content": "Combine 20 µL DNA + 130 µL CT Reagent in a PCR tube." },
        { "content": "Run conversion program: 98°C / 10 min → 64°C / 2.5 h → 4°C hold." }
      ],
      "equipment": [
        { "equipment_type_id": 1, "notes": "Thermal cycler" }
      ],
      "materials": [
        { "material_type_id": 28, "material_product_id": 5,
          "quantity": "1", "unit": "kit", "notes": "D5005 (50 rxn)",
          "alternatives": [{ "material_product_id": 17, "notes": "D5006 (200 rxn)" }]
        }
      ],
      "references": [
        { "citation": "Zymo Research EZ DNA Methylation-Gold Kit manual",
          "url": "https://files.zymoresearch.com/protocols/_d5005_d5006_ez_dna_methylation-gold_kit.pdf",
          "ref_type": "manual" }
      ],
      "parameters": [
        { "name": "conversion_temp", "data_type": "number", "default_value": "64", "unit_term_curie": "UO:0000027" },
        { "name": "conversion_minutes", "data_type": "number", "default_value": "150", "unit_term_curie": "UO:0000031" }
      ]
    }
  }'
# → 201 Created; response body is the full GET /procedures/{id} shape

# 4. (later) fetch it back
curl -H "X-API-Key: $KEY" \
  https://librebiotech.org/api.php/v1/procedures/123

Validation errors you might see:

  • 400 owner_group_id is required when visibility is "group" — set one from /me/groups or use visibility: "private".
  • 400 owner_group_id must be a group you are a member of — you passed an id you don't belong to.
  • 400 Unknown equipment_type_id: N / Unknown material_type_id: N / Unknown material_product_id: N / Unknown unit_term_id: N — id doesn't exist in the catalog or ontology. Fetch the catalog endpoints to see valid values.
  • 400 Missing required fields with errors.missing_fields: ["title"] or ["version_number"].
  • 400 parameter row missing name / 400 invalid data_type 'X' (expected one of: text, number, date, ontology_term, boolean) — malformed entry in parameters[].
  • 400 Unknown ontology term: UO:XXXXX. To register it, include 'unit_term_label' — new CURIEs need a label. See Ontology term identifiers.
  • 400 Unknown ontology term label: 'X' / 400 Ambiguous label 'X' — matches N terms: [...]. Disambiguate by passing 'unit_term_curie' instead. — label-only resolution failed.

Fetch public protocols

curl https://librebiotech.org/api/v1/protocols?category=sequencing&per_page=5
# Python
import requests

response = requests.get(
    "https://librebiotech.org/api/v1/protocols",
    params={"category": "sequencing", "per_page": 5}
)
protocols = response.json()["data"]
for p in protocols:
    print(f"{p['title']} (v{p['version']})")

Fetch investigation details (authenticated)

curl -H "X-API-Key: YOUR_KEY" \
  https://librebiotech.org/api.php/v1/investigations/3

Export investigation as ISA-JSON

curl -H "X-API-Key: YOUR_KEY" -o investigation_3.json \
  "https://librebiotech.org/api.php/v1/investigations/3/export?format=isajson"

Get ML-ready data

# JSON
curl -H "X-API-Key: YOUR_KEY" \
  https://librebiotech.org/api.php/v1/investigations/3/ml-export

# CSV
curl -H "X-API-Key: YOUR_KEY" -o data.csv \
  "https://librebiotech.org/api.php/v1/investigations/3/ml-export?format=csv"

# ZIP bundle (data + README)
curl -H "X-API-Key: YOUR_KEY" -o ml_export.zip \
  "https://librebiotech.org/api.php/v1/investigations/3/ml-export?format=zip"

Get investigation data card

# Markdown (default)
curl https://librebiotech.org/api.php/v1/investigations/3/card

# JSON
curl "https://librebiotech.org/api.php/v1/investigations/3/card?format=json"

# Platform skill file
curl https://librebiotech.org/CLAUDE.md

Get sample lineage

curl -H "X-API-Key: YOUR_KEY" \
  https://librebiotech.org/api.php/v1/samples/42/lineage

Export provenance as PROV-O

# Investigation-level
curl -H "X-API-Key: YOUR_KEY" \
  https://librebiotech.org/api.php/v1/investigations/3/prov-o

# Sample-level
curl -H "X-API-Key: YOUR_KEY" \
  https://librebiotech.org/api.php/v1/samples/42/prov-o

Python: fetch samples with annotations

import requests

API = "https://librebiotech.org/api.php/v1"
headers = {"X-API-Key": "YOUR_KEY"}

# List samples
resp = requests.get(f"{API}/samples", headers=headers, params={"per_page": 50})
samples = resp.json()["data"]["samples"]

# Get details + annotations for each
for s in samples[:5]:
    detail = requests.get(f"{API}/samples/{s['id']}", headers=headers).json()["data"]
    print(f"{detail['label']}: {detail.get('organism', 'N/A')}")

R: load ML-ready data into a tibble

library(httr)
library(jsonlite)
library(tibble)

api <- "https://librebiotech.org/api.php/v1"
key <- "YOUR_KEY"

resp <- GET(paste0(api, "/investigations/3/ml-export"),
            add_headers("X-API-Key" = key))
data <- fromJSON(content(resp, "text", encoding = "UTF-8"))

df <- as_tibble(data$rows)
colnames(df) <- sapply(data$columns, function(c) c$name)
print(df)