Publish a business event from your App

Have your App announce a fact that is not a row change — declare the event with a sample, publish it from a route after the write, pick an id that makes a retry harmless and a topicId that keeps one entity on one channel, test it with the mock adapter, and look up the topic your consumers subscribe to.

Most of what an App announces takes care of itself: a row changes, and a row event goes out. This guide is for the other kind of fact, the one no single row says: a stock transfer was received, an order was placed, a payment was captured. You declare that event in the manifest, publish it from your own code with db.events.emit(), and end up with a topic that a webhook, a workflow or another App can subscribe to.

The example adds a transfer to the Locations app from Build your first App and Add a data model: stock moves from one location to another, and receiving it publishes transfer.received. This is for developers who already have an App they deploy with the CLI. You need your tenant slug, an API key (create an API key) with the right scope (which scope covers which app), @revenexx/app-sdk 0.8.0 or later, and the typed data client from Add a data model. The code goes in the src/main.js and scripts/capabilities.js you already have, where app, db and buildApp are the router, the data client and the builder from those tutorials.

Row event or business event?

A row event is a property of a table: it fires on every insert, update or delete of that entity, from whatever path writes the row, and you write no code. A business event is a call in your code, so you decide when it fires and what it says.

Reach forWhen
A row event"A transfer row changed" is all a consumer needs. You want every change, including ones you did not write.
A business event (trigger: "app")The fact spans several rows, or depends on a decision ("only when it really changed"), or must fire once however many rows were written.

Receiving a transfer moves stock in two locations and closes one transfer row. Three row changes, one fact. That is a business event. The full field reference is Events you emit. This page is the walk-through.

1. Add the transfer

Add a transfers entity inside entities, next to locations and stock_levels, in schema.json, and add the transfers line to the permissions list in manifest.json:

schema.json
"transfers": {
  "columns": {
    "id":               { "type": "uuid", "pk": true, "default": "gen_random_uuid()" },
    "from_location_id": { "type": "uuid", "notNull": true, "references": { "entity": "locations", "column": "id" } },
    "to_location_id":   { "type": "uuid", "notNull": true, "references": { "entity": "locations", "column": "id" } },
    "sku":              { "type": "text", "notNull": true },
    "quantity":         { "type": "numeric(14,3)", "notNull": true, "check": "quantity > 0" },
    "status":           { "type": "text", "notNull": true, "default": "'requested'", "check": "status in ('requested', 'shipped', 'received')" },
    "created_at":       { "type": "timestamptz", "notNull": true, "default": "now()" },
    "updated_at":       { "type": "timestamptz", "notNull": true, "default": "now()" }
  }
}
manifest.json
"permissions": [
  { "entity": "locations", "access": ["read", "create", "update", "delete"] },
  { "entity": "stock_levels", "access": ["read", "create", "update", "delete"] },
  { "entity": "transfers", "access": ["read", "create", "update"] }
]

2. Declare the event

Every event your App emits is declared in the manifest first. Add this object to the emits array under events in manifest.json. If the array is not there yet, create it next to listens, and keep any entries it already has (the scaffold declares location.created, a row event):

manifest.json
{
  "name": "transfer.received",
  "trigger": "app",
  "data_schema": {
    "type": "object",
    "properties": {
      "transfer_id": { "type": "string" },
      "from_location_id": { "type": "string" },
      "to_location_id": { "type": "string" },
      "sku": { "type": "string" },
      "quantity": { "type": "number" }
    }
  },
  "sample": {
    "transfer_id": "7f3a9c1e-0000-4000-8000-000000000001",
    "from_location_id": "7f3a9c1e-0000-4000-8000-0000000000a1",
    "to_location_id": "7f3a9c1e-0000-4000-8000-0000000000b2",
    "sku": "WIDGET-1",
    "quantity": 12
  }
}
  • The name is exactly two segments, <entity>.<action>, lowercase. transfer.received is valid. stock.transfer.received (three segments) and received (one) are refused.
  • sample is required on an app-fired event, and entity, on and payload are refused on one. There is no row to derive a payload from, so the sample is what catalogs show and test runs prefill.
  • data_schema is optional and worth writing. It is what a consumer builds against, so describe the payload you actually send, and keep personal or bulky data out of it.

3. Regenerate the client

db.events exists only for events you declared. A project from an older tutorial, or one scaffolded by revenexx create app, may still pin ^0.4, and db.events arrives in 0.8.0, so make sure you are on it. Then regenerate the typed client:

Upgrade and regenerate
npm install @revenexx/app-sdk@^0.8.0
revenexx apps generate

Without this step db.events is undefined. It is also undefined when the SDK is older than 0.8.0, and when no app-fired event is declared at all.

4. Publish after the write

Add the route. The rule it follows: the event is a claim that something happened, so make it after the change it describes is stored.

src/main.js
// Extend the router require you already have with notFound and conflict:
const { createApp, mountCrud, badRequest, notFound, conflict } = require('@revenexx/app-sdk/router');

async function receiveTransfer(db, transferId) {
  const transfer = await db.transfers.get(transferId);
  if (!transfer) throw notFound('transfer not found');
  if (transfer.status === 'requested') throw conflict('the transfer has not shipped yet');

  if (transfer.status === 'shipped') {
    // Move the stock between the two locations' stock_levels rows here
    // (your own logic, not shown), then close the transfer.
    await db.transfers.update(transfer.id, { status: 'received' });
  }

  await db.events.emit('transfer.received', {
    transfer_id: transfer.id,
    from_location_id: transfer.from_location_id,
    to_location_id: transfer.to_location_id,
    sku: transfer.sku,
    quantity: Number(transfer.quantity),
  }, {
    topicId: transfer.id,
    id: `evt_transfer_received_${transfer.id}`,
  });

  return { ...transfer, status: 'received' };
}

// Inside buildApp(db), after the existing mountCrud calls:
app.post('/locations/transfers', async (c) => {
  const b = c.body ?? {};
  for (const key of ['from_location_id', 'to_location_id', 'sku']) {
    if (!b[key]) throw badRequest(`'${key}' is required`);
  }
  const quantity = Number(b.quantity);
  if (!(quantity > 0)) throw badRequest("'quantity' must be greater than 0");
  const transfer = await db.transfers.create({
    from_location_id: b.from_location_id, to_location_id: b.to_location_id, sku: b.sku, quantity, status: 'shipped',
  });
  return c.json(transfer, 201);
});

app.post('/locations/transfers/receive', async (c) => {
  const transferId = c.body?.transfer_id;
  if (!transferId) throw badRequest("'transfer_id' is required");
  return c.json(await receiveTransfer(db, transferId));
});

// At the bottom, below the existing `module.exports.buildApp = buildApp;`:
module.exports.receiveTransfer = receiveTransfer;

db.events.emit(name, data, options) answers { id, topic }, where topic is the full name a consumer subscribes to. It only accepts names the manifest declares: an undeclared one fails at the call.

A route has no transaction around its write and its emit, so the process can die between the two, and the caller retries. Step 5 explains the id and topicId options used above, and what makes that retry safe. Keep the stock move safe to repeat too, or a retry after a crash moves the stock twice: change it and close the transfer in one guarded step, or record each move as a ledger row.

The transfer is written by two routes only, ship and receive, and there is deliberately no CRUD mount for it: a PUT that set status to received would close a transfer without the event. Declare both routes as capabilities so the gateway exposes them, the same way as the custom route in Add a data model. The snippet below is written for the hand-written def(...) script from those tutorials. If your scripts/capabilities.js is instead the one revenexx create app generated, it builds CRUD capabilities for every entity in permissions, including a PUT and a POST /locations/transfers that would collide with ship and close a transfer without the event. Change its line const generated = permitted.flatMap(crud); to permitted.filter((entity) => entity !== 'transfers').flatMap(crud), delete any locations.transfers.* entries a previous run already wrote to manifest.capabilities.json, and add the ship and receive entries there by hand as { "type": "define", "capability": ..., "route": { ... }, "request": ..., "response": ... } objects. The merge keeps hand-written entries.

scripts/capabilities.js
const SHIP_REQUEST = {
  title: 'ShipTransferRequest',
  type: 'object',
  required: ['from_location_id', 'to_location_id', 'sku', 'quantity'],
  properties: {
    from_location_id: { type: 'string', format: 'uuid' },
    to_location_id: { type: 'string', format: 'uuid' },
    sku: { type: 'string' },
    quantity: { type: 'number', exclusiveMinimum: 0 },
  },
};

const RECEIVE_REQUEST = {
  title: 'ReceiveTransferRequest',
  type: 'object',
  required: ['transfer_id'],
  properties: { transfer_id: { type: 'string', format: 'uuid' } },
};

const TRANSFER = {
  title: 'Transfer',
  type: 'object',
  properties: {
    id: { type: 'string', format: 'uuid' },
    from_location_id: { type: 'string', format: 'uuid' },
    to_location_id: { type: 'string', format: 'uuid' },
    sku: { type: 'string' },
    quantity: { type: 'number' },
    status: { type: 'string', enum: ['requested', 'shipped', 'received'] },
  },
};

// Add to the capabilities array:
//   def('locations.transfers.ship', 'Ship a stock transfer', { method: 'POST', path: '/locations/transfers' }, { request: SHIP_REQUEST, response: TRANSFER }),
//   def('locations.transfers.receive', 'Receive a stock transfer', { method: 'POST', path: '/locations/transfers/receive' }, { request: RECEIVE_REQUEST, response: TRANSFER }),

Then regenerate the capability list. The gateway exposes only what the manifest lists, so skipping this gives you a 404 on every call in step 8:

Regenerate capabilities
npm run capabilities

5. Choose the id and the topicId

id is the idempotency key. A second emit with an id the platform has already seen is a no-op, so a deterministic id turns a retry into nothing instead of a second event. Without an id, every emit is a new event.

That is why the handler above emits even when the transfer was already received: a retry after a crash finds the row closed and skips the write, but still emits, and if the first attempt did publish, the repeat changes nothing. This is how this guide makes a retry safe, not something the platform does for you: a route that returns early when the work is already done never reaches its emit, and a crash between write and emit loses the event. How long the platform remembers an id is not documented (as of 2026-09), so do not lean on a repeat arriving months later being dropped. What goes into the id depends on how often the fact can happen:

  • It can happen once per entity (a transfer is received once): the entity id, as above.
  • It can happen again (an order put on hold, released, put on hold again): the id must also name the occurrence. Write a row for each occurrence, an audit entry, say, and put its id in the event id. An id built from the entity alone would swallow the second real hold.
  • It is one of many from one call (a cancellation per line): entity id plus the row that marks this one.

topicId is not the topic. It is the id of the resource the event is about, matching [A-Za-z0-9][A-Za-z0-9_-]{0,63}, and the platform refuses anything else instead of reshaping it. A realtime channel is built from the event name's first segment plus the topicId, so keep it the same for everything about one entity. If every event about a transfer carries the transfer id, one subscription hears its whole life. Put a child row's id there and the event lands on a channel nobody is watching. Leave it out for an event about a collection.

6. Publish per change, not per call

A route that recomputes many rows, such as a nightly job, runs far more often than anything moves. An event per call puts every run on the tenant's bus, and an event per change puts on it exactly what happened.

src/main.js
app.post('/categories/recompute', async (c) => {
  const category = await db.categories.get(c.body?.category_id);
  if (!category) throw notFound('category not found');

  const changed = await recomputeCategory(db, category);   // your own routine: { added, removed }
  if (changed.added + changed.removed === 0) return c.json({ changed: false });  // nothing moved, nothing to announce

  // `category.recomputed` is declared in the manifest like step 2 declares `transfer.received`.
  await db.events.emit('category.recomputed', { category_id: category.id, ...changed }, { topicId: category.id });
  return c.json({ changed: true, ...changed });
});

Guard on the change itself, then emit. A repeated call finds nothing left to move, so it publishes no duplicate. The guard cannot tell a retry from a repeat, though: a call that died after writing but before emitting announces nothing when it is retried, so the event is lost. Where a lost event matters, use a deterministic id and emit on the retry path as in step 4. If a consumer will treat the event as the trigger for work, a flood of no-op events is a flood of no-op work.

7. Test it with the mock adapter

The mock adapter records every emit instead of publishing it, and it drops a repeated id just like the real call. A test asserts what the route fired with db.events.emitted():

test/transfers.test.js
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { createDb } = require('../src/db.generated');
const { receiveTransfer } = require('../src/main');

test('receiving a transfer publishes one event, even when repeated', async () => {
  const db = createDb({ adapter: 'mock', seed: { transfers: [], stock_levels: [], locations: [] } });
  const transfer = await db.transfers.create({
    from_location_id: 'a1', to_location_id: 'b2', sku: 'WIDGET-1', quantity: 12, status: 'shipped',
  });

  await receiveTransfer(db, transfer.id);
  await receiveTransfer(db, transfer.id); // the caller retried

  const sent = db.events.emitted();
  assert.equal(sent.length, 1);
  assert.equal(sent[0].event, 'transfer.received');
  assert.equal(sent[0].options.topicId, transfer.id);
  assert.equal(sent[0].data.quantity, 12);
});

test('a transfer that has not shipped announces nothing', async () => {
  const db = createDb({ adapter: 'mock', seed: { transfers: [] } });
  const transfer = await db.transfers.create({ sku: 'WIDGET-1', quantity: 1, status: 'requested' });

  await assert.rejects(receiveTransfer(db, transfer.id));
  assert.equal(db.events.emitted().length, 0);
});
Test
npm test

A mock proves your route fires the right event with the right id. It does not prove the platform delivered it, and db.events.emitted() throws on any other adapter. See Adapters for what a mock does not enforce.

8. Deploy, find the topic, see it arrive

Deploy the App, then read the topic your consumers subscribe to from your tenant's event catalog. The catalog has no documented scope yet (as of 2026-10), so use a development key with * (which scope covers which app). REVENEXX_TENANT is your tenant slug and REVENEXX_API_KEY is a key you create in Cockpit:

Request
curl -s https://api.revenexx.com/v1/events/catalog \
  -H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
  -H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
  | jq -r '.emits[] | select(.app == "locations" and .name == "transfer.received") | .topic'
Response
acme.locations.transfer.received

transfer.received is the event's name. The topic puts your vendor and your app in front of it, <vendor>.<app>.<name>, and that is what a subscriber matches. Subscribe with the exact string the catalog lists, not the bare two-segment name (see Event types).

To see one arrive, register a webhook endpoint in Cockpit under Integration Studio → Webhooks → Outbound, subscribed to that topic (see Webhooks). Then ship a transfer, receive it through the gateway, and read the endpoint's delivery attempts in Cockpit. The two location ids are any two of your locations, from GET /v1/locations:

Receive a transfer
TRANSFER_ID=$(curl -s -X POST https://api.revenexx.com/v1/locations/transfers \
  -H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
  -H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "from_location_id": "<FROM_LOCATION_ID>", "to_location_id": "<TO_LOCATION_ID>", "sku": "WIDGET-1", "quantity": 12 }' | jq -r .id)

curl -s -X POST https://api.revenexx.com/v1/locations/transfers/receive \
  -H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
  -H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
  -H "Content-Type: application/json" \
  -d "{ \"transfer_id\": \"$TRANSFER_ID\" }"

The delivery carries your data object as the body, with the event id and the topic as headers. Call the route a second time and no second delivery appears, which is the idempotent id at work.

Who can listen

Who reacts to the event:

  • A system you run: a Webhook endpoint. Delivery is at least once, so dedupe on the X-Revenexx-Event-Id header.
  • A durable workflow against an external system: an Integration Studio event trigger. See Hand an order to your ERP for one.
  • Another App: an App can declare a listens entry, but only the two lifecycle events, app.installed and app.uninstalled, are verified for App handlers today (as of 2026-09). For a business event, declare the listen on a staging tenant, cause the event, and read the handler's execution log before you rely on it. See Events you receive.

Delivery guarantees and ordering for App handlers are not settled enough to document (as of 2026-09), so build consumers that tolerate a repeat and a missing event, and re-read the entity through the API when you need the truth.

A row event and a business event for the same moment

A transfer row insert and transfer.received can both describe part of the same story. Tell consumers which one to subscribe to, and to take one, never both. A consumer that takes both handles the moment twice. When a business event exists, it is usually the one to recommend: it fires when the fact is complete, where the row event fires on the first write and can arrive before the rest of the rows exist.

Troubleshooting

  • db.events is undefined. You have not declared an app-fired event, the client was not regenerated after you did (revenexx apps generate), or @revenexx/app-sdk is older than 0.8.0.
  • The call throws "not declared by this app". The name in db.events.emit() does not match an events.emits entry with "trigger": "app". A typo fails the same way in a mock and on the platform.
  • Validation refuses the manifest. An app-fired event without a sample, with entity, on or payload on it, or with a name that is not two lowercase segments. See Validate.
  • topicId is refused. It must match [A-Za-z0-9][A-Za-z0-9_-]{0,63}, and the platform does not clean it up. Pass the resource's id, not a path or an email address.
  • A consumer never receives it. Check the subscription is the four-segment topic from the catalog, not transfer.received. Then check the endpoint in Cockpit is switched on.
  • A consumer receives it twice. Either it subscribed to both the row event and the business event, or the platform retried and the consumer does not dedupe on the event id.
  • A retry published a second event. No id was set, or the id changes between attempts (a timestamp, a random value). Build it from what happened.
  • The event is missing after a crash. The route died before it emitted, and the retry took a path that skips the emit. Emit on the retry path too, as step 4 does, and rely on the id to absorb the repeat.
  • db.transfers is undefined. The client was not regenerated after transfers was added to schema.json. Run revenexx apps generate (step 3).
  • A call throws "transfers.get() is not permitted — add the matching access to manifest.permissions". transfers is missing from permissions, or lacks read, create or update (step 1). Fix the list, then run revenexx apps generate again, because the allowed operations are baked into the generated client.

Where to go next

Was this page helpful?