React to events and run scheduled work in your App
An App does not only answer requests. The platform can also invoke it when a lifecycle event fires and on a cron schedule, and both arrive as an invocation of the same function that serves your routes. This guide wires up one of each and makes them safe to run twice.
The example continues the Locations App from Build your first App and the transfers entity from Publish a business event from your App: the App seeds its defaults when it is installed, and a nightly job deletes received transfers older than 90 days. This is for developers who already have an App they deploy with the CLI. You need your tenant slug and the typed client from Add a data model. If you have no transfers entity, use any entity of yours that has a status and a timestamp.
What you can rely on today
Read this first, because it decides what the rest of the page is for.
- Two events are verified for App handlers:
app.installedandapp.uninstalled, in the sense that shipped Apps subscribe to them and handle them. Nearly every App that ships on the platform listens to those two, and no other event is verified for an App handler (as of 2026-10). - A business event such as an order being placed is not. A shipped App that declares listens for the order cancellation events says in its own documentation that the platform does not deliver them to it yet. It reads the orders on an hourly schedule instead (as of 2026-10).
- Delivery guarantees for App handlers are not settled. Ordering, retries and whether a handler can run twice are not documented (as of 2026-10), so this guide assumes the worst: an event may arrive twice, late, or not at all.
app.installeddoes not reliably fire on a Marketplace install (as of 2026-10), so it can never be the only way your defaults get seeded.- A schedule is the trigger shipped Apps rely on. The platform runs it from the cron expression you declare, once per installed tenant, but the platform does not promise delivery of every tick. That is why this page ends in a schedule doing the work an event may miss. It is a clock, not a queue, so do not assume a tick the platform missed is replayed.
If you need to react to a business event now, use a webhook or an Integration Studio workflow, and read Choose an event surface to pick one.
1. Branch on the trigger before the router
Neither an event delivery nor a schedule tick carries a path your routes match, so the exported handler has to tell them apart first. The handler from Build your first App already branches on events. Replace the module.exports handler in your src/main.js with the two exports below, which take the data client as an argument. That is what lets step 5 test it with the mock adapter. The code calls purgeOldTransfers and SCHEDULE_PURGE, which step 3 adds to the same file, and ensureDefaults, buildApp, createDb and DEFAULT_LOCATIONS, which you already have. Keep the module.exports.buildApp and module.exports.ensureDefaults lines. Paste the code now and read on: the file does not run until step 3 has added the two missing names.
async function handle(context, db) {
const { req, res } = context;
const headers = req.headers || {};
const jwt = headers['x-revenexx-context'] ?? headers['X-Revenexx-Context'] ?? '';
const path = (String(req.path || '/').replace(/\/+$/, '')) || '/';
// A cron tick carries the schedule name and the tenant context, but no path.
const schedule = headers['x-revenexx-schedule'] ?? '';
if (schedule) {
if (!jwt) return res.json({ error: 'missing tenant identity' }, 503);
if (schedule === SCHEDULE_PURGE) {
return res.json({ schedule, ...(await purgeOldTransfers(db)) });
}
return res.json({ schedule, ignored: true });
}
// An event delivery names the event.
const trigger = headers['x-revenexx-trigger'] ?? '';
const event = headers['x-revenexx-event'] ?? '';
if (trigger === 'event' && jwt) {
if (/(^|\.)installed$/.test(event)) {
return res.json({ event, ...(await ensureDefaults(db)) });
}
if (/(^|\.)uninstalled$/.test(event)) {
return res.json({ event, kept: true }); // nothing destructive, see below
}
return res.json({ event, ignored: true });
}
if (path !== '/' && !jwt) return res.json({ error: 'missing tenant identity' }, 503);
return buildApp(db).handler()(context);
}
module.exports = async (context) => handle(context, createDb({ adapter: 'runtime', context }));
module.exports.handle = handle;
Four details are deliberate.
Match the event with a pattern, not an exact string. The name on the bus carries a vendor and app prefix, and the x-revenexx-event header of a delivery can spell it differently from the app.installed you declared. Matching the suffix survives that. Matching the full string is a guess. See Events you receive.
Answer every delivery with a JSON body, including the ones you ignore. { event, ignored: true } in the execution log is the difference between five minutes of debugging and an afternoon. The same goes for a schedule name you do not know: a handler that falls through to your router answers the platform with a 404.
Refuse a tick that carries no tenant identity. The tenant comes from the x-revenexx-context header, and without it the data client has no tenant to be scoped to. The Inventories App answers 503 here, and a tick that ignored the check would run against no tenant.
app.uninstalled is the one chance you get to do anything when a tenant leaves. Keeping the rows is this guide's choice, so that a merchant who uninstalls and reinstalls still has their data. It is not a platform retention rule, and what the platform itself does with an uninstalled App's data is not documented here. See Publish and install.
2. Make the install handler safe to run twice
The handler above calls ensureDefaults. It is the function from Build your first App and it already has the property that matters: it checks before it writes.
async function ensureDefaults(db) {
const created = [];
const existing = [];
for (const location of DEFAULT_LOCATIONS) {
const [found] = await db.locations.list({ where: { code: location.code }, limit: 1 });
if (found) existing.push(location.code);
else { await db.locations.create(location); created.push(location.code); }
}
return { created, existing };
}
Because the event may arrive twice, and because it may not arrive at all, the same function is also behind a route that you or a first request can call:
// Already in buildApp(db) from Build your first App, shown for reference:
app.post('/locations/defaults', async (c) => c.json(await ensureDefaults(db)));
If you built your capabilities by hand, the route needs a capability entry like any other, or the gateway answers 404. Build your first App declares it.
Both paths may land, and the second one finds everything in place and creates nothing. That is the whole design: the event is a convenience, the route is the guarantee. After a Marketplace install, call the route once, as described in Verifying live.
The same three assumptions apply to any handler you write for an event:
- It may arrive twice. Check before you write, or write in a way that a repeat cannot double.
- It may arrive out of order. State you derive from a sequence needs a timestamp or version in the payload, not an assumption about arrival order.
- It may not arrive. If your App is wrong without the reaction, the reaction does not belong only in an event handler. Put it in a route, and add a schedule that catches what the event missed (step 3).
3. Declare a schedule and write a job that survives a repeat
Declare the schedule in manifest.json. The cron is five fields, evaluated in UTC. 0 3 * * * is 03:00 UTC, which is 04:00 in Berlin in winter and 05:00 in summer. Add the schedules entry, and change the transfers line in permissions to include delete:
"schedules": [
{ "name": "purge-old-transfers", "cron": "0 3 * * *" }
]
{ "entity": "transfers", "access": ["read", "create", "update", "delete"] }
The job deletes, so transfers needs the delete access above, and the other entries in permissions stay as they are. The generated client refuses an operation your manifest does not grant, by throwing a permission error with status 403 before the request is sent, so regenerate it after the change:
revenexx apps generate
Now the job. Four properties make it safe to run again after a failure, and each is visible in the code:
const SCHEDULE_PURGE = 'purge-old-transfers';
const PURGE_AFTER_DAYS = 90;
const PURGE_BATCH = 200;
async function purgeOldTransfers(db, now = new Date()) {
const cutoff = new Date(now.getTime() - PURGE_AFTER_DAYS * 86400000).toISOString();
const rows = await db.transfers.list({
where: { status: 'received', updated_at: { op: 'lt', value: cutoff } },
order: 'updated_at.asc',
limit: PURGE_BATCH,
});
let deleted = 0;
for (const row of rows) { await db.transfers.delete(row.id); deleted += 1; }
return { scanned: rows.length, deleted, more: rows.length === PURGE_BATCH };
}
- It finds its work by state, not by memory. The filter selects rows still in the state the job moves them out of. A run that dies halfway leaves the rest selectable, and the next run picks them up. A job that appends without checking does not have this property.
- It is bounded. One tick handles at most 200 rows and reports
more: trueif there may be others. The next tick continues from what is left, but a daily cron clears at most 200 rows a day in total: raisePURGE_BATCHor run the schedule more often if a tenant can accrue more. A job that tries to finish everything in one invocation is the one that times out on the tenant with the most data. - It fails loudly. A delete that throws is not caught, so the invocation ends in an error rather than a report, and the next tick selects the same rows again. Do not catch the error and carry on: a job that reports success while deleting nothing is worse than one that fails.
- It returns what happened. The return value lands in the execution record you read in App Studio.
{ scanned: 200, deleted: 200, more: true }is worth having.{}is not.
{ op, value }, not { lt: value }. The data client takes an operator as an object with an op and a value. An operator written as { lt: cutoff } is not read as a comparison. See The typed client.Whether two ticks of one schedule can ever overlap, whether a failed tick is retried, and whether a tick the platform missed is replayed are not documented (as of 2026-10). Design for none of them: the job above works from the data each time, so it does not need a replay, and a second run simply finds less to do. How long one tick may run before the platform stops it is also not documented (as of 2026-10), which is another reason for the batch limit.
One tick is one tenant
The platform fires the schedule for each installed tenant, with that tenant's context. Expect one tick per tenant per cron time, and see the paragraph above for what is not documented about overlap and retries. You do not enumerate tenants and you cannot: the data client in a tick is already scoped to one. So the job's cost is your largest tenant's work times the number of tenants, which is the reason for the batch limit. A schedule cannot do cross-tenant work, for the same reason a route cannot.
revenexx create app generated builds CRUD for every permitted entity, granting delete on transfers also generates a DELETE capability for a route that does not exist. Keep transfers out of that generated set with the edit described in Publish a business event from your App. If your manifest declares a row event on transfers with delete among its on operations, the purge publishes that event for every deleted row, up to 200 per tick, whether or not anything subscribes. You may have declared none: the transfer events in the sibling guide are fired by the App itself. Check your manifest before you rely on either. And the batch limit is per tick, so a faster cron deletes proportionally more per day.Give the job a route as well
Put the same function behind a route. You get a way to run it on demand, to test it live without waiting for 03:00 UTC, and a way for an operator to start a pass by hand. Both Inventories schedules work this way.
// Inside buildApp(db):
app.post('/locations/transfers/purge', async (c) => c.json(await purgeOldTransfers(db)));
Declare it as a capability the way Publish a business event from your App declares its routes (a def('locations.transfers.purge', ...) entry in scripts/capabilities.js), then run npm run capabilities. If your scripts/capabilities.js is the one revenexx create app generated, add the entry by hand to manifest.capabilities.json instead, as the same guide describes. Without a capability the gateway answers 404.
4. Reconcile what an event may have missed
Because delivery is not settled, a schedule is the cheap insurance for any reaction you cannot afford to lose. The Procurement App does this: it declares listens for order cancellations, which are not delivered to it yet, and an hourly reconcile schedule finds what those events would have told it by reading the orders.
The pattern is a function that derives its work from the data and is safe to call at any time. If your App must stay consistent with something another App does, write that function once and give it three triggers:
| Trigger | Role |
|---|---|
| A schedule | The guarantee. It runs whether or not any event arrived |
| A route | Manual and test runs |
| An event listen, if one is verified for your case | A speed-up. Never the only path |
5. Test both with the mock adapter
The mock adapter is an in-memory store, so the handler runs with no infrastructure. Build a context the way the platform does, with headers, and call handle from step 1 with a mock client:
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { createDb } = require('../src/db.generated');
const { handle } = require('../src/main');
function run(db, headers) {
let out;
const context = {
req: { method: 'POST', path: '/', body: null, headers: { 'x-revenexx-context': 'tenant-jwt', ...headers } },
res: { json: (body, code = 200) => (out = { body, code }) },
log: () => {}, error: () => {},
};
return handle(context, db).then(() => out);
}
const install = { 'x-revenexx-trigger': 'event', 'x-revenexx-event': 'apps.locations.installed' };
const tick = { 'x-revenexx-schedule': 'purge-old-transfers' };
test('the install event seeds once, even when delivered twice', async () => {
const db = createDb({ adapter: 'mock', seed: { locations: [] } });
assert.deepEqual((await run(db, install)).body.created, ['main']);
assert.deepEqual((await run(db, install)).body.created, []);
});
test('an event the App does not act on is acknowledged and changes nothing', async () => {
const db = createDb({ adapter: 'mock', seed: { locations: [] } });
const r = await run(db, { 'x-revenexx-trigger': 'event', 'x-revenexx-event': 'orders.order.placed' });
assert.equal(r.body.ignored, true);
assert.equal((await db.locations.list({})).length, 0);
});
test('a tick without tenant identity is refused, an unknown schedule is ignored', async () => {
const db = createDb({ adapter: 'mock', seed: { transfers: [] } });
assert.equal((await run(db, { ...tick, 'x-revenexx-context': '' })).code, 503);
assert.equal((await run(db, { 'x-revenexx-schedule': 'nope' })).body.ignored, true);
});
test('the purge deletes only old received transfers, and a second tick finds nothing', async () => {
const old = new Date(Date.now() - 100 * 86400000).toISOString();
const db = createDb({
adapter: 'mock',
seed: {
transfers: [
{ id: 't-old', status: 'received', updated_at: old },
{ id: 't-shipped', status: 'shipped', updated_at: old },
{ id: 't-new', status: 'received', updated_at: new Date().toISOString() },
],
},
});
assert.equal((await run(db, tick)).body.deleted, 1);
assert.deepEqual((await db.transfers.list({})).map((t) => t.id).sort(), ['t-new', 't-shipped']);
assert.equal((await run(db, tick)).body.deleted, 0);
});
npm test
A mock proves your branching and your filter. It does not prove the platform delivered anything, and it does not enforce constraints or foreign keys. See Adapters for the full list.
6. Deploy and watch it run
Deploy the App. Then check both triggers where you can see them, in the Cockpit under App Studio, on the Executions screen. Each invocation is a record with its trigger, its duration, its status and the log lines you wrote (Operating).
- Run the job on demand, through the route from step 3, with a development key (which scope covers which app):Request
curl -s -X POST https://api.revenexx.com/v1/locations/transfers/purge \ -H "X-Revenexx-Tenant: $REVENEXX_TENANT" \ -H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \ -H "Content-Type: application/json" \ -d '{}'Response{ "scanned": 0, "deleted": 0, "more": false }
That is the answer on a tenant with nothing older than 90 days to purge. The delete itself is proven by the test in step 5. Run the live call against a tenant that holds old received transfers only if you mean to delete them. - Wait for the first tick. A schedule starts firing once the version that declares it is installed. A tick shows up in Executions with the schedule name
purge-old-transfers. How soon after install the first tick runs is not documented (as of 2026-10). For a quicker check while you develop, change the cron to*/15 * * * *, deploy, look for the record, and change it back. - Confirm the install event on a staging tenant, not on your production one. Install the App and look for an
installedrecord. A missing record is not a bug in your code, because the event does not reliably fire on a Marketplace install. Call/locations/defaultsas in step 2.
Nothing on the platform alerts you when a schedule stops firing, and there is no alerting to configure (as of 2026-10). The work just does not happen, so open Executions after each release.
Troubleshooting
- The tick runs and your router answers
404. The handler fell through. Branch onx-revenexx-schedulebefore the router, and answer unknown names with{ schedule, ignored: true }. - The tick answers
503. The context header is missing. In a test, setx-revenexx-context. The503comes from your own handler (the check in step 1), not from the platform. - The job runs and deletes nothing. Check the filter shape (
{ op: 'lt', value }), and the cutoff in UTC. In a mock, check that your seededupdated_atis an ISO string, because the mock compares strings. - The tick fails, or the purge route answers
403because the generated client threw, with "transfers.delete() is not permitted". Thedeletegrant is missing frompermissions, or you did not runrevenexx apps generateafter adding it. - The job runs at the wrong local hour. Cron is UTC. If the exact local hour matters, run the job more often and have the handler decide, using the tenant's own configuration.
- No schedule record appears. The version that declares it is not the installed one. Check Deployments in App Studio, then Anatomy, which shows the schedules as the platform applied them.
- The install handler never ran on a merchant's tenant. Expected on a Marketplace install. The route and a first-use check are the path that works.
Where to go next
- Events you receive — the headers, the two verified events and what is not settled.
- Scheduled work — the cron rules, the
datapayload andwarmup. - Choose an event surface — when an App handler is the wrong tool.
- Publish a business event from your App — the other direction.
- Testing — the harness for the exported handler.