Your First App in 15 Minutes

From an empty directory to a live capability answering at api.revenexx.com — install the CLI, sign in, scaffold, deploy, verify.

This is the canonical first-app walkthrough. It takes you from an empty directory to a capability answering a real request at https://api.revenexx.com/v1 — no hand-written files. You will read two of the generated files along the way, because they are the two you will spend the rest of your time in. If the vocabulary here is new — tenant, app, capability, Studio — Concepts defines it on one page and takes five minutes.

Budget about fifteen minutes with your account already in hand. The clock does not include signing up — during the soft launch that takes hours to days, so sort it out first (see Prerequisites). From 1 November 2026, anyone can register and make an API call right away. Before you start you need a revenexx account and a tenant you may install an app on, an API key to call your app in the last two steps, and Node.js 22.12 or newer to run the scaffolded app's install and tests and the optional devkit preview (an earlier Node 22 runs the tests but not the devkit).

1. Install the CLI

Option A: npm

Terminal
npm install -g @revenexx/cli
revenexx -v

The package is public, so no registry configuration and no token are needed. See Installation for every option, including Windows.

Option B: Homebrew (macOS / Linux)

Terminal
brew install revenexx-sdks/cli/revenexx
revenexx -v

Option C: Download a binary (any platform)

Grab the asset for your platform from the latest release — this is the route to use on Windows if you don't have npm:

Terminal
curl -fsSL -o revenexx https://github.com/revenexx-sdks/cli/releases/latest/download/revenexx-darwin-arm64
chmod +x revenexx && sudo mv revenexx /usr/local/bin/revenexx
revenexx -v

2. Sign in

Run login with no arguments. The CLI opens your browser, you sign in with ID, and your session is saved to ~/.revenexx/prefs.json so later commands don't ask again.

Terminal
revenexx login

Then pick the tenant you're working in. The line printed after login names the tenants your account can access, and revenexx tenants list shows them again at any time (the same slug appears in Cockpit's tenant switcher, top left):

Terminal
revenexx tenants use <your-tenant-slug>

There is no project file and no init step — context comes from your login session, environment variables, and the active tenant.

In CI there is no browser, so pass a gateway API key and the tenant directly. Create the key in Cockpit under your avatar menu → Account → API-Keys:

CI
revenexx login --token "$REVENEXX_API_KEY" --tenant "$REVENEXX_TENANT"

A supplied key always wins over a stored browser session, so a pipeline never opens a browser. See CLI authentication for the full resolution order.

3. Scaffold the app

Terminal
revenexx create app locations --entity locations

One entity, plural snake_case. --entity is repeatable or comma-separated.

That writes the complete app contract and runs the codegen in-process:

locations/
locations/
├── manifest.json                 identity, type, permissions, events
├── manifest.capabilities.json    the capability register — generated entries are rewritten on every regen
├── schema.json                   your entities → platform tables
├── cockpit.json                  the admin UI: navigation + a list view
├── billing.json                  Marketplace listing and pricing
├── package.json                  one dependency: @revenexx/app-sdk
├── scripts/capabilities.js       regenerates the capability register from schema.json
├── src/main.js                   the function entrypoint
├── src/db.generated.js           the typed data client (generated — never hand-edit)
├── src/db.generated.d.ts         its type declarations (generated too)
├── test/locations.test.js        node --test against an in-memory store
├── README.md
└── .gitignore

Scaffolding validates before it writes: the app name must match ^[a-z][a-z0-9-]*$, the entity name ^[a-z][a-z0-9_]*$, and names that collide with SQL or JavaScript keywords (order, user, select, function, public, …) are refused outright. A non-empty target directory aborts the scaffold rather than merging into it.

Terminal
cd locations && npm install

Set the vendor

The scaffold writes "vendor": "revenexx" into manifest.json. That is revenexx's own namespace, so a deploy from any other tenant is refused with Vendor 'revenexx' is owned by another tenant. Change it before the first deploy:

manifest.json
{
  "name": "locations",
  "vendor": "<your-tenant-slug>",
  "version": "0.1.0"
}

A vendor can be any name no other tenant has claimed. Use your tenant slug: Cockpit builds the table names for an app's generated screens from the tenant slug, and finds the app's tables only when the vendor is the same.

A tenant slug that contains a hyphen, such as acme-test, does not work yet (as of 2026-10). With the slug as the vendor, every request to the app's data answers 400 App … is not installed for tenant …. A vendor without the hyphen avoids that error and the API works, but Cockpit's generated screens then look for the wrong tables. An underscore is refused at build time. Until that is fixed, build your first app on a tenant whose slug has no hyphen.

4. Read the two files that matter

schema.json — the tables you own

schema.json
{
  "$schema": "https://schemas.revenexx.com/schema.schema.json",
  "entities": {
    "locations": {
      "scopeable": ["market"],
      "columns": {
        "id":         { "type": "uuid", "pk": true, "default": "gen_random_uuid()" },
        "name":       { "type": "text", "notNull": true, "check": "length(name) > 0" },
        "metadata":   { "type": "jsonb" },
        "created_at": { "type": "timestamptz", "notNull": true, "default": "now()" },
        "updated_at": { "type": "timestamptz", "notNull": true, "default": "now()" }
      },
      "indexes": [
        { "columns": ["tenant_id", "name"] }
      ]
    }
  }
}

Four things to notice, because they are the four that surprise people:

  • type is the column type, used exactly as you write it. There is no string, no decimal, no float.
  • default and check are raw SQL expressions — so when you add a literal string default, it needs SQL quotes inside the JSON string: "'warehouse'".
  • There is no tenant_id column. The platform injects it and scopes every read and write for you — you may name it in an index, as above, but never declare it.
  • scopeable: ["market"] lets a row belong to one market or stay global — see Permissions and scope.

Full detail in the schema reference.

src/main.js — your function

src/main.js
'use strict';

const { createApp, mountCrud, badRequest } = require('@revenexx/app-sdk/router');
const { createDb, ENTITIES } = require('./db.generated');

// Idempotent per-tenant seeding — runs on the app.installed lifecycle event.
async function ensureDefaults(db) {
    return { created: [], existing: [] };
}

function buildApp(db) {
    const app = createApp({
        name: 'locations',
        healthExtra: () => ({ entities: Object.keys(ENTITIES) }),
    });

    mountCrud(app, db.locations, {
        path: '/locations/locations',
        columns: ENTITIES.locations.columns,
    });

    return app;
}

module.exports = async (context) => {
    const { req, res } = context;
    const headers = req.headers || {};
    const jwt = headers['x-revenexx-context'] ?? headers['X-Revenexx-Context'] ?? '';
    const path = (String(req.path || '/').replace(/\/+$/, '')) || '/';

    const trigger = headers['x-revenexx-trigger'] ?? '';
    const event = headers['x-revenexx-event'] ?? '';
    if (trigger === 'event' && jwt) {
        const db = createDb({ adapter: 'runtime', context });
        if (/(^|\.)installed$/.test(event)) {
            const result = await ensureDefaults(db);
            return res.json({ event, ...result });
        }
        return res.json({ event, ignored: true });
    }

    if (path !== '/' && !jwt) {
        return res.json({ error: 'missing tenant identity — this route needs the x-revenexx-context header' }, 503);
    }

    const db = createDb({ adapter: 'runtime', context });
    return buildApp(db).handler()(context);
};

module.exports.buildApp = buildApp;
module.exports.ensureDefaults = ensureDefaults;

createApp is the router. mountCrud wires one entity to the five standard REST routes — note the path is /locations/locations: the scaffold puts every route under the app's name, then the entity. The platform doesn't enforce that, but keep it: a method and path can belong to only one app platform-wide, and the first app to register it keeps it. createDb with the runtime adapter reads the invocation context, so you configure no endpoint, no tenant and no credential. The scaffold also wires lifecycle events (ensureDefaults runs on app.installed) and a health route at /. See the router and the typed client.

5. Regenerate and test

Any time you change schema.json, run the same three steps:

Terminal
revenexx apps capabilities --write
revenexx apps generate
npm test
  • apps capabilities --write regenerates the entity entries in manifest.capabilities.json from schema.json and strips the capability register out of manifest.json. It merges by capability key, so entries you wrote by hand (a custom route, an implement) survive — see Capabilities. Without --write it prints a dry run.
  • apps generate regenerates the typed data client under src/.
  • npm test runs the scaffolded node --test suite against an in-memory store, so it passes with no infrastructure.

Both generators are idempotent — re-running them on an unchanged schema produces no diff. The scaffolded npm run capabilities script emits identical output, so use whichever fits your workflow.

6. Preview the admin UI (optional)

Nothing below depends on this step — skip it on a first run if you would rather see the app live first.

cockpit.json is already populated with a navigation entry and a list view over your entity. Preview it locally with the devkit before you deploy, so a bad column name shows up on your laptop rather than in a merchant's sidebar. The devkit and the local preview loop are documented with the rest of the Cockpit surface — see Preview locally.

7. Deploy

Terminal
revenexx deploy app

One command runs the whole path: register the app if it is new, upload the directory, wait for the platform build, publish the version, install it on the active tenant. Where the platform reviews releases, publish and install wait until the release is approved, and the command stops before them until then (see When the release waits for approval).

The install step is the one people skip and regret: routes are written at install time. --no-install leaves you with a built version that answers nothing new. See Deploying.

Publishing is part of that command. By default deploy app also lists the version on the Marketplace, where any tenant can install it (where the platform reviews releases, only once the release is approved; see When the release waits for approval). To keep the app on your own tenant, set "type": "private" in manifest.json and deploy with --no-publish. A public app that is not published cannot be installed: the deploy stops after the build with App 'locations' is public but not yet published to the Marketplace, so it cannot be installed. Publishing a private app is refused, so --no-publish is required for one.

Bump version when you change the manifest. A version that has already been built is reused as it is, so a changed type, for example, has no effect until version is higher (as of 2026-10).

To deploy from a connected Git repository instead, push to the production branch — a git-wired App builds on push. Do not run revenexx deploy app against one: it uploads a tarball, and the active deployment loses its commit.

8. Call it for real

No API key yet? Confirm it is live from the CLI

Your login session is enough to invoke the app directly and read its health route. Find the app's id with revenexx apps list (apps get a generated id — the name won't work), then:

Terminal
revenexx apps create-execution --function-id <app-id> --path / --method GET
Output
status             : completed
responseStatusCode : 200
responseBody       : {"app":{"id":"6a98224e4f5ed184dbde","name":"locations","deployment":"6a98224e7b1ff30fdef4","runtime":"Node.js-25"},"status":"ok","routes":[{"method":"GET","path":"/locations/locations"},{"method":"POST","path":"/locations/locations"},{"method":"DELETE","path":"/locations/locations/{id}"},{"method":"GET","path":"/locations/locations/{id}"},{"method":"PUT","path":"/locations/locations/{id}"}],"timestamp":"2026-09-02T13:20:58.324Z","entities":["locations"]}

That proves the build, publish and install all happened: the five routes in routes were written at install time. It stops there, though — a direct execution skips the gateway, so the entity routes answer 503 missing tenant identity this way. To exercise them you go through api.revenexx.com, and that needs a key.

Through the gateway

Your capability now answers on the public gateway. The two headers carry your API key and your tenant slug — if you have not created a key yet, do it now in Cockpit under your avatar menu → Account → API-Keys (see Prerequisites), then export both. The key needs a scope that covers your app — locations.*, or full access on a development key; a key without one answers 403 (see Scopes). The key must also belong to the tenant in X-Revenexx-Tenant; a key from another tenant answers 403 api key not valid for this tenant:

Terminal
export REVENEXX_API_KEY="<your-api-key>"
export REVENEXX_TENANT="<your-tenant-slug>"

The public URL is /v1 plus the path your app mounted, so /locations/locations answers at /v1/locations/locations. Create a row, then list:

Verify
curl -s -X POST https://api.revenexx.com/v1/locations/locations \
  -H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
  -H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
  -H "Content-Type: application/json" \
  -d '{"name": "Main warehouse"}'

curl -s "https://api.revenexx.com/v1/locations/locations?limit=50" \
  -H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
  -H "X-Revenexx-Tenant: $REVENEXX_TENANT"

Always send a body on a POST whose capability declares a request schema — the gateway validates the body against the contract before your function is invoked, and no body at all fails that check.

The scaffold has no seeding route. ensureDefaults is an empty stub that runs on app.installed, and that event does not reliably fire on a Marketplace install. If your app needs default rows, add an idempotent route that seeds them (the shipped apps call it /defaults) and call it once after install, rather than trusting the event.

9. Confirm it is in the published contract

The same declaration that produced the route produced the contract. Your tenant's merged OpenAPI document should now contain your operation:

Verify the contract
curl -s https://api.revenexx.com/v1/openapi.json \
  -H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
  -H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
  | grep -o '"/v1/locations[^"]*"'

You should see "/v1/locations/locations" and "/v1/locations/locations/{id}" — the document keys carry the /v1 prefix. If your path is in there, so is your app — in the generated SDKs, in the API Explorer, and in the MCP tool list. That document is the only place any of them read from, which is why a wrong summary is fixed in the manifest and redeployed rather than anywhere else. See The published OpenAPI document.

When something does not answer

  • 404 on your path. The install step did not run, or you called a path your app does not mount (the public URL is /v1 plus the mounted path, e.g. /v1/locations/locations). Re-run revenexx apps capabilities --write and revenexx deploy app.
  • A list never returns past page one. The list capability declares no limit/offset parameters, so the gateway strips them. See Pagination and filtering.
  • 400 on a POST you thought was fine. You sent no body to a capability that declares a request schema. Send one — {} passes the gateway, but a create still needs every notNull column without a default, such as name.
  • 403 on a write. manifest.permissions does not grant that operation on that entity. Add it, regenerate, redeploy. See Entity access.
  • 403 api key not valid for this tenant. The key was created for a different tenant than the one in X-Revenexx-Tenant. Create a key in the tenant you are calling.
  • Vendor 'revenexx' is owned by another tenant at deploy. You did not change the scaffold's vendor. See Set the vendor.
  • public but not yet published to the Marketplace, so it cannot be installed at deploy. The build succeeded and the install did not run. Either deploy without --no-publish, or make the app private: set "type": "private", raise version, and deploy with --no-publish. See Deploy.
  • 400 App … is not installed for tenant … although the app is installed. The platform cannot match the app's vendor to its install record, which is what a vendor with a hyphen produces (as of 2026-10). Cockpit shows the same message on a generated screen when the vendor and the tenant slug differ. See Set the vendor.
  • Token expired? Re-authenticate: revenexx login, or in CI revenexx login --token <new-token> --tenant <your-tenant-slug>.
  • Deployment failed? Find your app's id with revenexx apps list (apps get a generated id — the name won't work), then review the build: revenexx apps list-deployments --function-id <app-id>. Build logs are in Cockpit under App Studio → My Apps.
  • Can't find the CLI? Verify installation: which revenexx (Unix) or where.exe revenexx (Windows). If missing, reinstall.

Where to go next

App model

What each generated file is for — dependencies, permissions, and events.

Build your first App, by hand

The same app built file by file, if you want to understand the scaffold rather than use it.

Integration Studio

Connect external systems with visual workflows.

Storefronts

Build a customer-facing storefront on Nuxt 4.
Was this page helpful?