Praxsuite

Mutations

Vincent Depassier · August 29, 2026

PraxQL — Mutations

Mutations write data through the same /query endpoint as reads. You send a mutation key instead of a query key.


The mutation body

{
  "refs": {
    "TableAlias": "table-guid"
  },
  "mutation": {
    "type": "insert",
    "table": "TableAlias"
  }
}

type is insert, update or delete. mutation and query are mutually exclusive — send one, not both.


Who can mutate

The credential's table scope needs AccessLevel of write or readwrite. A read-only key gets 403 SCOPE_VIOLATION before anything is parsed. A new table scope is created as `read`, so writing is always something somebody turned on deliberately.

Call mutations from server-side code with a Server Key (sk_live_). A write-scoped key in a browser is a write-scoped key in everyone's hands.


INSERT

{
  "refs": {
    "Customers": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
  },
  "mutation": {
    "type": "insert",
    "table": "Customers",
    "values": [
      { "Name": "Acme Corp",  "Email": "hello@acme.com",  "Region": "EU" },
      { "Name": "Beta Inc",   "Email": "info@beta.io",    "Region": "US" }
    ],
    "returning": true
  }
}

Field

Description

values

Array of row objects, each mapping column name to value

returning

true returns the inserted rows with every readable column; false or omitted returns a count; an array of column names returns just those

"returning": ["Name", "Email"]

Columns you cannot set

These are injected by the platform, and sending any of them returns 400 INVALID_MUTATION:

Column

Behaviour

ID

Generated GUID

CREATEDDATE / UPDATEDDATE

Current UTC timestamp

CREATEDBY / UPDATEDBY

The authenticated principal

AUTONUMBER

Auto-incremented

POSITION

Auto-assigned

CREATEDBY being un-settable is not just hygiene — it is what makes it usable as a security anchor. See the write filter section below.

Maximum 100 rows per insert. For a bulk import, chunk it.


UPDATE

{
  "refs": {
    "Customers": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
  },
  "mutation": {
    "type": "update",
    "table": "Customers",
    "set": {
      "Status": "Inactive",
      "Region": "EU"
    },
    "where": [
      { "field": "LastActiveDate", "op": "lt", "value": "2025-01-01" }
    ]
  }
}

Field

Description

set

Column names mapped to new values. Maximum 50 columns.

where

Required. At least one condition.

{ "affectedRows": 12, "durationMs": 8 }

DELETE

{
  "refs": {
    "Customers": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
  },
  "mutation": {
    "type": "delete",
    "table": "Customers",
    "where": [
      { "field": "Status", "op": "eq", "value": "Archived" },
      { "field": "DeletedAt", "op": "isNull" }
    ]
  }
}
{ "affectedRows": 3, "durationMs": 5 }

where is mandatory, and that is the point

An update or delete with no where is rejected with 400 INVALID_MUTATION. There is no flag to opt out.

An unscoped mutation is not a rare mistake — it is what a bug produces when a filter value comes back empty and the condition gets dropped. Requiring the clause means that bug fails loudly instead of emptying a table.


Row filters apply to writes too

If the table scope carries a row filter, it is injected into the where of every update and delete, exactly as it is into every query. The caller cannot see it, override it, or write around it: their conditions are ANDed with it, never substituted for it.

The separate write filter

A scope can carry two filters:

Filter

Applies to

Row filter

query — and to mutations when no write filter is set

Write row filter

insert, update, delete, overriding the read filter

Leave the write filter empty and one rule governs everything — a row the caller cannot read is a row they cannot modify. That is the default and it is usually right.

Set it when several people share a container. A chat channel scoped Channel = X with write access lets every member edit and delete every other member's messages, because a single filter only decides which rows are matched. Two filters express the rule you actually wanted:

   read filter   →  Channel eq X          ("read the whole room")
   write filter  →  CREATEDBY eq {{claim:sub}}   ("change only what is yours")

`CREATEDBY` is the anchor to reach for. The gateway stamps it from the caller's verified token on every insert and refuses to let a client set it, so it is the one column a caller cannot forge.

An UPDATE is checked a second time after it executes: if the rows it touched do not satisfy the write filter, the transaction is rolled back rather than committed.


Column permissions

Writing a column needs CanWrite on its column scope, and `CanWrite` is off by default — the opposite of CanRead. So the moment you define column scopes on a table, every column becomes read-only until you say otherwise. A table with no column scopes at all stays fully writable.

A column the credential cannot write is rejected rather than ignored. A mutation naming it returns 400 INVALID_MUTATION and names the column — silently dropping it would let a caller believe a value was stored.


Claim placeholders

For end-user JWT callers, a mutation value can reference a claim from the token with {{claim:claimName}}. It is substituted server-side before executing:

"values": [
  {
    "OwnerId": "{{claim:sub}}",
    "Title":   "My Record"
  }
]

This is how multi-tenant inserts get tagged with the caller's own id without trusting the client to send it. The value comes from the validated token, so a caller cannot claim to be somebody else by editing the request body.

Or let the column do it for you

A column scope can carry a default value template using the same {{claim:…}} syntax. When set, the value is injected on every insert and cannot be overridden by the caller — even if they send that column explicitly.

The difference matters. A placeholder in your request body is your app choosing to stamp the row correctly; a default on the column scope is the platform refusing to let it be stamped any other way. For ownership columns, prefer the second.


Announcing a mutation on the Event Bus

A mutation can announce itself on an Event Bus bus the moment it commits, with no automation in the path:

{
  "refs": { "m": "<table_id>" },
  "mutation": {
    "type": "insert",
    "table": "m",
    "values": [{ "Channel": "9f2c...", "Body": "hello" }],
    "returning": ["Body", "Channel"],
    "notify": { "bus": "chat:9f2c8ab1-...", "event": "message.created" }
  }
}

Field

Meaning

bus

Full bus key, {topic}:{instance}. The topic must be declared and enabled.

event

Name subscribers switch on. Defaults to row.created / row.updated / row.deleted.

Three rules decide what this is safe for:

  • It fires after the commit, never before. Nothing is announced that could still roll back.

  • The event body is built from what was actually written, never from your request. With returning, the rows go out mapped to logical column names and masked with the same rules as a read — a column the caller cannot see over REST does not arrive over the bus instead. Without returning, only the row ids go out, which is enough for a subscriber to fetch what it is entitled to.

  • It is best-effort. The row is already durable; an ephemeral fan-out is never allowed to fail a persistent write.

Publishing needs participate on the target bus, and it is checked before the write runs — so a refused notify fails the request instead of committing and then going quiet. The reserved user: topic cannot be a notify target.


Guardrails

Limit

Value

Rows per insert

100

Columns per set

50

Where conditions

50

Command timeout

30 seconds