Skip to content
ONIWC
iwc 0.1.0

API development: .api.iwc

Purpose: an HTTP endpoint under /api, with a request contract (what it accepts), a response contract (what it returns), security, and exactly one implementation: a built-in operation, an action, or a workflow transition. When: for every form submission, every data source that isn't a direct table read, and anything a mobile app or another system calls.

Structure

.api.iwc
{
  "name": "products",
  "method": "GET",
  "path": "/products",
  "description": "The public catalogue.",
  "operation": "db.list",
  "resource": "product.schema.iwc",
  "request": {
    "query": {
      "category": { "type": "string" },
      "sort": { "type": "string", "enum": ["name", "price", "-price"] },
      "q": { "type": "string", "maxLength": 60 },
      "page": { "type": "integer", "min": 1 },
      "limit": { "type": "integer", "min": 1, "max": 100 }
    }
  },
  "response": { "type": "list", "fields": { "id": "string", "name": "string", "price": "number", "image": "string" } },
  "security": { "auth": false, "rateLimit": "120/1m" },
  "cache": "30s"
}
PropertyTypeRequiredMeaning
namenameyesUsed by API.<name> data sources and as the OpenAPI operation id
methodGET POST PUT PATCH DELETEyes
pathrouteyesMounted under /api: "/products/:id" is /api/products/:id. Method + path is unique
operation / action / workflowexactly oneThe implementation (below)
resourceschema filefor db.*The table
transitionnamewith workflowWhich transition
request.payloadfield specswritesThe accepted JSON body. Undeclared keys are rejected
request.queryfield specsnoAccepted query parameters (validated; unrelated ones are ignored)
request.headers{name: description}noHeaders the API reads: an action receives them as input.headers (lower-case names)
request.rawBodybooleannoThe action receives the exact request body as input.rawBody (for webhook signatures)
response.fields{name: type or field}noThe only fields returned. Everything else is stripped
response.typeobject | listnoWith list, fields applies to each entry of items
response.status200–299noSuccess status (default 200, or 201 for creates and registration)
security.authtrue | false | required | optional | nonenotrue/required: 401 unless signed in
security.rolesrole listno403 unless the user has one (needs auth: true)
security.permissionspermission listno403 unless the user has one (access control)
security.scope{scope: field}no404 unless the record the field names is in the caller's scope
security.rateLimittrue (60/1m), "10/1m", or {"rate", "per"}noPer client IP, or per user / per request value (below)
security.idempotencyrequired | optionalnoIdempotency-Key handling for writes (below)
security.csrfbooleannoSet false to waive CSRF on a write that needs no sign-in (webhooks)
cachedurationGETCache responses per user; writes to the resource clear it
descriptionstringnoIn the API documentation
versionv1…v999noServe under /api/<version>: "version": "v2" with "path": "/orders" is /api/v2/orders. One method + path once per version (versions)
summary, tags, deprecatednoHow the endpoint appears in the API documentation: a one-line title, groups (default: the first path segment), and a deprecation mark (it is still served)
docsbooleannofalse leaves the endpoint out of the documentation (it is still served and still protected by its security)
examplesobjectnorequest (a body), query (parameters) and response shown in the documentation instead of generated ones

Implementations

Built-in operations

operationMethodDoes
db.listGETLists resource rows: {"items": [...], "total", "page", "pages"}. Understands ?page, ?limit (max 100), ?sort=field / ?sort=-field, ?q= (searches text fields), and ?<field>=value filters for declared query fields
db.getGETOne row by :id (path must contain :id), or 404
db.createPOSTInserts the validated payload. Needs request.payload; each key must be a schema field. Sets the schema's owner field to the user
db.updatePATCH/PUTUpdates the payload fields of row :id
db.deleteDELETEDeletes row :id
auth.registerPOSTCreates a user and signs them in. See authentication
auth.loginPOSTSigns in
auth.logoutPOSTSigns out
auth.meGETThe signed-in user, or 401
auth.exchangePOSTSigns in with a provider's credential (Firebase phone ID token); needs provider. See phone sign-in
auth.refreshPOSTRotates a token session's refresh token
auth.sessionsGETThe caller's signed-in devices
auth.revokePOSTSigns out one device, or all others
storage.uploadPOSTStores an uploaded file (below)

Every db.* operation applies the schema's access rules, so db.update on an owner-only table can only touch the caller's rows. Someone else's row looks exactly like a missing one (404), so ids can't be probed.

An action

.api.iwc
{
  "name": "checkout",
  "method": "POST",
  "path": "/checkout",
  "action": "checkout.action.iwc",
  "request": {
    "payload": {
      "items": { "type": "array", "required": true, "minItems": 1, "items": { "type": "object", "fields": { "id": { "type": "string", "required": true }, "quantity": { "type": "integer", "required": true, "min": 1 } } } },
      "address": { "type": "string", "required": true, "maxLength": 200 }
    }
  },
  "response": { "fields": { "orderId": "string", "total": "number" } },
  "security": { "auth": true, "rateLimit": "10/1m" }
}

The action receives the payload, the query parameters and the path parameters as input. Its return value is the response. See actions.

A workflow transition

.api.iwc
{
  "name": "shipOrder",
  "method": "POST",
  "path": "/orders/:id/ship",
  "workflow": "order.workflow.iwc",
  "transition": "ship",
  "security": { "auth": true, "roles": ["admin"] }
}

Moves record :id through the transition. It answers 409 if the record is not in a from state.

Idempotency keys

A phone that loses its connection mid-checkout retries. With "idempotency": "required", every request must carry an Idempotency-Key header (8–128 letters, digits, -, _; a fresh random value per operation, reused for its retries):

.api.iwc
{
  "name": "checkout",
  "method": "POST",
  "path": "/checkout",
  "action": "checkout.action.iwc",
  "request": { "payload": { "note": { "type": "string" } } },
  "security": { "auth": true, "idempotency": "required" }
}
A request with a key that…gets
hasn't been usedruns; its response is stored for 24 hours
was used for the same requestthe stored response, without running again (Idempotent-Replayed: true)
was used for a different request422 idempotency_key_reused
belongs to a request still running409 request_in_progress with Retry-After
belongs to a request whose server stopped mid-way409 idempotency_outcome_unknown: check the result before retrying with a new key

Keys are scoped to the app, the API and the signed-in user. A 5xx response isn't stored, so a retry runs again. "optional" applies all this only when the header is present. For operations that must never happen twice even across a server crash, also store the key on the record with a unique constraint (an order's idempotencyKey).

Rate limits per user or per value

.api.iwc
{
  "name": "otp",
  "method": "POST",
  "path": "/otp/check",
  "action": "otp.action.iwc",
  "request": { "payload": { "phone": { "type": "string", "required": true } } },
  "security": { "auth": false, "rateLimit": { "rate": "5/10m", "per": ["ip", "=input.phone"] } }
}

Each per entry is its own bucket, and all must allow the request: ip, user (signed-out callers count per IP), or an expression over input and user. Values are stored hashed, and an entry whose value is empty doesn't apply. With "rateLimit": {"store": "database"} in .security.iwc, counters live in the database, so every server instance shares them.

Webhooks: raw body and headers

Signatures are computed over the exact bytes a provider sent, which re-encoding the parsed JSON wouldn't reproduce:

.api.iwc
{
  "name": "paymentWebhook",
  "method": "POST",
  "path": "/webhooks/payments",
  "action": "payment-webhook.action.iwc",
  "request": {
    "rawBody": true,
    "headers": { "X-Signature": "HMAC-SHA256 of the body" },
    "payload": { "eventId": { "type": "string", "required": true }, "amountPaise": { "type": "integer", "required": true } }
  },
  "security": { "auth": false, "csrf": false }
}

The action receives input.rawBody and input.headers['x-signature']; they can't be supplied through the payload. Verify them with verifyHmacSecret, or an integration's verifyWebhook (packages).

A provider's event (Stripe, Razorpay, WhatsApp…) is large, nested and differs by type. Leave payload out and the API accepts any body: the action then gets only input.rawBody and the declared headers, so nothing unverified becomes an input field. verifyWebhook returns the parsed event once the signature holds.

Spreadsheets: CSV out and in

An API whose answer is a list can answer it as a CSV download instead of JSON:

.api.iwc
{
  "name": "exportProducts",
  "method": "GET",
  "path": "/products.csv",
  "action": "export-products.action.iwc",
  "response": {
    "format": "csv",
    "filename": "products.csv",
    "columns": ["sku", { "field": "name", "label": "Product" }, { "field": "category.name", "label": "Category" }, { "field": "price", "label": "Price" }]
  },
  "security": { "auth": true, "permissions": ["products.export"] }
}

columns sets the order and headings (a field, or {field, label} with a dot path into nested values); without it every field is a column, alphabetically. A cell that a spreadsheet would run as a formula (starting =, +, -, @, a tab or a carriage return) gets a leading apostrophe, so text a visitor typed can't run in whoever opens the file. Numbers are written as numbers. An action can read up to 10,000 rows with a query step's limit.

To import, send the file's text in a JSON field (a widget can read a file the visitor picks) and parse it with parseCsv(text, delimiter?): the first row names the columns, each later row becomes {column: text} (trimmed; blank lines skipped; at most 5,000 rows). Inside a "transaction": true action, one bad row rolls the whole import back:

step (in a .iwc file)
{ "operation": "forEach", "items": "parseCsv(input.csv)", "as": "row", "steps": [
  { "operation": "require", "condition": "number(row.price) > 0", "error": "=concat('Row ', row.sku, ': the price must be positive')" },
  { "operation": "create", "resource": "product.schema.iwc", "values": { "sku": "=row.sku", "name": "=row.name", "price": "=number(row.price)" } }
] }

Field specs

The same shape is used for request.payload, request.query, action input, event payload and component props:

field (in a .iwc file)
{ "type": "string", "required": true, "minLength": 2, "maxLength": 80, "pattern": "^[A-Za-z ]+$", "trim": true, "description": "Display name" }
KeyMeaning
typeRequired. string email url date datetime number integer boolean object array any
required, default
min, maxnumber bounds
minLength, maxLengthstring length (default max 10,000)
patternregular expression (checked at compile time)
enumallowed values
minItems, maxItems, itemsarrays; items is a field spec
fieldsobjects: nested field specs
trimtrim strings (default true)
nullableaccept an explicit null: an optional field is cleared (a PATCH that sends "note": null empties it), and a required one may be sent as null
descriptiondocumentation
examplean example value for the API documentation (never used as a value)

Query parameters arrive as strings and are converted ("2" becomes 2 for an integer, "true" becomes true).

Errors

Every error has one shape:

json
{ "error": { "code": "validation_failed", "message": "Some fields are invalid", "fields": { "email": "is required" } } }
StatuscodeWhen
400invalid_json, bad_requestUnparseable body
401unauthenticated, invalid_credentialsNot signed in / wrong password
403forbidden, csrfMissing role or access rule / missing CSRF token
404not_foundNo such route or row
405method_not_allowedPath exists, method doesn't
409conflict, invalid_transitionUnique value taken, record changed, stock rule
413, 415too_large, unsupported_media_typeBody over 1 MB / not JSON
422validation_failed, rule_failedInvalid input / an action's require failed
429rate_limitedToo many requests
500internal_errorA bug or outage. The message carries a reference; the details are in the server log

Calling APIs

  • From forms (submit.api), buttons (action: "api") and data sources (API.<name>), IWC handles sessions and CSRF for you.
  • From other clients: send JSON, and either a session cookie plus the X-IWC-CSRF header, or Authorization: Bearer <token> with the token returned by auth.login/auth.register (bearer requests need no CSRF token). Declare token in the login API's response.fields to return it. A bearer token that is expired, revoked or from another app gets 401 with WWW-Authenticate: Bearer error="invalid_token", so the client knows to sign in again.
  • A client that doesn't render pages (a phone app, a script, an API-only project) gets its CSRF token from GET /_iwc/csrf → {"csrf": "…"}, which also sets the cookie the token pairs with. Send both on the next write, typically the sign-in. Pages on other origins can't read this response.
Terminal
jar=$(mktemp); tok=$(curl -s -c $jar localhost:3000/_iwc/csrf | sed 's/.*"csrf":"\([^"]*\)".*/\1/')
curl -s -b $jar -H "X-IWC-CSRF: $tok" -H 'Content-Type: application/json' \
  -d '{"email":"maya@example.com","password":"…"}' localhost:3000/api/auth/login
  • Every API is documented at /_iwc/openapi.json (OpenAPI 3.1) and explorable at /_iwc/api-docs in development (below).
  • A client on another origin needs that origin in .security.iwc cors.origins. It may then send Content-Type, Authorization, X-IWC-CSRF, Idempotency-Key and every header an API declares in request.headers, and read Retry-After, Idempotent-Replayed, WWW-Authenticate and Content-Disposition in answers.
Terminal
curl -s localhost:3000/api/products?sort=-price&limit=2

File uploads

.api.iwc
{ "name": "upload", "method": "POST", "path": "/uploads", "operation": "storage.upload", "security": { "auth": true } }

The form (or client) sends multipart/form-data with the file in a field named file:

element (in a .iwc file)
{ "type": "form", "inputs": [{ "name": "file", "type": "file", "label": "Photo", "accept": "image/*" }], "submit": { "api": "upload.api.iwc", "label": "Upload" } }

The response is {"id", "url", "name", "mime", "size"}; the file is served at url (/_iwc/files/<id>). The size limit and allowed types come from app.storage. The type is detected from the file's bytes, not its name, and scriptable types (HTML, SVG, JavaScript) are always refused.

Versions

Two versions of an endpoint can be served side by side while clients move over:

.api.iwc
{
  "name": "getOrderV2",
  "version": "v2",
  "method": "GET",
  "path": "/orders/:id",
  "operation": "db.get",
  "resource": "order.schema.iwc",
  "response": { "fields": { "id": "string", "status": "string", "total": "number" } },
  "security": { "auth": true }
}

This is /api/v2/orders/:id; an API without version keeps its plain /api/... address, so adding versions never moves existing clients. Mark the old one "deprecated": true to say so in the documentation. Forms, buttons and data sources that name a versioned API call its versioned address.

API documentation

IWC documents every API from its definition. Nothing is written twice: the request contract, validation rules, response fields, status codes and security come from the same declarations the server enforces, and the request-body rules are produced by the very function that validates requests.

WhereWhat
/_iwc/api-docsAn interactive explorer: endpoints grouped by tag, expandable details, parameter and JSON body editors prefilled with examples, Try it out against the running server (with your browser session, a CSRF token fetched for you, or a bearer token), and the real status, headers, body and per-field validation errors. Served in development; set apiDocs.ui to always to serve it in production
/_iwc/openapi.json, /_iwc/openapi.yamlThe OpenAPI 3.1 document, for Postman, client generators, gateways and contract tests. Served always unless apiDocs.spec says otherwise
iwc make --api docsThe same as files: openapi.json, openapi.yaml and a self-contained index.html (CLI)
iwc builddist/openapi.json and dist/openapi.yaml

What the document says, and where it comes from:

In the documentFrom
Path, method, path parameters, versionpath, method, version
Summary, description, tags, deprecatedsummary (default: the name), description, tags (default: the first path segment), deprecated
Query parameters and headersrequest.query with every rule; request.headers; Idempotency-Key (required when security.idempotency is required) and X-IWC-CSRF where they apply
Request bodyrequest.payload, or the body a built-in operation accepts (sign-in, password reset, two-factor…); additionalProperties: false because undeclared keys are refused. Uploads are multipart/form-data; an opaque webhook takes any body
Types and rulestype, required (a field with a default isn't), nullable, default, enum, min/max, minLength/maxLength (strings default to at most 10,000 characters), pattern, minItems/maxItems, nested fields and items
Success responseresponse.fields (the only fields returned), else the table's row for db.* operations (sensitive fields never appear), the transition result for workflows, the stored file for uploads. An action without response.fields is documented as having an undocumented shape: declare the fields to document it
Error responsesOnly the answers this endpoint can give: 400/413/415 with a body, 401, 403 with roles, permissions, access rules or CSRF, 404 with an id or scope, 409 for workflows, unique values, idempotency or offline conflicts, 422, 429 with a rate limit, 500. All share the Error shape
Securitysecurity.auth (session cookie or bearer token; optional sign-in listed as optional), roles and permissions (x-iwc-roles, x-iwc-permissions and in words), scopes, rate limit and cache in the description
Examplesexamples when declared, else built from the rules (a declared field example or default first). iwc make warns when a declared request example would be refused

Nothing secret is included: no sensitive fields, credentials, environment values, action steps or provider settings.

Configure it in app.iwc:

app.iwc
{
  "name": "Atlas",
  "apiDocs": {
    "title": "Atlas API",
    "description": "Orders, catalogue and accounts.",
    "version": "2.1.0",
    "servers": [{ "url": "https://api.atlas.example", "description": "Production" }],
    "tags": { "orders": "Placing and tracking orders" },
    "contact": { "email": "api@atlas.example" },
    "spec": "always",
    "ui": "development"
  }
}

spec and ui take always, development or never. Without servers, the live document lists the server that answered (with its base path) and iwc make uses --server, seo.siteUrl or http://localhost:3000.

Common mistakes

MessageFix
an api names exactly one of "operation", "action" or "workflow"A response schema alone implements nothing
db.get needs an :id path parameter"path": "/products/:id"
db.create is served with POSTMatch the method to the operation
db.create needs a declared "request.payload"Only declared fields are ever written
product.schema.iwc has no writable field "id"id, createdAt, updatedAt are managed
field "password" is sensitive and is never returned by an apiRemove it from response.fields
GET apis take "query" parameters, not a payload
"roles" requires "auth": true

Source: iwc/docs/api-development.md