Skip to content

Tables & Rows

Workpeg tables are the platform's structured-data store: a table defines typed columns, and holds rows whose values are validated against those columns on every write. Tables are scoped to a business, carry Unix-style permissions, support namespaces for grouping, and can run safe hooks — named, sandboxed functions — before and after each row save.

Peg UI apps reach the Tables API two ways:

Server-side Functions receive table context when invoked as custom hooks.


The table object

{
  "uuid": "e89b99a6-5095-4e1d-87ae-5890532045e1",
  "name": "Projects",
  "owner": { "id": 1, "...": "..." },
  "permission": "740",
  "namespace": "",
  "peg": null,
  "columns": [
    {
      "id": "3aba83a6-0718-4978-860b-1e073fd33c84",
      "name": "Title",
      "type": "text",
      "position": 1000,
      "required": true
    }
  ],
  "metadata": {},
  "created_at": "2026-01-05T10:27:56.578731Z"
}
Field Type Description
uuid string (UUID) The table's identifier — used in every endpoint path. Read-only.
name string Display name (≤ 255 chars).
owner object The staff member who created the table. Read-only; set from your session on create.
permission string Three Unix-style digits — see Permissions.
namespace string Free-form scope label; filter with ?namespace=.
peg number | null The Peg the table belongs to (see Peg scoping).
columns array Column definitions — see Columns. An inheriting table renders the abstract's columns here.
inherits_from string | null The alias of the abstract table this table inherits its columns from. Read-only.
metadata object Table metadata — only render_types and extra keys are accepted (see Table metadata).
created_at string ISO 8601 timestamp. Read-only.

Two more fields are write-only — you send them, they never come back:

Field Type Description
alias string A stable name for this table — see Table aliases.
abstract_from string The alias of the table to inherit columns from — see Abstract tables.

Create table payload

{
  "name": "Projects",
  "columns": [
    { "name": "Title",  "type": "text",   "position": 1000, "required": true },
    { "name": "Status", "type": "mutable_select", "position": 2000,
      "options": { "choices": [
        { "ref": "7w8SDzEV6vcfHUj", "tag": "uncategorized" }
      ] } }
  ],
  "metadata": {}
}

The owner is always the authenticated caller — you don't send it. Column ids are generated server-side (UUIDs) when omitted; you may supply your own stable string ids instead.


Columns

Column structure

{
  "id": "3aba83a6-0718-4978-860b-1e073fd33c84",
  "name": "Amount",
  "type": "number",
  "position": 3000,
  "required": false,
  "description": "Order total",
  "options": { "min": 0, "decimal_places": 2 },
  "metadata": {}
}
Field Type Description
id string Unique per table. Generated (UUID) when omitted; custom string ids are allowed and make row data keys readable.
name string Column label, ≤ 255 chars, unique per table.
type string One of the column types.
position number Ordering index. Not enforced unique — keep it unique by convention, spaced (1000, 2000, …) so you can insert between (e.g. 1500).
required boolean When true, every row must include this column's key in data.
description string Optional, ≤ 1000 chars.
options object Type-specific options (choices, min/max, …).
metadata object Type-specific metadata (datetime kind, many-to-many datatype, …).

Column types

Type Row value Notes
text string Plain text.
number number Options: min, max, decimal_places. Values outside min/max are rejected.
boolean boolean
datetime ISO 8601 string e.g. "2026-01-13T00:00:00". Metadata type refines the kind: datetime, date, time, or deadline.
currency string Uppercase ISO 4217 code — "USD", "NGN".
country string Uppercase ISO 3166-1 alpha-2 code — "NG", "US".
teammate list Staff references.
uuid string A canonical UUID.
file string (UUID) A Workpeg file id.
mutable_select string One of the option refs — see below.
many_to_many list Each item validated as metadata.datatype (any other column type). Requires metadata: {"datatype": "..."}.
recurrence string An RFC 5545 recurrence rule ("RRULE:FREQ=WEEKLY;BYDAY=MO"); invalid rules are rejected.
select string Deprecated — use mutable_select. Value must be one of options.choices (plain strings).

mutable_select

Choices are {ref, tag} pairs; the row stores the ref, so a tag can be renamed without rewriting rows:

{
  "name": "Status",
  "type": "mutable_select",
  "position": 2000,
  "options": {
    "choices": [
      { "ref": "7w8SDzEV6vcfHUj", "tag": "uncategorized" },
      { "ref": "9kQ2mPa81xWd0Yz", "tag": "done" }
    ]
  }
}

Both ref and tag must be unique within a column; the choices list can't be empty. A row value that isn't one of the refs is rejected.


Table metadata

Only two top-level keys are accepted (anything else is a 400):

  • render_types — a list of view hints for clients. Each entry needs a type of "table", "board", or "document". A board entry also needs representation_column and grouping_column (a mutable_select column; name it or use its id — it's stored as the id) and a boolean is_default.
  • extra — free-form object; yours to use. Some built-in hooks read well-known keys out of it.
{
  "metadata": {
    "render_types": [
      {
        "type": "board",
        "representation_column": "Title",
        "grouping_column": "Status",
        "is_default": true
      }
    ],
    "extra": { "color": "#7c5cff" }
  }
}

Rows

A row's values live in data, keyed by column id:

{
  "id": 9080,
  "permission": 740,
  "data": {
    "3aba83a6-0718-4978-860b-1e073fd33c84": "text row",
    "5d21a537-b7ec-42d2-bfcf-61015552caf3": "USD",
    "b3d1e074-9306-4037-a12a-8e1ac87a8b09": "2026-01-13T00:00:00"
  },
  "position": 1000,
  "metadata": {},
  "namespace": "",
  "inherit_business_permission": true,
  "inherit_individual_permissions": true,
  "fingerprint": "0d9c2c4e-...",
  "created_at": "2026-01-09T12:16:37.763231Z"
}
Field Type Description
id number Row id. Read-only.
data object Keys are column ids; values conform to each column's type. Missing keys read as null. Keys must cover every required column.
position number (float) Ordering index. Lists come back descending by position (highest first). Not enforced unique — space values out and insert between.
metadata object Free-form row metadata.
namespace string Row-level scope label, independent of the table's namespace.
inherit_business_permission boolean Follow the table's permissions (row permission updates when the table's does).
inherit_individual_permissions boolean Copy the table's per-staff grants onto the row at creation.
permission number Effective permission. Read-only on rows — set the inherit flags or per-row grants instead.
fingerprint string Opaque change marker; it changes on every save. Read-only — compare it to know whether a row changed (cache/sync).
created_at string ISO 8601.

Row creation payload

{
  "data": {
    "COLUMN_ID": "value",
    "OTHER_COLUMN_ID": ["multi 1", "multi 2"]
  },
  "position": 3000,
  "metadata": {},
  "namespace": "",
  "inherit_business_permission": true,
  "inherit_individual_permissions": true
}

Validation errors come back as 400 with a per-field message — a wrong type, a value not in the choices, a missing required column, and so on.

PATCH updates only the fields present in the payload. Note that data is replaced as a whole field — send the full data object, not just the changed keys.


Permissions

Think of each business account as a computer of its own — everything a business owns lives on its machine, and permissions are the familiar Unix-style three digits, read against that machine:

Digit Who it applies to
owner The staff member who owns the table
business Every bona fide staff member of the business — guests excluded
public Everyone on the business's computer, guests included

Each digit is a bitmask:

Bit Meaning
4 Read
2 Write
1 Share

So "740" = owner read+write+share, staff read, nothing for guests. A staff member holds the union of the digits that apply to them — a bona fide member gets business | public, a guest gets public only. And because the public digit means "everyone on this computer", nothing is ever visible from another business, whatever the digits say. The value must be between 100 and 777, and only the table owner can change it (setting it at creation is fine — the creator is the owner).

On top of the three digits, individual permissions grant a specific staff member a permission on one table or one row (see endpoints below) — the per-person exception, e.g. letting one guest into a staff-only table.

Rows have the same digits plus the two inherit_* flags; when inherit_business_permission is set, the row's permission follows the table's whenever the table is updated. Files carry the same three digits with the same semantics.


Namespaces & filtering

Three independent axes, all on the tables list endpoint:

Filter Where Effect
?namespace=<ns> query Tables whose own namespace matches.
?row_namespace=<ns> query Tables that contain rows with that row namespace.
?peg=<name> / Peg-Namespace header query / header Tables owned by that Peg — see below.

Row namespace is a label on each row (e.g. "inbox", "archive"); the table's namespace groups tables (e.g. one per feature).

Peg scoping

Every table can belong to a Peg. The Peg-Namespace request header — which the workpeg.* client sends automatically with the calling Peg's namespace — takes precedence over the ?peg= query arg: when the header is present, the query arg is ignored. The special value @ selects tables that belong to no Peg.

In practice: a Peg UI app calling workpeg.get_tables() sees its own tables by default, without passing anything.


REST endpoints

Base: https://api.workpeg.com. All endpoints require an authenticated session and business-staff membership; {business_uuid} is the active business, {table_uuid} a table's uuid, {row_id} a row's integer id.

Tables

Method Path Action
GET /v1/businesses/{business_uuid}/tables/ List tables you can read (supports the filters).
POST /v1/businesses/{business_uuid}/tables/ Create a table.
GET /v1/businesses/{business_uuid}/tables/{table_uuid}/ Retrieve one table.
PATCH /v1/businesses/{business_uuid}/tables/{table_uuid}/ Partial update (write permission; permission changes are owner-only).
DELETE /v1/businesses/{business_uuid}/tables/{table_uuid}/ Delete the table and its rows.

Rows

Method Path Action
GET /v1/businesses/{business_uuid}/tables/{table_uuid}/rows/ List rows you can read, descending by position.
POST /v1/businesses/{business_uuid}/tables/{table_uuid}/rows/ Create a row (table write permission).
POST /v1/businesses/{business_uuid}/tables/{table_uuid}/bulk-create/ Create many rows — the body is a list of row payloads.
GET /v1/businesses/{business_uuid}/tables/{table_uuid}/rows/{row_id}/ Retrieve one row.
PATCH /v1/businesses/{business_uuid}/tables/{table_uuid}/rows/{row_id}/ Partial update.
DELETE /v1/businesses/{business_uuid}/tables/{table_uuid}/rows/{row_id}/ Delete the row.

Individual permissions

Method Path Action
GET/POST /v1/businesses/{business_uuid}/tables/{table_uuid}/individual-permissions/ List / grant per-staff table permissions ({"staff": <id>, "permission": <bitmask>}). Writes are owner-only.
GET/POST /v1/businesses/{business_uuid}/tables/{table_uuid}/rows/{row_id}/individual-row-permissions/ Same, per row.

Both also support PATCH/DELETE on /{id}/ for a single grant.


workpeg.* helpers (Peg UI)

The Peg UI subset's workpeg client (see Data & realtime) ships table sugar so you don't hand-build the endpoint paths. Every helper:

  • targets the active business — the id is read from the page URL (/b/<business_id>/...), so they resolve on business pages;
  • is protected — sends the session cookie, refreshing it when stale;
  • is namespaced — carries your Peg's Peg-Namespace header, so get_tables() returns your Peg's tables by default;
  • returns parsed JSON and is meant to be awaited.
Helper Calls
workpeg.get_tables([namespace]) List tables, optionally filtered by table namespace.
workpeg.get_table(table_uuid) One table (columns included).
workpeg.create_table(table) Create a table; resolves to the created table (with its uuid).
workpeg.update_table(table_uuid, patch) Partial update.
workpeg.delete_table(table_uuid) Delete the table.
workpeg.get_rows(table_uuid, limit=…, offset=…) The table's rows, descending by position. limit/offset (both optional) window the list server-side — the standard for any table that can grow; the response is a plain list either way.
workpeg.get_row(table_uuid, row_id) One row.
workpeg.add_row(table_uuid, row) Create a row.
workpeg.add_rows(table_uuid, rows) Bulk-create from a list of row payloads.
workpeg.update_row(table_uuid, row_id, patch) Partial update.
workpeg.delete_row(table_uuid, row_id) Delete the row.

Fetching from large tables — pass limit= (and offset= to step) so a growing table never ships whole:

self.items = await workpeg.get_rows(self.table_uuid, limit=100)
# next page:
more = await workpeg.get_rows(self.table_uuid, limit=100, offset=100)

Treat windowed fetches as the standard for user-generated data; an unqualified get_rows(uuid) still returns everything, which is fine for small, bounded tables (settings, seeds, lookups).

from workpeg.ui import App, State, Column, Text, Button, workpeg


class Todos(App):
    table_uuid = State("")
    items = State([])

    async def on_mount(self):
        tables = await workpeg.get_tables("todos")
        if len(tables) == 0:
            made = await workpeg.create_table({
                "name": "Todos",
                "namespace": "todos",
                "columns": [
                    {"id": "title", "name": "Title", "type": "text",
                     "position": 1000, "required": True},
                    {"id": "done", "name": "Done", "type": "boolean",
                     "position": 2000},
                ],
            })
            self.table_uuid = made["uuid"]
        else:
            self.table_uuid = tables[0]["uuid"]
        self.items = await workpeg.get_rows(self.table_uuid)

    async def add(self):
        row = await workpeg.add_row(self.table_uuid, {
            "data": {"title": "New todo", "done": False},
            "position": len(self.items) * 1000 + 1000,
        })
        self.items = [row] + self.items

    async def toggle(self, row):
        await workpeg.update_row(self.table_uuid, row["id"], {
            "data": {"title": row["data"]["title"],
                     "done": not row["data"]["done"]},
        })

    def build(self):
        return Column(
            Button("Add", on_click=self.add),
            *[Text(r["data"]["title"]) for r in self.items],
        )

Custom string column ids ("title", "done") keep row data readable. For anything the sugar doesn't cover (individual permissions, ?row_namespace=), drop to workpeg.get_protected(...) / workpeg.post_protected(...) with the REST paths above.


Table aliases

A table is addressed by its uuid — which you only learn after creating it. An alias is a stable name you choose, so a table can be referred to before (and without) knowing its uuid. Pass alias when you create it:

schema = await workpeg.create_table({
    "name": "Task schema",
    "alias": "task",
    "columns": [ ... ],
})

An alias is scoped to your business and your Peg. Two businesses running your app can both call their table "task" without colliding, and no other app can see or claim yours — you always declare and resolve the short name, and the platform qualifies it underneath. Rules worth knowing:

  • 1–64 characters: letters, digits, ., -, _, starting with a letter or digit.
  • Names beginning with _ are reserved for the platform (_email is the schema every email thread inherits).
  • Re-declaring the same alias on the same table is a no-op; claiming one another of your tables already holds is a 400.

Today aliases exist to serve abstract_from, below.


Abstract tables

An abstract table is a column schema kept in one place so other tables don't repeat it. A concrete table declares no columns of its own and names the abstract via abstract_from — the platform's email threads work this way, every thread table sharing one _email schema.

The point is that the schema stays in one place: add a column to the abstract and every table inheriting it has that column, immediately, with no migration and no walk over existing tables.

Using one

Create the schema once, alias it, then inherit it:

# once — the shared schema
await workpeg.create_table({
    "name": "Task schema",
    "alias": "task",
    "columns": [
        {"id": "title", "name": "Title", "type": "text", "position": 1000},
        {"id": "done", "name": "Done", "type": "boolean", "position": 2000},
    ],
})

# per project / per team / per whatever — no columns repeated
board = await workpeg.create_table({
    "name": "Q3 tasks",
    "abstract_from": "task",
})

board["columns"] comes back as the abstract's columns, and rows validate against them — an inheriting table behaves exactly like a concrete one to every other endpoint.

What to expect

Reading An inheriting table renders the abstract's columns in its own columns field.
inherits_from Read-only; the alias of the abstract, or null when the table owns its columns. The only way to tell the two apart.
Editing the abstract Changes the schema for every inheriting table at once.
Deleting the abstract Refused with a 400 while any table still inherits it.
Depth One hop. A table can't inherit from a table that is itself inheriting — that's a 400, not a silent empty schema.
Scope Your own aliases are private to your app. Platform templates are also available — see below. A schema you may not reach reports the same "does not exist" as a typo.

abstract_from with your own columns does nothing

If you send columns and abstract_from, your columns win and the inheritance is inert. Omit columns — that's what asks to inherit.

Read-modify-write freezes the schema

GET inlines the abstract's columns, so writing that response back (rename the table, tweak metadata) turns an inheriting table into a concrete snapshot, and later edits to the abstract stop reaching it. Check inherits_from before a round trip, and PATCH only the fields you meant to change.

Platform templates

Besides your own schemas, the platform provisions a few abstract tables for everyone to build on. Which apps may inherit one depends on whether it is pinned to a Peg:

The template Who may inherit it
Pinned to a Peg Only that Peg. Its schema is that app's, and abstract_from from anywhere else reads as "does not exist" — _email, the schema behind every email thread, works this way.
Not pinned to a Peg Any Peg. A shared starting point — inherit it and add your own tables on top.

You cannot mint one of these yourself: a template is registered by a platform operator, and the aliases you declare stay scoped to your business and app (a table you own is never a template, however it is aliased). A future release will let a Peg declare its own provisioning in its manifest, addressed as <peg-namespace>.<table>.

When not to bother

One table with a namespace column is simpler than many tables sharing an abstract. Reach for an abstract table when the rows genuinely belong in separate tables — separate permissions, separate lifetimes, separate things to delete — and you'd otherwise be pasting the same columns block into each one.


Safe hooks

Hooks run when rows are saved. They are safe in the sense that a hook is always a name looked up in a fixed catalog — either a built-in the platform ships, or a function you published, running in its own container. There is no way to inject code through the API: an unknown hook name is a validation error.

Two kinds, by when they run:

Hook Runs Can Failure
pre_save_hook Synchronously, before the row is validated/saved Transform the row payload, or reject it The save fails with a 400 carrying the hook's error
post_save_hook Asynchronously, after the row is saved Side effects (notify, index, chain) Logged; the save has already succeeded

And two kinds, by how they're called — the same phase rules apply to both, but the mechanics differ:

Built-in hooks Function hooks
What runs A method the platform ships (log_data, send_email, …) A function you published, in its own container
Name default.<name> on a table; bare <name> on a row custom.<namespace>:<version> on a table; <namespace>:<version> on a row
Where it runs In-process, inside the API Over HTTP — the platform POSTs to your container's /invoke
Input The row-save fields directly The {context, payload} envelope (payload = the row's data)
Pre-save result The hook's return value continues the save The response's result replaces the row's data
Post-save result Ignored Ignored (the row is already saved)
Latency bound — Each call is bounded by a platform timeout; a hung container fails the pre-save (400) instead of hanging the request

Registering hooks

A table holds at most one hook of each type. Register them either way:

On the table — pass hook names when creating (or updating) the table. Prefix built-ins with default. and functions with custom.:

{
  "name": "Contact requests",
  "columns": [ ... ],
  "pre_save_hook": "default.log_data",
  "post_save_hook": "custom.mypeg:1"
}

On the first row — pass pre_save_hook / post_save_hook in a row payload (no prefix for built-ins, namespace:version for functions). The first use persists the hook on the table; subsequent row saves run it automatically without passing anything:

await workpeg.add_row(self.table_uuid, {
    "data": {...},
    "pre_save_hook": "send_email",
})

Built-in hooks

Name Type What it does
log_data pre/post Logs the payload server-side; the identity hook — useful while wiring things up.
send_email pre Sends an email built from the row's data — expects to (list), from, and optionally subject, plain_text, html, cc, bcc, in_reply_to, attachments (a list of Workpeg file ids you can read). Adds the generated message_id back into the row data.
notify_email post Deprecated — scheduled for removal. Emails a notification per recipient listed in the row metadata's extra.notify_email, using subject/body templates from the table metadata's extra.notify_email ({"subject": ..., "body": ...}, rendered with each recipient's variables).
save_mail_alias post Platform email-thread plumbing (registers the row under its message id); rarely useful directly.

notify_email is being deactivated

The recipient list and template variables come from the row-save request itself (the row's metadata.extra.notify_email), which makes the hook abusable as an open email relay. It is scheduled for deactivation — don't build new tables on it; use a custom.<namespace>:<version> post-save function hook and send from your own function instead.

Custom hooks (your own Functions)

Reference a published function as custom.<peg_namespace>:<version> (table-level) or <peg_namespace>:<version> (row-level) — the version your business has installed. On each row save the platform POSTs to your function container's /invoke (see how the platform calls a function) with:

{
  "context": {
    "table":   { "uuid": "...", "name": "...", "owner_id": 1,
                 "metadata": {}, "columns": [...],
                 "namespace": "", "peg": null },
    "staff":   { "first_name": "...", "last_name": "...", "email": "..." },
    "metadata": { },
    "business": "<business_uuid>",
    "token":    "<a JWT minted for this call>"
  },
  "payload": { "COLUMN_ID": "value", "...": "..." }
}
  • payload is the row's data.
  • context.metadata is the row's metadata (on updates).
  • context.business and context.token are what your function uses to call back into the Tables API for this save. If the platform could not mint a token, both are left out, so read them with .get(...).

For a pre-save hook, the function's result replaces the row's data — return the (transformed) payload:

def main(context, payload):
    # normalize before it hits the table
    payload["title"] = payload.get("title", "").strip().title()
    return payload

Raise an exception (or return an error status) to reject the save — the API surfaces it to the caller as a 400 on pre_save_hook. A timeout or an unreachable container rejects the save the same way, so a stuck function can't hang the request.

A post-save hook is invoked the same way (same envelope, payload is the saved row's data) but is fire-and-forget: the row is already saved, the response is ignored, and a failure — non-2xx, timeout, network error — is logged server-side and never surfaced to the caller.


Backend access from a function

Hooks aren't only incoming: while it handles a call, your function can call back into the Tables API to create tables, CRUD rows and grant permissions, using the same REST endpoints with the token the platform put in that call's envelope:

Authorization: Bearer <context["token"]>

Every call the platform makes to your function carries two values for this in context, on a save hook and on a manifest action alike:

  • context["token"]: an HS256 JWT the API minted for this one call. It names your peg_namespace, the business and the person whose save or request caused the call, and it expires 15 minutes after it was minted.
  • context["business"]: the uuid of that business, for the {business_uuid} in the path.

The dashboard's WORKPEG_PK is refused here

The key from API & SDK → Generate token on the developer dashboard is signed by the registry, not by the API. It is for workpeg function submit and workpeg cdn submit. The Tables API cannot verify it and answers 401 to any call that sends it. Do not put it in your function's environment: read the token from context on every call instead.

There is no long-lived server key for the Tables API. A page reaches its tables through the SDK's workpeg.* helpers with the signed-in person's session, and a function reaches them only with the token of the call it is handling. A Sign in with Workpeg access token is not one either: it works on /v1/sso/userinfo and nowhere else.

Authentication is stateless: the token's signature, expiry and claims are the identity, with no session, cookie or server-side token record. The claims bound what the call can touch:

  • You act in one business, as one person. The {business_uuid} in the path must be the token's business, your Peg must be installed there, and the person the token names must still be on its staff; otherwise the API answers 403. You can do no more than that person could do themselves, and tables you create are owned by them.
  • You are jailed to your own namespace. Only tables whose peg is your Peg are visible: other Pegs' tables and staff-created (peg-less) tables answer 404/403, whatever ?peg= / Peg-Namespace filter you send. Tables you create or update have their peg pinned to your namespace; a peg value in the payload is ignored.

Everything inside the jail works like the normal API: table CRUD (including setting the initial permission digits at creation), row CRUD and bulk-create, table/row namespaces, and granting individual permissions to staff in the business, so your function can share its tables with the people who should see them.

# app/main.py — inside your published function (backend only!)
import requests

API = "https://api.workpeg.com"


def main(context, payload):
    # Minted by the platform for THIS call: never the dashboard WORKPEG_PK,
    # never an environment variable, never kept for a later call.
    token = context.get("token")
    business = context.get("business")
    if not token or not business:
        raise RuntimeError("this call carries no Workpeg token")
    headers = {"Authorization": f"Bearer {token}"}

    # Find (or create) this Peg's table in the calling business.
    tables = requests.get(
        f"{API}/v1/businesses/{business}/tables/?namespace=bookings",
        headers=headers, timeout=10).json()
    if not tables:
        table = requests.post(
            f"{API}/v1/businesses/{business}/tables/",
            json={"name": "Bookings", "namespace": "bookings",
                  "columns": [{"id": "title", "name": "Title",
                               "type": "text", "position": 1000}]},
            headers=headers, timeout=10).json()
    else:
        table = tables[0]

    row = requests.post(
        f"{API}/v1/businesses/{business}/tables/{table['uuid']}/rows/",
        json={"data": {"title": payload["title"]}, "position": 1000},
        headers=headers, timeout=10).json()

    return {"row_id": row["id"]}

The same CRUD, as cURL

Every call is the plain REST API plus the bearer header, nothing else. The cURL below shows the shape of each request. $TOKEN and $BIZ stand for context["token"] and context["business"] from one invocation, so they work for 15 minutes after that call and only in that business; the dashboard's WORKPEG_PK does not work in their place.

API="https://api.workpeg.com"
BIZ="<context['business']>"           # the business this call is for
TOKEN="<context['token']>"            # this call's token, never WORKPEG_PK
AUTH="Authorization: Bearer $TOKEN"   # backend only — see the warning below
JSON="Content-Type: application/json"

# CREATE a table — `peg` is pinned to your namespace automatically
curl -X POST "$API/v1/businesses/$BIZ/tables/" -H "$AUTH" -H "$JSON" -d '{
  "name": "Bookings",
  "namespace": "bookings",
  "columns": [
    {"id": "title", "name": "Title", "type": "text",     "position": 1000,
     "required": true},
    {"id": "when",  "name": "When",  "type": "datetime", "position": 2000}
  ]
}'
# → 201 {"uuid": "<table_uuid>", "name": "Bookings", "peg": "<your peg>", …}

# READ — list your Peg's tables / one table
curl "$API/v1/businesses/$BIZ/tables/" -H "$AUTH"
curl "$API/v1/businesses/$BIZ/tables/<table_uuid>/" -H "$AUTH"

# UPDATE — partial; only the fields you send change
curl -X PATCH "$API/v1/businesses/$BIZ/tables/<table_uuid>/" \
  -H "$AUTH" -H "$JSON" -d '{"name": "Bookings (v2)"}'

# CREATE a row (data keys are column ids)
curl -X POST "$API/v1/businesses/$BIZ/tables/<table_uuid>/rows/" \
  -H "$AUTH" -H "$JSON" -d '{
    "data": {"title": "Kick-off call", "when": "2026-07-03T10:00:00"},
    "position": 1000
  }'
# → 201 {"id": 1, "data": {…}, …}

# READ rows (descending by position) / one row
curl "$API/v1/businesses/$BIZ/tables/<table_uuid>/rows/" -H "$AUTH"
curl "$API/v1/businesses/$BIZ/tables/<table_uuid>/rows/1/" -H "$AUTH"

# UPDATE a row — `data` is replaced whole, send the full object
curl -X PATCH "$API/v1/businesses/$BIZ/tables/<table_uuid>/rows/1/" \
  -H "$AUTH" -H "$JSON" \
  -d '{"data": {"title": "Kick-off (moved)", "when": "2026-07-04T15:00:00"}}'

# BULK-CREATE — the body is a LIST of row payloads
curl -X POST "$API/v1/businesses/$BIZ/tables/<table_uuid>/bulk-create/" \
  -H "$AUTH" -H "$JSON" -d '[
    {"data": {"title": "Design review"}, "position": 2000},
    {"data": {"title": "Retro"},         "position": 3000}
  ]'

# DELETE a row / the table
curl -X DELETE "$API/v1/businesses/$BIZ/tables/<table_uuid>/rows/1/" -H "$AUTH"
curl -X DELETE "$API/v1/businesses/$BIZ/tables/<table_uuid>/" -H "$AUTH"
# → 204 each

A token the API did not mint (the dashboard's WORKPEG_PK included), or one that has expired, answers 401. A business other than the token's, one that hasn't installed your Peg, or a person no longer on its staff answers 403. Another Peg's (or a staff-created) table answers 404/403: the jail again.

Both tokens are backend secrets

The call's token acts as a real person in a real business until it expires. Use it only server-to-server, from your function container, and only for the call that carried it: don't log it, store it or pass it on. The dashboard's WORKPEG_PK publishes code under your Peg's name. Neither belongs in Peg UI code, a browser, a mobile app or a public repository (the UI's workpeg.* helpers use the signed-in person's session instead: that's the front-end path). If WORKPEG_PK leaks, regenerate it from the developer dashboard's API & SDK page.


Practical notes

  • Positions: floats, list order is descending, uniqueness is yours to keep. Space by 1000 and insert between (1500) — or use a timestamp for append-only feeds.
  • Fingerprints: both tables and rows return a fingerprint that changes on every save — compare, don't parse.
  • Unknown data keys (not matching any column id) are ignored — don't rely on them persisting.
  • Guests (staff with guest access) are covered by the public digit and by individual grants — the business digit never applies to them. Use the public digit for "everyone in the business, guests included", the business digit for staff-only.
  • select is deprecated: new tables should use mutable_select so choices can be relabelled without touching rows.