Receive platform events at your own endpoint
When an order is placed on your tenant (the workspace that holds one business's data on the platform), a service you run is called, acts on that order once even though the platform may deliver the event more than once, and catches up after an outage with a job you schedule. This guide builds that consumer for the order.placed event: an HTTPS receiver, a registration in Cockpit, a handler that survives duplicates and reordering, and a reconcile job for the events that never arrive. The same shape works for any other topic.
This is for integrators who run code of their own: an ERP connector, a search index, a notification service. You need:
- a tenant, and a login to Cockpit, the platform's admin interface at app.revenexx.com. Webhooks are registered in its Integration Studio area;
- an HTTPS URL the platform can reach from the internet;
- an API key, created as described in API keys, with the
orders.getandorders.listscopes to read orders in steps 4 and 6, andorders.placeto place the test order in step 5 (see which scope covers which app). Reading the event catalog in step 1 has no documented scope yet, so use a development key with*for that step.
New to the platform? Prerequisites covers Cockpit, your tenant slug, installing an app (here, Orders) and a test tenant.
If you would rather not host anything, Choose an event surface compares a webhook with a workflow that the event starts inside Integration Studio.
export REVENEXX_TENANT="<TENANT_SLUG>" # your tenant slug, the value every call sends as X-Revenexx-Tenant
export REVENEXX_API_KEY="rvxk_..." # the key from API keys
What the platform promises, and what it does not
A webhook is a notification, not a transaction. The design below follows from that.
| The platform does | The platform does not |
|---|---|
| POST each subscribed event to your URL, at least once | Deliver each event exactly once, or in order |
| Retry a failed attempt up to 10 times, the first after 30 seconds and the gap doubling each time, so the last comes about 8.5 hours after the first | Retry after that, or redeliver on request |
Send the event's data as the JSON body, with the id and topic in headers | Let you verify the signature: the signing secret is not shown anywhere |
| Show every attempt in Cockpit | Offer an endpoint API, a test or ping action or custom headers |
So the work is yours: dedupe on the event id, re-read the entity before you act, and reconcile through the API. The X-Revenexx-Signature header is on every delivery, but ignore it until the secret is available; Security has the scheme.
1. Pick the topic
A webhook subscribes by topic, and a topic carries the vendor (the app's publisher, revenexx for the platform's own apps) and the app in front of the event's name: order.placed from the Orders app, which manages a tenant's orders, is the topic revenexx.orders.order.placed. Cockpit lists the topics to pick from, so the work here is choosing the right event, not spelling it.
Run the call below once anyway. It proves your key and tenant slug work before you build on them, and it gives you the exact topic string to compare against in step 5. Your tenant's catalog lists every event your tenant's apps emit under emits, each with its topic, name and app:
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 == "orders" and .name == "order.placed") | .topic'
revenexx.orders.order.placed
A 403, or empty output, means the key lacks access to the event catalog (a development key with * covers it) or the tenant slug is wrong. To act on a new order, order.placed is the right event: it fires once the order has its order number, with positions and totals. order.created fires earlier and for quote requests too. An order that waits for approval fires order.requested first and order.placed once it is approved, so a workflow on order.placed sees it then; a rejected order is cancelled and never fires order.placed. Subscribe to order.requested as well only if you must see orders while they wait. Which order event to subscribe to lists the rest.
2. Register the endpoint
In Cockpit, open Integration Studio → Webhooks → Outbound and create an endpoint:
- Enter the HTTPS URL of your receiver, for example
https://hooks.acme.example/revenexx. Use a long random path segment, because the URL is the only thing that tells a stranger's request from the platform's while the signature cannot be verified. The path must match the route your receiver listens on (step 4). - Pick the topics from the list Cockpit shows. Subscribe to exactly what the handler needs.
- Save, and leave the endpoint switched on.
Cockpit does not reject an http:// URL, so check the scheme yourself. An endpoint that is switched off receives nothing, and switching it back on does not resend what it missed. Webhooks has everything else an endpoint can be set to.
3. Answer inside the delivery contract
Your receiver gets a POST like this:
POST https://hooks.acme.example/revenexx
Content-Type: application/json
X-Revenexx-Event-Id: evt_01jc8m4yq2…
X-Revenexx-Topic: <topic>
X-Revenexx-Timestamp: 2026-10-01T10:00:00Z
X-Revenexx-Signature: v0=9f86d081…
{ "order_id": "7f3a9c1e-…", "number": "ORD-000123", "status": "placed", "currency": "EUR", "grand_total": 245.02, "item_count": 12, "items": [ … ], … }
The body is abbreviated here: it carries the order's fields, not just these.
- The body is the event's
dataobject only. It does not repeat the id or the topic. Those arrive as headers. X-Revenexx-Event-Ididentifies the event and stays the same across every retry of it.X-Revenexx-Timestampis when that attempt was sent, not when the order was placed.- Answer with a
2xxwithin 5 seconds. A status of400or above, a connection error or a timeout is a failed attempt and is retried. That includes every4xx,410 Gonetoo, so an error never makes the platform give up sooner. An endpoint that keeps failing is not switched off: every event gets its full retry schedule. - Answer at the URL you registered. Do not redirect a delivery.
Acknowledge only after you have stored the event, and do the slow part afterwards. Heavy work before the answer is the usual cause of a retry you did not need. Storing first matters just as much the other way round: if you answer 2xx and then lose the event, nothing sends it again.
4. Dedupe, and act on the entity rather than the body
The receiver stores the event durably, answers, and leaves the work to a worker. db.insertIfNew and the worker below are placeholders for your own code: db stands for your database client, and insertIfNew for an insert that does nothing when the row exists. events_seen has a unique key on event_id, so the insert is the dedupe:
import express from "express";
const app = express();
app.use(express.json({ limit: "1mb" })); // the default 100kb refuses a large order, and a refused delivery is retried
app.post("/revenexx", async (req, res) => {
const eventId = req.get("X-Revenexx-Event-Id");
if (!eventId) return res.sendStatus(400); // not a platform delivery
try {
// A repeat of an event already stored inserts nothing.
await db.insertIfNew("events_seen", {
event_id: eventId,
topic: req.get("X-Revenexx-Topic"),
order_id: req.body.order_id,
processed_at: null,
});
} catch (err) {
return res.sendStatus(500); // not stored, so ask for a retry
}
res.sendStatus(204); // stored, so acknowledge; the worker does the rest
});
app.listen(3000);
The receiver speaks plain HTTP on port 3000. Put a reverse proxy or load balancer in front of it that terminates TLS, so the platform reaches it at the HTTPS URL you register.
A worker, which you write and run yourself, picks up the rows with processed_at unset and, for each one, calls processOrder(order_id), which does steps 1 to 3 below; step 4 is the worker's own. processOrder is a function of the order id alone, so the reconcile job in step 6 calls it directly, with no events_seen row. Dedupe on the event id guards only the delivery path; step 3's idempotent write is what keeps the two paths from duplicating anything.
- Re-reads the order. The body is a snapshot from when the event fired, and until you can verify the signature a delivery is only a hint that something happened. Fetch the current state and act on that. Because the read always returns the order as it is now, it also settles ordering.Read the order
curl -s "https://api.revenexx.com/v1/orders/$ORDER_ID" \ -H "X-Revenexx-Tenant: $REVENEXX_TENANT" \ -H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" - Decides on the order's
status, not on the fact that an event arrived. An order ispending,placed,in_fulfillment,completedorcancelled. Act on an order that isplaced,in_fulfillmentorcompleted. Skip apendingone: it is waiting for approval, and nothing may be picked, charged or sent to an ERP until somebody approves it. Treat acancelledone deliberately, by withdrawing what you already wrote for it or by ignoring it if you never did. - Writes idempotently. Key what you write on the order's own id, so a second run overwrites rather than duplicates.
- Marks the row processed once
processOrderhas finished, whether it wrote or skipped. Apendingorder you skip, or acancelledone you ignore, involves no write: mark the row processed anyway and keep the reason in it (for exampleskipped: pending), or it is read again forever. A crash before the write, or before the mark, leaves the row to be picked up again, and the idempotent write makes the repeat harmless.
Never treat an event as a transaction. If losing one would cost money, the reconcile job in step 6 is what makes that safe. The body carries order data, so keep in events_seen only what the worker needs.
5. Check it with a real event
There is no test or ping action, so check the loop with a real order on a test tenant.
- Place an order so that
order.placedfires. You do not need a storefront: callPOST /v1/orders/placeas shown in Orders, with a key that has theorders.placescope. Onlyitemsis required, at least one position with anameorsku:Place a test ordercurl -s -X POST https://api.revenexx.com/v1/orders/place \ -H "X-Revenexx-Tenant: $REVENEXX_TENANT" \ -H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \ -H "Content-Type: application/json" \ -d '{"currency": "EUR", "items": [{"name": "Test item", "quantity": 1, "unit_price": 1}]}'
The answer is201with the order,statusplaced. Keep the order small: if your tenant sets an approval threshold (the Orders app'srequire_approval_above_valuesetting,0and so off by default), an order worth more is createdpendingand firesorder.requestedinstead. - In Cockpit, open the endpoint and read its attempts: when each ran, the topic, whether it succeeded, the HTTP status your receiver returned and which attempt it was. A
204on attempt 1 is the healthy case. The page does not show your response body. - Check that the order reached your system once.
- Replay the delivery yourself by sending the same event id again, and check that nothing was written twice. Take the id and the order id from the
events_seenrow:Replay a deliverycurl -i -X POST https://hooks.acme.example/revenexx \ -H "Content-Type: application/json" \ -H "X-Revenexx-Event-Id: <EVENT_ID_FROM_events_seen>" \ -d '{"order_id": "<ORDER_ID>"}' - Compare the
X-Revenexx-Topicyour receiver stored with the string from step 1. It has not been confirmed that the header carries exactly the catalog's topic string, so if your handler routes on the topic, route on the value you actually received.
This checks delivery and dedupe. The reconcile job needs its own check: run it once by hand and see that it finds the order and does nothing new.
6. Reconcile the gaps
An outage longer than about 8.5 hours loses events for that endpoint, because the retries run out. There is no manual redelivery, from Cockpit or through the API. The fix is to ask the API what you missed.
Run a job on a schedule, and after any outage or handler bug. It walks the orders changed since a watermark you store, with a small overlap, and calls processOrder from step 4 for each. The list returns every order that changed, including pending orders still awaiting approval and cancelled ones, so processOrder's decision on the re-read status is what keeps the job from acting on them; appearing in the list is not a reason to act. It needs the orders.list scope. The orders list has no range filter, so sort newest first by updated_at, which changes whenever anything on the order changes, and page until you reach a row older than the boundary:
since = watermark - overlap
GET /v1/orders?order=updated_at.desc&limit=200&offset=0 # then 200, 400, …
for each order with updated_at >= since: processOrder(order.id) # it decides on status
stop at the first order older than since
watermark = latest updated_at you saw
Before the first run, set the watermark to the time you registered the endpoint: orders placed earlier were never delivered, so load those with a backfill if you need them. Save the new watermark only after the whole walk finished. If a page fails partway, keep the old one and run again; the worker overwrites, so the repeat is harmless. An overlap of a few minutes is enough. Pagination explains order, limit and offset, and Incremental sync explains the watermark and the overlap. Because the worker overwrites with the order's current state, running the reconcile over events you already handled changes nothing.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| No delivery ever arrives | The endpoint is switched off, or it is subscribed to a different event than intended, for example order.created instead of order.placed, or the order is waiting for approval and fired order.requested | Check the endpoint is on and subscribed to the event the handler needs (step 1) |
| The same order is processed twice | The handler does not dedupe, or keys on something other than X-Revenexx-Event-Id | Key on the header (step 4) |
| Attempts show a timeout and then succeed | The handler does slow work before answering | Return 2xx after storing, then process (step 3) |
| Orders are missing after an outage | The retries ran out | Run the reconcile job (step 6) |
| Events seem to arrive in the wrong order | Retries reorder events | Re-read the order and decide on its current status (step 4) |
Where to go next
Run the reconcile job on a schedule before you go live: it is the only part of this guide that notices a loss. Then read:
- Webhooks, Delivery and Security: the reference behind this guide.
- Consuming events: handling events inside an App that runs on the platform, instead of on your own server.
- Event types: how topics are named and versioned.
- Hand an order to the ERP: the same event, handled by a workflow instead of a server of your own.
- Ship, cancel and return: the events a returned order emits.