Receive platform events at your own endpoint

Register a webhook endpoint in Cockpit, answer inside the delivery contract, dedupe on the event id, check the loop with a real order, and close the gaps an outage leaves by re-reading the API.

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.get and orders.list scopes to read orders in steps 4 and 6, and orders.place to 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.

Statements below about behaviour that is unconfirmed, not enforced or not yet documented can change (as of 2026-10).
Environment
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 doesThe platform does not
POST each subscribed event to your URL, at least onceDeliver 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 firstRetry after that, or redeliver on request
Send the event's data as the JSON body, with the id and topic in headersLet you verify the signature: the signing secret is not shown anywhere
Show every attempt in CockpitOffer 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:

Read the topic
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'
Response
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:

  1. 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).
  2. Pick the topics from the list Cockpit shows. Subscribe to exactly what the handler needs.
  3. 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:

A delivery
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 data object only. It does not repeat the id or the topic. Those arrive as headers.
  • X-Revenexx-Event-Id identifies the event and stays the same across every retry of it.
  • X-Revenexx-Timestamp is when that attempt was sent, not when the order was placed.
  • Answer with a 2xx within 5 seconds. A status of 400 or above, a connection error or a timeout is a failed attempt and is retried. That includes every 4xx, 410 Gone too, 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:

Receiver
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.

  1. 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"
    
  2. Decides on the order's status, not on the fact that an event arrived. An order is pending, placed, in_fulfillment, completed or cancelled. Act on an order that is placed, in_fulfillment or completed. Skip a pending one: it is waiting for approval, and nothing may be picked, charged or sent to an ERP until somebody approves it. Treat a cancelled one deliberately, by withdrawing what you already wrote for it or by ignoring it if you never did.
  3. Writes idempotently. Key what you write on the order's own id, so a second run overwrites rather than duplicates.
  4. Marks the row processed once processOrder has finished, whether it wrote or skipped. A pending order you skip, or a cancelled one you ignore, involves no write: mark the row processed anyway and keep the reason in it (for example skipped: 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.

  1. Place an order so that order.placed fires. You do not need a storefront: call POST /v1/orders/place as shown in Orders, with a key that has the orders.place scope. Only items is required, at least one position with a name or sku:
    Place a test order
    curl -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 is 201 with the order, status placed. Keep the order small: if your tenant sets an approval threshold (the Orders app's require_approval_above_value setting, 0 and so off by default), an order worth more is created pending and fires order.requested instead.
  2. 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 204 on attempt 1 is the healthy case. The page does not show your response body.
  3. Check that the order reached your system once.
  4. 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_seen row:
    Replay a delivery
    curl -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>"}'
    
  5. Compare the X-Revenexx-Topic your 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:

Reconcile
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

SymptomCauseFix
No delivery ever arrivesThe 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.requestedCheck the endpoint is on and subscribed to the event the handler needs (step 1)
The same order is processed twiceThe handler does not dedupe, or keys on something other than X-Revenexx-Event-IdKey on the header (step 4)
Attempts show a timeout and then succeedThe handler does slow work before answeringReturn 2xx after storing, then process (step 3)
Orders are missing after an outageThe retries ran outRun the reconcile job (step 6)
Events seem to arrive in the wrong orderRetries reorder eventsRe-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:

Was this page helpful?