Skip to main content
An item is the primitive. Every record in Marfa is an item, regardless of whether it represents a note, a book, a person, a photo, or a custom type registered by an app. Every item, regardless of type, has four layers.

System fields

Set by the server (or SDK in modes without a server), immutable from client request bodies. The Item schema in the API reference is the source of truth for field names, types, and nullability. A few fields carry semantics that are worth knowing before reading the spec:
  • id — UUIDv7. Time-sortable. Generated server-side.
  • timestamp vs created_at — timestamp is the user-meaningful time (when a photo was taken, when an event occurred). created_at is when the item arrived in Marfa. timestamp defaults to created_at.
  • source — stamped from the credential server-side in server-synced mode and not forgeable. See Provenance.
  • source_id — optional. The upstream system’s stable identifier for this item — an RSS entry id, a Calendar event id, an external row id. Combined with the credential-stamped source, it forms the natural key behind the POST /items upsert behavior described below. The sync agent stores each file’s root-relative path here — see Folder structure for how that shapes folder queries and cross-device behavior.
  • version — starts at 1, increments on every update. Used for optimistic concurrency; PATCH /items/{id} takes it for conflict detection.
  • schema_version — which version of the type schema the item was validated against. See Types.
  • device, capture_latitude, capture_longitude — optional, client-supplied, preserved as written, not validated.
Each of these system fields is reserved across every type — a custom-type schema cannot redeclare them as properties. Registration is rejected 400 property_shadows_field. See Reserved field names for the full list and the rationale.

Properties

Type-schema-validated JSON. The item’s content — what makes one type different from another.
  • Schema-defined fields are enforced on write. Missing required fields produce a validation error.
  • Unknown fields are preserved — forward compatibility for schemas that evolve. Servers and SDKs must not strip unknown properties.
  • Properties are type-specific. A core.note has body; a core.media.book has title, author, isbn; their shapes don’t overlap.
See Types for the full core type set and how custom types work.

Metadata — the structural universal layer

Every item carries the metadata layer regardless of type. Three fields:
The metadata layer is closed — types can’t add to it. The three fields hold concepts with system-wide consequences: labels, the curation axis, and lifecycle. Additional always-present fields belong in properties on the type. The metadata layer applies only to items — edges and attachments don’t carry it.

Extensions

Namespaced JSON objects attached to an item. Extensions are sidecar state — not structured content with its own identity.
Extensions live at dedicated endpoints (/items/{id}/extensions/{namespace}) and respect per-namespace permissions. See Extensions for when to use them vs a custom item type.

Attachments

Every item has an attachments array. Each entry:
  • blob_ref — content-addressed reference (SHA-256). Storage deduplicates naturally; identical bytes have identical hashes.
  • mime_type — MIME type.
  • title — optional human-readable label.
  • role — optional. Well-known vocabulary: banner, screenshot, pdf, archive, readable, video, transcript, thumbnail, cover, original-html. Custom roles allowed in an app namespace.
Attachments vs primary blob. A core.file.* item carries its primary binary in a type-specific property (blob_ref is required on core.file). attachments[] is for secondary artifacts — a core.file.video might have attachments[] entries for a transcript and thumbnail; the video file itself sits in properties.blob_ref.

When to use attachments[] vs the attached-to edge

Both attach secondary content to an item, but they have different semantics. The choice turns on whether the secondary content has its own identity. If in doubt: secondary content that only makes sense in the host’s context → attachments[]. Independent item that happens to belong to a host → attached-to edge.

Edges

Relationships to other items live as edges, not as fields on the item itself. about references, parent-child hierarchies, thread membership, annotations — all edges. See Edges. Single-item reads hydrate edges inline:
List reads opt into edges via ?include=edges.

Lists are lean, opt into extras

GET /items returns the minimum item shape by default — no edges, no metadata, no extensions. That’s deliberate: a naive “fetch the extras per item” loop is an N+1 trap. The ?include= parameter is the escape hatch: name what you need and the server batches it inline. Currently supported values (comma-separated):
  • ?include=edges — hydrates outbound edges per item, grouped by edge type. Same shape as single-item responses.
  • ?include=metadata — wraps each entry as { item, metadata } so tags travel inline.
  • ?include=extensions — hydrates extension namespaces per item, filtered by caller permissions (same rule as GET /items/:id/extensions).
Combinations work. ?include=edges,extensions is the typical “rendering a rich list view” shape: one request, everything the UI needs, no per-item follow-ups. The TypeScript SDK exposes typed helpers — client.items.listWithMetadata, client.items.listWithExtensions — that call these under the hood. Prefer them over assembling raw query strings.

Atomic item + edges write

A single POST /items call creates an item and its outbound edges in one transaction. See Edges for the request shape.

Natural-key upsert

POST /items is idempotent on (source, source_id). When both are present and resolve a non-trashed row in the caller’s space, the request short-circuits to update — properties shallow-merge, tags replace if provided, edges replace per-type if provided. Fields that only matter at create time (id, state, capture_*) are ignored on the update branch. The realised effect is reflected in the HTTP status:
  • 201 created — a new row was created. No matching (source, source_id) existed.
  • 200 ok — an existing row was updated via natural-key match. The response body is the updated item.
Without source_id, behavior is unchanged — every call creates a new row. With a source_id that resolves no row, the create path runs as before. The contract lets inbound integration handlers recover from whole-batch retries (handler succeeded, cursor write failed, the queue redelivers the same items) without producing duplicates. Same shape as the resource-level idempotency in mature integration platforms.

Item response shape

The metadata layer fields (tags, tier, state) are flattened onto the item object. extensions are fetched separately via the extensions endpoints — or, on list reads, hydrated inline with ?include=extensions.