From 8950d9660febd92e9041f38274b9d42f523e4698 Mon Sep 17 00:00:00 2001 From: Karl Rankla Date: Fri, 9 Oct 2026 14:32:17 +0300 Subject: [PATCH 01/19] docs(integration-toolkit): add the interactive registration recipe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The only guidance we gave an integration partner for portal self-registration was implicit: push your data while the portal is still polling entity search, and hope. That poll cannot tell "not yet" from "never", so a registration that fails produces a generic timeout and a support ticket nobody can investigate afterwards — not the partner, not epilot. This documents the correlation-id contract that replaces it. The portal mints an id and hands it to the extension hook, the partner returns it and sends it on every push for that customer, and the portal waits on that correlation instead of searching blind. The recipe states the minimum ask in two lines before anything else, then the optional interactive lane and completion flag, the request and response contract for the ingest call and both status endpoints, the two requirement sets of the correlation wait and when to use which, the `interactive` use case option, what the rate limit actually does to an org that exceeds it, the event-stream variant, and the two things not to do. The reference updates slot it into the existing structure rather than beside it: a quick-reference row and a use case section in Use Cases, the component table and the inbound direction in the Overview, a new inbound use case options table in Configuration, and a pointer from the inbound Getting Started where the asynchronous response is first explained. Documents the Interactive Inbound Lane RFC (integration-toolkit-api !278). The API described here ships in the toolkit MR stack, so this merges after it. --- .../integration-toolkit/configuration.md | 21 + .../inbound/getting-started.md | 6 + .../interactive-registration.md | 526 ++++++++++++++++++ .../integration-toolkit/overview.md | 3 + .../integration-toolkit/use-cases.md | 35 ++ 5 files changed, 591 insertions(+) create mode 100644 docs/integrations/integration-toolkit/interactive-registration.md diff --git a/docs/integrations/integration-toolkit/configuration.md b/docs/integrations/integration-toolkit/configuration.md index bb30134..d8ecd33 100644 --- a/docs/integrations/integration-toolkit/configuration.md +++ b/docs/integrations/integration-toolkit/configuration.md @@ -129,6 +129,27 @@ curl -X POST 'https://integration-toolkit.sls.epilot.io/v1/integrations/{integra - `secure_proxy` — Route requests through epilot's secure proxy for static IP or VPN access. See [Secure Proxy Use Cases](#secure-proxy-use-cases). ::: +### Inbound Use Case Options + +Beyond the mapping itself, an inbound use case's `configuration` accepts the following options. + +| Option | Values | Default | Description | +|--------|--------|---------|-------------| +| `direct` | boolean | `false` | Skip the mapping engine and accept entity-shaped payloads. See [Direct Mode](./inbound/direct-mode.md). | +| `interactive` | `disabled`, `on_request`, `always` | `disabled` | Whether events of this use case may run on the interactive lane. See [Interactive Registration](./interactive-registration.md#enabling-it-on-the-use-case). | + +```json title="Allow the request to decide" +{ + "entities": [ "..." ], + "interactive": "on_request" +} +``` + +With `on_request` — the normal setting — one mapping serves both the nightly batch and the +registration push, and the inbound request decides which it is by sending `interactive: true`. With +`disabled`, a request that asks for interactive is processed as bulk and told so per event; nothing +is rejected. With `always`, every event of the use case is interactive. + ### Enabling/Disabling a Use Case ```bash diff --git a/docs/integrations/integration-toolkit/inbound/getting-started.md b/docs/integrations/integration-toolkit/inbound/getting-started.md index efa8bef..13377be 100644 --- a/docs/integrations/integration-toolkit/inbound/getting-started.md +++ b/docs/integrations/integration-toolkit/inbound/getting-started.md @@ -121,9 +121,15 @@ Events flow through the following pipeline: ![Processing Flow](/img/integrations/toolkit-processing.svg) +Ingestion is asynchronous: the response means "accepted and queued", not "written". When an end +customer is waiting for the push to land — portal self-registration, adding a contract — send the +portal's correlation id with the request and let the portal wait on it. See +[Interactive Registration](../interactive-registration.md). + ## Next Steps - [Entity Mapping](./mapping.md) - Learn about field mappings and transformations - [Unique Identifiers](./unique-identifiers.md) - Configure entity lookup strategies - [Relations](./relations.md) - Link entities together +- [Interactive Registration](../interactive-registration.md) - Push data an end customer is waiting for - [Examples](./examples.md) - See complete integration examples diff --git a/docs/integrations/integration-toolkit/interactive-registration.md b/docs/integrations/integration-toolkit/interactive-registration.md new file mode 100644 index 0000000..e90a01a --- /dev/null +++ b/docs/integrations/integration-toolkit/interactive-registration.md @@ -0,0 +1,526 @@ +--- +sidebar_position: 5 +title: Interactive Registration +description: Push ERP data with a correlation id and let the portal wait for exactly that data, instead of polling entity search +slug: /integrations/integration-toolkit/interactive-registration +--- + +# Interactive Registration + +Interactive registration is an opt-in mode of the [inbound API](./inbound/getting-started.md) for the +flows where a person is sitting in front of a screen waiting: self-registration in the +[End Customer Portal](/docs/portals/customer-portal), adding a contract or a customer account to an +existing login, and public journeys that identify a customer. + +Your push stays asynchronous. What changes is that every push can carry a **correlation id** the +portal handed you, that the correlation gets its own rate-limited processing lane, and that the +portal can **wait on that correlation** instead of guessing from entity search whether your data has +arrived yet. + +:::info Availability +Interactive registration is rolling out (October 2026). If the request fields on this page are +rejected by your organization's integration, contact your epilot representative. +::: + +## The problem it replaces + +Until now, the contract between the portal and your middleware was implicit, and it was not a good +one. + +The portal calls your [extension hook](/docs/apps/components/portal-extension) — +`registrationIdentifiersCheck` or `contractIdentification` — inside the end customer's own request. +You validate the identifiers in the ERP and answer. Unless your hook returns an epilot contact id, +which almost no middleware can, the portal then falls back to **polling entity search** every 500 ms +until shortly before its own timeout, looking for a contact and a contract that match what the user +typed. + +That poll is blind in three ways: + +- **It cannot tell "not yet" from "never".** An empty search result means the same thing whether your + push is still in the queue, whether it was sent but carried no contract, or whether it was never + sent at all. The portal has no choice but to answer `TIMEOUT`, and the end customer sees a generic + error. +- **It is a race against a shared queue.** Your registration push is ordered behind whatever bulk + traffic the platform is processing at that moment. The advice has effectively been "push while the + portal is still polling, and hope". On a calm queue that works; on a busy one the same code path + fails for reasons neither side can see. +- **It cannot be investigated afterwards.** When a registration times out, nobody — not you, not + epilot support — can answer the only question that matters: *did the data land, and if so, what was + missing?* Support tickets of this class exist because the answer is not recorded anywhere. + +Interactive registration removes all three. The portal mints an id, you echo it, and the portal asks +epilot a precise question: *has a contact and a contract landed for correlation `C`?* The answer is +either the entities, or `complete` with a list of what you did not send — which is a message a +support agent can act on. + +## How it works + +```mermaid +sequenceDiagram + participant B as Browser + participant P as epilot portal + participant M as Your middleware + participant T as Integration Toolkit + participant E as epilot entities + + B->>P: Register / add contract (identifiers) + Note over P: Mints correlation id C + P->>M: Extension hook (identifiers, C) + M->>M: Validate in the ERP + M-->>P: 200 { correlation_id: C } + M->>T: POST /v3/erp/updates/events
{ correlation_id: C, interactive: true } + P->>T: GET /v3/erp/updates/events?correlation_id=C
&wait=25&wait_for_entities=contact,contract + T->>E: Mapping, entity writes (interactive lane) + M->>T: POST /v3/erp/updates/events
{ correlation_id: C, interactive: true, correlation_complete: true } + T-->>P: 200 { completion: "requirements_met", entities: [...] } + P->>E: Link the returned contact, verify the contract + P-->>B: Logged in, contracts visible +``` + +You may push everything in one request or in as many as the ERP produces. The portal is not waiting +for all of your events — it is waiting for a contact and a contract. The last request carries +`correlation_complete: true` so that, if what it is waiting for never comes, the portal learns that +immediately instead of at the end of its budget. + +## What you have to do + +The required ask is two lines of code in the flow you already have: + +:::tip The minimum +1. **Pass the correlation id we give you** on every push you make for that customer, as + `correlation_id` on the inbound request. +2. **Return it from the hook**, as `correlation_id` in the hook's JSON response body. +::: + +That is enough for the portal to stop polling entity search and wait on your data instead. Everything +below improves the experience and is a second step: + +| Optional | What it buys you | +|---|---| +| `interactive: true` on the request | Your registration pushes run on a dedicated lane, ahead of bulk traffic, instead of queueing behind the nightly import | +| `correlation_complete: true` on the last request | The portal fails fast and precisely ("account data arrived, no contract") instead of waiting out its budget | + +Nothing breaks if you ignore any of it. A request without `correlation_id` behaves exactly as it does +today. + +:::note Who turns interactive on +The correlation is only tracked for events that are interactive. That is a setting on the epilot +side, not a second thing to build: a use case set to `interactive: "always"` makes every one of its +events interactive without your middleware changing a line. Sending `interactive: true` yourself is +the better choice when the same use case also carries the nightly batch, because only the request +knows which is which — see [Enabling it on the use case](#enabling-it-on-the-use-case). +::: + +## Enabling it on the use case + +Interactive processing is allowed per **inbound use case**, with the `interactive` option on the use +case `configuration`. The common case is one mapping serving both the nightly batch and the +registration push, so the use case grants permission and the request decides. + +```json title="Inbound use case configuration" +{ + "entities": [ "..." ], + "interactive": "on_request" +} +``` + +| Value | Effect on a request that asks for interactive | +|---|---| +| `disabled` (default) | The events are processed as bulk. Each result says `"interactive": false` with the message `use case does not allow interactive processing`, and an `INTERACTIVE_NOT_ALLOWED` info event appears in monitoring. Nothing is rejected. | +| `on_request` | The request's `interactive: true` selects the interactive lane. This is the normal setting. | +| `always` | Every event of this use case is interactive, whether the request asks or not. Use it for a use case that exists only to serve registrations. The per-organization rate limit still applies. | + +The option is also exposed in the Integration Hub on the inbound use case. See +[Use Case Configuration](./configuration.md#use-case-configuration) for the surrounding contract. + +## Sending interactive events + +Two new fields on `POST /v3/erp/updates/events`, both at request level. `interactive` may also be set +per event, and the event wins — the same precedence `correlation_id` and `group_id` already follow. + +```bash title="First push — customer account" +curl -X POST 'https://integration-toolkit.sls.epilot.io/v3/erp/updates/events' \ + -H 'Authorization: Bearer ' \ + -H 'Content-Type: application/json' \ + -d '{ + "integration_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "correlation_id": "018f8e9b-5a1b-7c4e-9b2a-4f0f6c1d7a21", + "interactive": true, + "events": [ + { + "use_case_slug": "customer_account", + "timestamp": "2026-10-09T09:05:28Z", + "format": "json", + "payload": { + "customerNumber": "4711", + "firstName": "Anna", + "lastName": "Schmitz", + "email": "anna.schmitz@example.de", + "accountNumber": "200184" + }, + "deduplication_id": "reg-4711-20261009090528" + } + ] + }' +``` + +| Field | Type | Meaning | +|---|---|---| +| `correlation_id` | string | The id the portal handed to your hook. Already part of the API — what is new is that it is now the key the portal waits on, so it must be the portal's id and not one you mint yourself. | +| `interactive` | boolean | Request the interactive lane for this request's events. | +| `correlation_complete` | boolean | No further events will follow for this correlation id. Send it on your **last** request only. | + +The response gains the correlation id and, per result, which lane the event actually went to: + +```json title="Response" +{ + "correlation_id": "018f8e9b-5a1b-7c4e-9b2a-4f0f6c1d7a21", + "results": [ + { + "event_id": "0199c3f2-8a41-7d0e-b6c7-2f1a9d3e4b55", + "status": "success", + "interactive": true, + "lane": "interactive" + } + ] +} +``` + +`correlation_id` is echoed when you sent one and generated when you did not — so a partner that only +wants the lane, without a portal in the loop, still gets an id it can query later. `lane` is one of +`interactive`, `priority`, `default` or `deferred`, and it tells you what actually happened rather +than what you asked for; see [Rate limits](#rate-limits). + +Your last request closes the correlation: + +```bash title="Last push — contract, and close the correlation" +curl -X POST 'https://integration-toolkit.sls.epilot.io/v3/erp/updates/events' \ + -H 'Authorization: Bearer ' \ + -H 'Content-Type: application/json' \ + -d '{ + "integration_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "correlation_id": "018f8e9b-5a1b-7c4e-9b2a-4f0f6c1d7a21", + "interactive": true, + "correlation_complete": true, + "events": [ + { + "use_case_slug": "contract", + "timestamp": "2026-10-09T09:05:31Z", + "format": "json", + "payload": { + "contractNumber": "200184", + "customerNumber": "4711", + "startDate": "2026-11-01", + "meterNumber": "1ESY1161000123" + }, + "deduplication_id": "reg-4711-contract-20261009090531" + } + ] + }' +``` + +Closing a correlation twice is a no-op. A request with `correlation_complete: true` and no events of +its own is valid only if the correlation already exists — you can close a correlation you opened +earlier, but you cannot close one that was never opened. + +An event that arrives **after** the correlation was closed is still processed normally. It is counted +as a late event and raises a `CORRELATION_LATE_EVENT` warning in monitoring, because by then the +portal has usually already answered the end customer. + +## Checking what landed + +Two read endpoints, both authorized with the same organization-scoped token you use for ingest. No +cross-organization read is possible: the lookup is partitioned by the token's organization. + +### One event + +`GET /v3/erp/updates/events/{event_id}` returns the processing record for a single event. It never +waits. + +```json title="GET /v3/erp/updates/events/0199c3f2-8a41-7d0e-b6c7-2f1a9d3e4b55" +{ + "event_id": "0199c3f2-8a41-7d0e-b6c7-2f1a9d3e4b55", + "correlation_id": "018f8e9b-5a1b-7c4e-9b2a-4f0f6c1d7a21", + "status": "done", + "lane": "interactive", + "use_case_id": "58cf8956-e737-4b4c-a683-3528be3eedb8", + "use_case_slug": "customer_account", + "ingested_at": "2026-10-09T09:05:28.412Z", + "stage1_at": "2026-10-09T09:05:29.004Z", + "done_at": "2026-10-09T09:05:31.277Z", + "children_total": 5, + "children_done": 5, + "entities": [ + { "slug": "contact", "_id": "9acc9f54-5c0a-4b3e-8e6f-1d2c3b4a5968", "operation": "created" }, + { "slug": "billing_account", "_id": "3f1b7e20-9c84-4a55-bd21-6e0d8a7c4f13", "operation": "created" } + ], + "errors": [] +} +``` + +`status` is `accepted`, `processing`, `done`, `partial` or `error`. `children_total` and +`children_done` count the entity writes the event fanned out into, including follow-up writes such as +stub creates, so `children_done == children_total` on a `done` record means the whole tree is +finished — not just the first write. + +The three timestamps are the three steps of the pipeline: `ingested_at` when the event was accepted, +`stage1_at` when the mapping had produced its entity updates, `done_at` when the last of those writes +finished. + +`ingested_at` to `done_at` is the first end-to-end latency figure the inbound pipeline has ever +exposed. Use it when you want to know whether a slow registration was slow on your side or on ours. + +### A correlation, with a wait + +`GET /v3/erp/updates/events?correlation_id={id}` returns the summary across every event of one +correlation. With `wait` it long-polls, and with a requirement set it returns as soon as what the +caller needs has arrived. + +| Parameter | Meaning | +|---|---| +| `correlation_id` | Required. The correlation to look at. | +| `wait` | Seconds to wait, `0` to `25`. Omit or `0` for an immediate answer. The ceiling is 25 s because the HTTP API cuts off at 30 s. | +| `wait_for_entities` | Comma-separated entity requirements, each `slug` or `slug[key=value]`. | +| `wait_for_use_cases` | Comma-separated inbound **use case slugs**. | + +Both requirement sets are optional, and both are only valid together with `correlation_id` — sending +one without it returns 400. When both are given, **both** must be satisfied before the call answers +`requirements_met`. + +```bash title="Wait for a contact and a contract" +curl -G 'https://integration-toolkit.sls.epilot.io/v3/erp/updates/events' \ + -H 'Authorization: Bearer ' \ + --data-urlencode 'correlation_id=018f8e9b-5a1b-7c4e-9b2a-4f0f6c1d7a21' \ + --data-urlencode 'wait=25' \ + --data-urlencode 'wait_for_entities=contact[customer_number=4711],contract' \ + --data-urlencode 'wait_for_use_cases=customer_account,contract' +``` + +```json title="Response" +{ + "correlation_id": "018f8e9b-5a1b-7c4e-9b2a-4f0f6c1d7a21", + "completion": "requirements_met", + "closed": true, + "closed_at": "2026-10-09T09:05:31.902Z", + "events_received": 7, + "events_done": 7, + "late_events": 0, + "entities": [ + { + "slug": "contact", + "_id": "9acc9f54-5c0a-4b3e-8e6f-1d2c3b4a5968", + "operation": "created", + "unique_ids": { + "customer_number": "4711", + "identity_id": "9a9ce1d1-0f7b-4c6a-9d58-7b2e4a1c6f30" + } + }, + { + "slug": "contract", + "_id": "c0d9a1b8-4e37-4f52-8a90-5b6c7d8e9f01", + "operation": "created", + "unique_ids": { + "external_id": "V4ey7pQ1", + "contract_number": "200184" + } + } + ], + "use_cases": [ + { "use_case_slug": "customer_account", "received": 4, "done": 4, "error": 0 }, + { "use_case_slug": "contract", "received": 3, "done": 3, "error": 0 } + ], + "errors": [] +} +``` + +#### What "arrived" means + +A requirement is satisfied when the event that carries it reached **`done`** — the entity writes +finished — not when the event was received. Anything looser would be a lie: a caller told "your data +is ready" while the write is still queued would read an entity that does not exist yet, which is the +failure mode this whole page exists to remove. + +An event that ends in a non-retryable error resolves its requirement too, into +`completion: "error"`. A wait never hangs on a failed event waiting for something that can no longer +come. + +#### Entity requirements, with selectors on `unique_ids` + +Every entity in the response carries the `unique_ids` the toolkit resolved for its lookup — the use +case's unique-id set, plus the identity id where the mapping writes one. That is what lets a caller +pick the right entity when a bundle contains several of a type, which is common: one ERP message can +produce a business-partner contact and a profile contact, or a customer contact and a separate +billing contact. + +- `contact` — satisfied by **any** processed contact under the correlation. Only safe when the bundle + cannot contain more than one. +- `contact[identity_id=9a9ce1d1-0f7b-4c6a-9d58-7b2e4a1c6f30]` — satisfied only by a contact whose + `unique_ids` contain that pair. +- `contract[contract_number=200184]` — the same, keyed on the contract number the end customer typed. + +Percent-encode the value if it can contain a comma, a bracket or an equals sign; the `curl -G +--data-urlencode` form above does that for you. + +#### Use case requirements + +`wait_for_use_cases` takes **inbound use case slugs** — the same `use_case_slug` you already send on +the ingest event. That is deliberate: the v3 events endpoint recommends `use_case_slug` over +`event_name` for routing precisely because slugs are portable across environments, and a use case +slug is unique within its integration and immutable once set. `event_name` is partner vocabulary and +a fallback; it is not a stable key for a requirement. + +A requirement is met when **at least one event of that use case slug is done**. There are no +selectors on use case slugs in this version — several events of one use case within a correlation are +normal and all of them count towards the same requirement. The response's `use_cases` array gives the +per-use-case summary (`received`, `done`, `error`) so a caller can see which side of the contract is +still open. + +#### Which requirement set to use + +Use **entity requirements** when you need the entity itself: they express the user-visible goal — *a +contact and a contract exist* — and the response hands you the ids to link and verify. This is what +the portal uses for registration and contract addition. + +Use **use case requirements** when you need to know that the partner sent the kind of data it +promised, rather than that a specific entity appeared. They are the right key when: + +- the **entity set varies per push** — one customer brings two contracts and a meter, the next brings + one contract and nothing else, and no fixed list of slugs describes both; +- an expected use case **legitimately produces no entity at all** — the push results in a file, a + relation on an existing entity, or a no-op update. An entity requirement could never be met there, + so the wait would burn its whole budget and answer `timeout` on a correlation that in fact + completed successfully. + +Combining them is normal: `wait_for_entities=contact,contract` says what the portal needs, +`wait_for_use_cases=customer_account,contract` says the partner finished sending it. + +#### Values of `completion` + +| Value | Meaning | +|---|---| +| `requirements_met` | Every requirement given — entity requirements, use case requirements, or both — is satisfied. The wait returns the moment this becomes true. | +| `complete` | The correlation is closed and every event received for it is done. This is the hard stop for a caller that wants everything — and it also ends a requirement wait that can no longer be satisfied, which is how the portal learns that you sent an account but no contract. | +| `timeout` | The wait budget elapsed. The body still lists everything that landed so far; call again to continue waiting. | +| `error` | At least one event of the correlation ended in a non-retryable error. `errors[]` carries the [monitoring codes](./monitoring/codes.md). | + +There is no quiet-window heuristic: with no requirements and no close, a wait runs to its budget and +then returns `timeout`. + +A correlation id that epilot has never seen — or one that belongs to non-interactive traffic — comes +back as `events_received: 0, closed: false`. The caller cannot tell "not yet" from "never", which is +exactly the ambiguity `correlation_complete` exists to remove. + +:::note Concurrent waits are capped +Long polls are limited per organization and globally. Past the cap the call answers **429** with a +`Retry-After` header instead of waiting; callers fall back to a non-waiting call and retry. The +portal already does this. +::: + +## Rate limits + +The interactive lane is only worth having if it stays fast, and it stays fast only if no single +organization can fill it. Every organization therefore has a token budget for interactive events, +charged one token per event at ingest. The default is a sustained **120 interactive events per 5 +minutes with a burst of 40**, raised by agreement for organizations with higher registration volume. + +When the budget is exhausted, nothing is rejected and nothing is lost: + +- The events are accepted and processed on the organization's **normal lane**. The per-result `lane` + field names it (`priority`, `default` or `deferred`). +- The **processing records are still written**, so `correlation_complete`, the wait and the status + endpoints keep working exactly as before — the answer simply takes longer to arrive, and the + portal's wait may run to `timeout` and retry. +- An `INTERACTIVE_RATE_LIMITED` **warning** appears in monitoring for that request. + +A steady trickle of `INTERACTIVE_RATE_LIMITED` means interactive is being used for traffic that is +not interactive — a bulk sync that sets the flag on every request, usually. Bulk pushes should not +carry `interactive: true`; they are not slower for it, but they consume the budget that the +registrations of the same organization need. + +## Event-stream partners + +If your ERP backend publishes change events to Kafka or a comparable log rather than calling the +inbound API itself, the contract is the same. Only the carrier differs, and epilot's bridge sets the +inbound request fields for you. + +:::tip The minimum +1. **Return an acknowledge id from the synchronous endpoint** the portal hook calls — the assignment + or registration endpoint that already exists. That id is the correlation id. +2. **Echo it as a CloudEvents extension on every event the operation produces**, as the + `ce_correlationid` header or `correlationId` in the event envelope's extensions. +::: + +That is all the wait needs. The bridge maps the extension to `correlation_id` and sets +`interactive: true` on any batch that carries one, so the portal's requirements are satisfied the +moment the matching events finish processing — entity by entity, or use case by use case. + +Two improvements on top: + +- **Publish a terminal event last, on the same message key** as the data events — a name such as + `AssignmentCompleted`, with no business payload. The bridge translates it into + `correlation_complete: true` on the inbound request that carries it and does not forward it as + data, so it needs no mapping and no use case. It is not required for the wait to finish early; what + it buys is the opposite case — when the operation produced less than the portal needs, the wait + ends immediately as `complete` with the missing requirement named, instead of running out its + budget and answering `timeout`. +- **Emit the full set of events the operation produced.** An assignment that changes a business + partner but publishes no contract or meter event still fails for the end customer. The difference + is that it now fails in seconds with the missing requirement named, rather than as an unexplained + timeout nobody can investigate. + +:::caution Ordering +Per-key FIFO ordering is what makes a terminal event meaningful: it guarantees the data events for +that key were forwarded before it. If your stream splits one business operation across several keys — +contract on one, meter on another — the terminal event no longer orders behind them, and the bridge +has to hold the completion flag until the earlier offsets are through. Keep one operation on one key. +::: + +## What not to do + +**Do not try to make the ingest call synchronous.** `wait` on `POST /v3/erp/updates/events` is not +supported and returns **400**. Ingest validates, enqueues and answers; it never blocks on processing. +That is deliberate: the ordering, deduplication and backpressure guarantees of the inbound pipeline +all come from the fact that nothing waits inside the write path. + +**Prefer not to call the wait from inside your hook.** A fully synchronous middleware *may* push its +events, then call the correlation wait itself, and only answer the portal hook once epilot confirms +the data landed. It works, and it is supported. It is also the worse design: + +- It ties **your** HTTP timeout to epilot's processing time. The portal's hook timeout is typically + 20 s and the wait budget goes up to 25 s, so a slow correlation can burn your hook budget and the + portal's at the same time, and the end customer sees a failed hook instead of a precise message. +- It gives up the retry loop. When the portal owns the wait, a `timeout` is just a round that + continues in the browser. When your hook owns it, a timeout is a failed hook. +- It gains nothing. The portal links the entity it finds, not the one your response describes. + +Answer the hook as soon as the ERP has validated the identifiers, return the correlation id, and let +the portal wait. + +## Monitoring + +Interactive traffic raises its own codes in the +[Monitoring tab](./monitoring/overview.md) alongside the usual entity events, and every one of them +is filterable by correlation id: + +| Code | Level | Emitted when | +|---|---|---| +| `INTERACTIVE_RATE_LIMITED` | warning | An interactive request exceeded the organization's budget and ran on the normal lane | +| `INTERACTIVE_NOT_ALLOWED` | info | `interactive: true` arrived for a use case configured `interactive: disabled` | +| `CORRELATION_COMPLETED` | info | A correlation reached `complete`, with its event and entity counts and the ingest-to-complete duration | +| `CORRELATION_LATE_EVENT` | warning | An event arrived after `correlation_complete` | +| `CORRELATION_WAIT_TIMEOUT` | info | A wait with requirements ended by timeout, or by `complete` without them met — the unsatisfied entity slugs and use case slugs are in the event details | + +## A runnable reference + +The [erpilot-integration](https://github.com/epilot-dev/erpilot-integration) demo implements this +page end to end against a demo ERP: the hook handler that returns the correlation id, the pushes that +carry it, and the final request that closes the correlation. Start there if you would rather read +working code than a contract. + +## Next steps + +- [Inbound Getting Started](./inbound/getting-started.md) — the inbound API this builds on +- [Use Cases](./use-cases.md) — the catalogue of standard inbound and outbound flows +- [Portal Extensions](/docs/apps/components/portal-extension) — the hooks the portal calls during + registration and contract addition +- [Monitoring Codes](./monitoring/codes.md) — every code, what it means, and what to do about it diff --git a/docs/integrations/integration-toolkit/overview.md b/docs/integrations/integration-toolkit/overview.md index 6d8f83d..8c2f5d5 100644 --- a/docs/integrations/integration-toolkit/overview.md +++ b/docs/integrations/integration-toolkit/overview.md @@ -28,6 +28,7 @@ The Integration Toolkit is composed of the following components. Each plays a sp | **[ERP Integration API](#erp-integration-api)** | CRUD API to manage integrations, use cases, and mappings | Stable | | **[ERP Inbound API](#inbound-api)** | Dedicated API to receive and simulate inbound ERP events | Stable | | **[Use Cases](./use-cases.md)** | Documented integration flows with testing support | Stable | +| **[Interactive Registration](./interactive-registration.md)** | Correlation-id contract and a dedicated lane for inbound pushes an end customer is waiting for | Rolling out | | **[Core Entities](/docs/entities/core-entities)** | Standardized entity schemas for mapping targets | Stable | | **[Changesets](/docs/entities/changesets)** | Pending attribute updates that wait for ERP confirmation or human approval | Stable | | **[Core Events](/docs/integrations/core-events)** | Standardized event payloads for outbound notifications | Stable | @@ -160,6 +161,8 @@ Push data from your ERP into epilot. Typical flows: [Inbound Integration Guide](./inbound/getting-started.md) +Inbound ingestion is asynchronous by design. For the flows where an end customer is waiting for the push to land — portal self-registration, adding a contract — [Interactive Registration](./interactive-registration.md) adds a correlation-id contract, a dedicated processing lane and a status endpoint the portal waits on, so nobody has to guess whether the data is still coming. + ### Outbound (epilot to ERP) Push epilot events to your ERP via webhooks. Typical flows: diff --git a/docs/integrations/integration-toolkit/use-cases.md b/docs/integrations/integration-toolkit/use-cases.md index 21d761b..e7befe1 100644 --- a/docs/integrations/integration-toolkit/use-cases.md +++ b/docs/integrations/integration-toolkit/use-cases.md @@ -27,6 +27,7 @@ Most integrations start with inbound use cases (syncing master data into epilot) | [Sync Billing Events](#sync-billing-events) | [`billing_event`](/docs/entities/core-entities#billing_event), [`billing_account`](/docs/entities/core-entities#billing_account) | Invoices/payments posted in ERP | | [Sync Installment Schedule](#sync-installment-schedule) | [`billing_event`](/docs/entities/core-entities#billing_event), [`billing_account`](/docs/entities/core-entities#billing_account) | Installment plan (Abschlagsplan) created/changed in ERP | | [Sync Portal User](#sync-portal-user) | [`portal_user`](/docs/entities/core-entities#portal_user), [`contact`](/docs/entities/core-entities#contact) | Portal user registered or mapped | +| [Interactive Registration](#interactive-registration) | [`contact`](/docs/entities/core-entities#contact), [`contract`](/docs/entities/core-entities#contract), [`billing_account`](/docs/entities/core-entities#billing_account) | Portal self-registration or contract addition, while the end customer waits | ### Outbound (epilot to ERP) @@ -340,6 +341,40 @@ This use case is typically combined with [Keep Customer In Sync](#keep-customer- --- +### Interactive Registration + +Deliver the customer's data while the person is still waiting for it: portal self-registration, adding a contract or a customer account to an existing login, and public journeys that identify a customer. The difference to every other inbound use case is that someone is watching a spinner. + +```mermaid +flowchart LR + U[End customer] -->|Identifiers| EP[epilot portal] + EP -->|Extension hook + correlation id| MW[Middle Layer] + MW -->|Validate| ERP[ERP System] + MW -->|POST /v3/erp/updates/events
correlation_id, interactive| API[ERP Integration API] + EP -->|Wait on the correlation| API + API -->|Upsert| E[contact, billing_account, contract, meter] +``` + +**What happens in epilot:** +- The portal mints a correlation id and passes it to your [extension hook](/docs/apps/components/portal-extension) together with the identifiers the end customer entered +- Instead of polling entity search for data that may or may not be coming, the portal waits on that correlation id until the entities it needs exist +- On success it links the returned contact and shows the contracts; on a closed correlation that never produced a contract it says so, instead of timing out silently + +**What happens in the ERP:** +- Middle layer validates the identifiers in the ERP and answers the hook with `{ "correlation_id": "..." }` +- Middle layer pushes the customer's data to `/v3/erp/updates/events` with that same `correlation_id` and `interactive: true`, reusing the existing inbound mappings +- The last request carries `correlation_complete: true`, so the portal learns immediately when nothing more is coming + +**Core Entities:** [`contact`](/docs/entities/core-entities#contact), [`billing_account`](/docs/entities/core-entities#billing_account), [`contract`](/docs/entities/core-entities#contract), [`meter`](/docs/entities/core-entities#meter) + +**Typical unique identifier:** whatever the end customer typed — `customer_number`, `contract_number`, `meter_number` — plus the portal user's identity id where the mapping writes one + +:::tip +The full recipe, including the request and response contract, the `interactive` use case option, the rate limits and the Kafka variant, is on the [Interactive Registration](./interactive-registration.md) page. +::: + +--- + ## Outbound Use Cases (epilot to ERP) Outbound use cases push epilot events to your ERP via [Webhooks](/docs/integrations/webhooks). When a user performs a self-service action in a portal, journey, or epilot 360, an automation triggers a [Core Event](/docs/integrations/core-events) which is delivered to your middle layer webhook endpoint. Your middle layer then processes the event and calls the appropriate ERP API. From d73fd396acc69ef3092442c0f6e27ad288a680f6 Mon Sep 17 00:00:00 2001 From: Karl Rankla Date: Fri, 9 Oct 2026 14:35:50 +0300 Subject: [PATCH 02/19] docs(integration-toolkit): state the zero-event correlation close An event-stream bridge whose terminal marker arrives alone in a batch has no data to send with the close, so it closes the correlation with an empty events array. That is the one case in which the ingest endpoint accepts one; without correlation_complete an empty events array is still a 400. Spelling out both halves, because the asymmetry is otherwise only discoverable by getting the error. --- .../integration-toolkit/interactive-registration.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/integrations/integration-toolkit/interactive-registration.md b/docs/integrations/integration-toolkit/interactive-registration.md index e90a01a..068e4fd 100644 --- a/docs/integrations/integration-toolkit/interactive-registration.md +++ b/docs/integrations/integration-toolkit/interactive-registration.md @@ -219,9 +219,13 @@ curl -X POST 'https://integration-toolkit.sls.epilot.io/v3/erp/updates/events' \ }' ``` -Closing a correlation twice is a no-op. A request with `correlation_complete: true` and no events of -its own is valid only if the correlation already exists — you can close a correlation you opened -earlier, but you cannot close one that was never opened. +Closing a correlation twice is a no-op. + +You can also close a correlation you opened earlier with a request that carries **no events at all** — +useful when the signal that the operation finished arrives on its own, with no data attached. That is +the one case in which an empty `events` array is accepted: `correlation_complete: true` with an empty +`events` array closes an existing correlation, while an empty `events` array without the flag is +still rejected with 400. An event that arrives **after** the correlation was closed is still processed normally. It is counted as a late event and raises a `CORRELATION_LATE_EVENT` warning in monitoring, because by then the From b737fb7923e183139c87daf834ffb095c933ece7 Mon Sep 17 00:00:00 2001 From: Karl Rankla Date: Fri, 9 Oct 2026 16:33:40 +0300 Subject: [PATCH 03/19] docs(integration-toolkit): align interactive registration with the final contract The recipe was written against an earlier draft of the API. Several things have since been decided or found to be wrong, and an integrator building against the old page would ship code that silently never works. - The partner minimum is two fields on the push: the portal's correlation_id and interactive: true. Processing is only recorded for interactive events, so the id alone leaves nothing to wait on. Returning the id from the hook is optional, needed only when a partner pushes under an id of its own, because the portal waits on the id it minted when the hook returns none. - A requirement is met by the first matching event. Without correlation_complete a wait can answer requirements_met while more of the customer's data is still arriving, so the flag is presented as strongly recommended and never required, with a worked example of where several use cases help and where one use case firing twice needs a selector instead. - A selector can only name a member of the target's configured unique_ids, because that is all the response carries per entity; any other attribute never matches and fails silently. The examples now select on configured unique ids only, and the claim that the identity id is returned and that bundles carry a separate billing contact is gone, as both were verified false. - Use case requirements are only as fine-grained as the use case configuration: one inbound use case per partner event type, each with a slug, or the requirement cannot tell messages apart or cannot be named at all. - The correlations listing is its own endpoint, GET /v3/erp/updates/correlations, newest first with a declared 7-day horizon. - Response field names follow the final schema (mapped_at, writes_total, writes_done, the use_cases list, the errors[] item shape, first_ingested_at and last_done_at), and the close, wait-range and completion rules follow the decided behaviour. - Monitoring lists the five added codes, CORRELATION_WAIT_TIMEOUT is a warning carrying missing_entities and missing_use_cases, and CORRELATION_CLOSED_EMPTY is info, so a partner that correctly reports "no data" never alerts louder than a user who waited for nothing. - The two partner failure patterns are documented: customer found but bundle incomplete pushes what exists and closes in the same request; customer not found answers the hook with an error and pushes nothing. --- .../integration-toolkit/configuration.md | 5 + .../interactive-registration.md | 568 ++++++++++++++---- .../integration-toolkit/overview.md | 2 +- .../integration-toolkit/use-cases.md | 14 +- 4 files changed, 451 insertions(+), 138 deletions(-) diff --git a/docs/integrations/integration-toolkit/configuration.md b/docs/integrations/integration-toolkit/configuration.md index d8ecd33..4c4be2f 100644 --- a/docs/integrations/integration-toolkit/configuration.md +++ b/docs/integrations/integration-toolkit/configuration.md @@ -150,6 +150,11 @@ registration push, and the inbound request decides which it is by sending `inter `disabled`, a request that asks for interactive is processed as bulk and told so per event; nothing is rejected. With `always`, every event of the use case is interactive. +If a caller is going to wait on your use cases by slug (`wait_for_use_cases`), model one inbound use +case per partner event type and give every use case a slug: a use case that maps several kinds of +message cannot tell them apart, and one without a slug cannot be named at all. See +[Interactive Registration](./interactive-registration.md#one-use-case-per-partner-event-type). + ### Enabling/Disabling a Use Case ```bash diff --git a/docs/integrations/integration-toolkit/interactive-registration.md b/docs/integrations/integration-toolkit/interactive-registration.md index 068e4fd..1730ac7 100644 --- a/docs/integrations/integration-toolkit/interactive-registration.md +++ b/docs/integrations/integration-toolkit/interactive-registration.md @@ -12,7 +12,7 @@ flows where a person is sitting in front of a screen waiting: self-registration [End Customer Portal](/docs/portals/customer-portal), adding a contract or a customer account to an existing login, and public journeys that identify a customer. -Your push stays asynchronous. What changes is that every push can carry a **correlation id** the +Your push stays asynchronous. What changes is that every push carries a **correlation id** the portal handed you, that the correlation gets its own rate-limited processing lane, and that the portal can **wait on that correlation** instead of guessing from entity search whether your data has arrived yet. @@ -48,10 +48,10 @@ That poll is blind in three ways: epilot support — can answer the only question that matters: *did the data land, and if so, what was missing?* Support tickets of this class exist because the answer is not recorded anywhere. -Interactive registration removes all three. The portal mints an id, you echo it, and the portal asks -epilot a precise question: *has a contact and a contract landed for correlation `C`?* The answer is -either the entities, or `complete` with a list of what you did not send — which is a message a -support agent can act on. +Interactive registration removes all three. The portal mints an id, you send it on your pushes, and the portal asks +epilot a precise question: *has the contact with customer number 4711 and a contract landed for +correlation `C`?* The answer is either the entities, or `complete` with what you did not send — which +is a message a support agent can act on. ## How it works @@ -67,50 +67,122 @@ sequenceDiagram Note over P: Mints correlation id C P->>M: Extension hook (identifiers, C) M->>M: Validate in the ERP - M-->>P: 200 { correlation_id: C } + M-->>P: 200 (identifiers valid) M->>T: POST /v3/erp/updates/events
{ correlation_id: C, interactive: true } - P->>T: GET /v3/erp/updates/events?correlation_id=C
&wait=25&wait_for_entities=contact,contract + P->>T: GET /v3/erp/updates/events?correlation_id=C
&wait=25&wait_for_entities=contact[customer_number=4711] T->>E: Mapping, entity writes (interactive lane) M->>T: POST /v3/erp/updates/events
{ correlation_id: C, interactive: true, correlation_complete: true } T-->>P: 200 { completion: "requirements_met", entities: [...] } - P->>E: Link the returned contact, verify the contract + P->>E: Link the contact, verify the contract P-->>B: Logged in, contracts visible ``` You may push everything in one request or in as many as the ERP produces. The portal is not waiting -for all of your events — it is waiting for a contact and a contract. The last request carries -`correlation_complete: true` so that, if what it is waiting for never comes, the portal learns that -immediately instead of at the end of its budget. +for all of your events — it is waiting for the entities it named, and it stops waiting the moment the +first ones that match have landed. Your last request should carry `correlation_complete: true`: it is +the only way the portal can learn that nothing more is coming, which matters both when what it is +waiting for never comes and when more of the customer's data is still on its way. See +[Partial results](#partial-results-the-first-match-wins). ## What you have to do -The required ask is two lines of code in the flow you already have: +The required ask is **two fields on the push**, and nothing in the hook response: :::tip The minimum -1. **Pass the correlation id we give you** on every push you make for that customer, as - `correlation_id` on the inbound request. -2. **Return it from the hook**, as `correlation_id` in the hook's JSON response body. -::: +On every push to `POST /v3/erp/updates/events` you make for that customer, send: -That is enough for the portal to stop polling entity search and wait on your data instead. Everything -below improves the experience and is a second step: +1. **`correlation_id`** — the id the portal passed to your hook. +2. **`interactive: true`**. -| Optional | What it buys you | -|---|---| -| `interactive: true` on the request | Your registration pushes run on a dedicated lane, ahead of bulk traffic, instead of queueing behind the nightly import | -| `correlation_complete: true` on the last request | The portal fails fast and precisely ("account data arrived, no contract") instead of waiting out its budget | - -Nothing breaks if you ignore any of it. A request without `correlation_id` behaves exactly as it does -today. - -:::note Who turns interactive on -The correlation is only tracked for events that are interactive. That is a setting on the epilot -side, not a second thing to build: a use case set to `interactive: "always"` makes every one of its -events interactive without your middleware changing a line. Sending `interactive: true` yourself is -the better choice when the same use case also carries the nightly batch, because only the request -knows which is which — see [Enabling it on the use case](#enabling-it-on-the-use-case). +```json +{ "correlation_id": "", "interactive": true, "events": [ "..." ] } +``` ::: +Both are needed. epilot only records the processing of interactive events — that is what keeps the +bulk pipeline free of the bookkeeping — so a push that carries the correlation id but is not +interactive leaves nothing to wait on, and the portal answers `timeout` exactly as it would have +before. + +Your hook response does not change. The portal minted the id, so it already knows it, and when your +response carries no `correlation_id` it waits on its own id. + +**Optional: return the id from the hook.** You only need to do this if you push under an id of your +own rather than the portal's — for example because your middleware already has a transaction id it +threads through every push. Then answer the hook with that id as `correlation_id` in the JSON response +body, and the portal waits on yours instead of its own. + +:::note When the use case is dedicated to registrations +If an inbound use case exists only to serve registrations, epilot can set it to +`interactive: "always"` (see [Enabling it on the use case](#enabling-it-on-the-use-case)). Every event +of that use case is then interactive whether the request says so or not, and your part shrinks to the +`correlation_id` alone. Sending `interactive: true` yourself is the default because one mapping +usually serves both the nightly batch and the registration push, and only the request knows which is +which. +::: + +**Strongly recommended:** send `correlation_complete: true` on your **last** request for the +customer. It is never required — the wait works without it — but without it the portal can never +know whether you have finished. With it, a wait whose requirements cannot be met ends at once with the +gap named ("account data arrived, no contract") instead of running out its budget, and a caller that +needs everything can wait for the correlation to be complete rather than for the first match. + +A request without `correlation_id` behaves exactly as it does today. Nothing breaks if you never adopt +any of this; the portal simply keeps polling entity search. + +## When the ERP cannot deliver + +The happy path is the easy part. What the end customer actually reads when something goes wrong is +made of how you handle the two failure cases, so get these right. + +### The customer exists, but the bundle is incomplete + +Push what exists and set `correlation_complete: true` **on that same request**. Then answer the hook +with success as usual, and name what is missing in your response for your own logs and support. + +```bash title="Account found, no contract in the ERP yet — push and close in one request" +curl -X POST 'https://integration-toolkit.sls.epilot.io/v3/erp/updates/events' \ + -H 'Authorization: Bearer ' \ + -H 'Content-Type: application/json' \ + -d '{ + "integration_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "correlation_id": "018f8e9b-5a1b-7c4e-9b2a-4f0f6c1d7a21", + "interactive": true, + "correlation_complete": true, + "events": [ + { + "use_case_slug": "customer_account", + "timestamp": "2026-10-09T09:05:28Z", + "format": "json", + "payload": { + "customerNumber": "4711", + "firstName": "Anna", + "lastName": "Schmitz", + "email": "anna.schmitz@example.de", + "billingAccountNumber": "300571" + }, + "deduplication_id": "reg-4711-20261009090528" + } + ] + }' +``` + +The portal's wait ends `complete` as soon as that one event is processed, its contract requirement +unmet, and it can tell the end customer "we found your account, your contract is not in our system +yet" — in seconds. The wrong move is to push the partial bundle and leave the correlation open in the +hope that the rest turns up: the portal then waits out its whole budget and the end customer gets a +timeout instead of an explanation. + +### The customer cannot be found + +Answer the hook with an error status and push **nothing** — no events, and no correlation. An empty +open correlation is worse than an explicit failure: for as long as the portal's budget lasts, it looks +exactly like a partner that is still working. + +If you have already pushed under the correlation id before you learn that you cannot deliver, close it +with a request that carries `correlation_complete: true` and an empty `events` array (see +[Closing a correlation](#closing-a-correlation)). The portal then learns at once that nothing is coming. + ## Enabling it on the use case Interactive processing is allowed per **inbound use case**, with the `interactive` option on the use @@ -126,18 +198,37 @@ registration push, so the use case grants permission and the request decides. | Value | Effect on a request that asks for interactive | |---|---| -| `disabled` (default) | The events are processed as bulk. Each result says `"interactive": false` with the message `use case does not allow interactive processing`, and an `INTERACTIVE_NOT_ALLOWED` info event appears in monitoring. Nothing is rejected. | +| `disabled` (default) | The events are processed as bulk. Each result says `"interactive": false` with the message `use case does not allow interactive processing`, no processing record is written, and an `INTERACTIVE_NOT_ALLOWED` info event appears in monitoring. Nothing is rejected. | | `on_request` | The request's `interactive: true` selects the interactive lane. This is the normal setting. | | `always` | Every event of this use case is interactive, whether the request asks or not. Use it for a use case that exists only to serve registrations. The per-organization rate limit still applies. | The option is also exposed in the Integration Hub on the inbound use case. See [Use Case Configuration](./configuration.md#use-case-configuration) for the surrounding contract. +If anyone is going to wait on your data by use case rather than by entity, the way you model use cases +matters too — see [One use case per partner event type](#one-use-case-per-partner-event-type). + ## Sending interactive events Two new fields on `POST /v3/erp/updates/events`, both at request level. `interactive` may also be set per event, and the event wins — the same precedence `correlation_id` and `group_id` already follow. +The examples on the rest of this page share one integration with two inbound use cases. Their entity +targets are keyed like this, and the keys matter later: + +```json title="Entity targets of the example use cases (excerpt)" +// use case "customer_account" +"entities": [ + { "entity_schema": "contact", "unique_ids": ["customer_number"], "fields": [ "..." ] }, + { "entity_schema": "billing_account", "unique_ids": ["billing_account_number"], "fields": [ "..." ] } +] + +// use case "contract" +"entities": [ + { "entity_schema": "contract", "unique_ids": ["contract_number"], "fields": [ "..." ] } +] +``` + ```bash title="First push — customer account" curl -X POST 'https://integration-toolkit.sls.epilot.io/v3/erp/updates/events' \ -H 'Authorization: Bearer ' \ @@ -156,7 +247,7 @@ curl -X POST 'https://integration-toolkit.sls.epilot.io/v3/erp/updates/events' \ "firstName": "Anna", "lastName": "Schmitz", "email": "anna.schmitz@example.de", - "accountNumber": "200184" + "billingAccountNumber": "300571" }, "deduplication_id": "reg-4711-20261009090528" } @@ -166,11 +257,11 @@ curl -X POST 'https://integration-toolkit.sls.epilot.io/v3/erp/updates/events' \ | Field | Type | Meaning | |---|---|---| -| `correlation_id` | string | The id the portal handed to your hook. Already part of the API — what is new is that it is now the key the portal waits on, so it must be the portal's id and not one you mint yourself. | +| `correlation_id` | string | The id the portal handed to your hook. Already part of the API — what is new is that it is now the key the portal waits on, so it must be the portal's id, or the id you returned from the hook if you use one of your own. | | `interactive` | boolean | Request the interactive lane for this request's events. | -| `correlation_complete` | boolean | No further events will follow for this correlation id. Send it on your **last** request only. | +| `correlation_complete` | boolean | No further events will follow for this request's `correlation_id`. Send it on your **last** request only. | -The response gains the correlation id and, per result, which lane the event actually went to: +The response gains the correlation id and, per result, how the event was processed: ```json title="Response" { @@ -187,9 +278,17 @@ The response gains the correlation id and, per result, which lane the event actu ``` `correlation_id` is echoed when you sent one and generated when you did not — so a partner that only -wants the lane, without a portal in the loop, still gets an id it can query later. `lane` is one of -`interactive`, `priority`, `default` or `deferred`, and it tells you what actually happened rather -than what you asked for; see [Rate limits](#rate-limits). +wants the lane, without a portal in the loop, still gets an id it can query later. + +The two result fields answer different questions and are independent of each other: + +- `interactive` means **processed as interactive**: a processing record was written and the wait works + for this event. It is `false` only when the use case does not allow interactive processing. +- `lane` is the queue that actually carried the event — `interactive`, `priority`, `default` or + `deferred`. Over the rate limit an event is still `"interactive": true` but runs on + `"lane": "default"` (or the organization's other normal lane); see [Rate limits](#rate-limits). + +### Closing a correlation Your last request closes the correlation: @@ -210,8 +309,8 @@ curl -X POST 'https://integration-toolkit.sls.epilot.io/v3/erp/updates/events' \ "payload": { "contractNumber": "200184", "customerNumber": "4711", - "startDate": "2026-11-01", - "meterNumber": "1ESY1161000123" + "billingAccountNumber": "300571", + "startDate": "2026-11-01" }, "deduplication_id": "reg-4711-contract-20261009090531" } @@ -219,13 +318,21 @@ curl -X POST 'https://integration-toolkit.sls.epilot.io/v3/erp/updates/events' \ }' ``` -Closing a correlation twice is a no-op. - -You can also close a correlation you opened earlier with a request that carries **no events at all** — -useful when the signal that the operation finished arrives on its own, with no data attached. That is -the one case in which an empty `events` array is accepted: `correlation_complete: true` with an empty -`events` array closes an existing correlation, while an empty `events` array without the flag is -still rejected with 400. +The rules: + +- `correlation_complete` closes the **request-level** `correlation_id`, and only that one. epilot never + infers the correlation from the ids on individual events, so a request that sets + `correlation_complete: true` without a request-level `correlation_id` is rejected with **400** and + raises `CORRELATION_ID_MISSING` in monitoring. Nothing is closed. +- The correlation closes after the request's own events are registered, so they count as part of it. + Closing twice is a no-op. +- A request may carry **no events at all** when it sets `correlation_complete: true` — useful when the + signal that the operation finished arrives on its own, with no data attached. This is the one case + in which an empty `events` array is accepted; without the flag, an empty `events` array is still a + 400. +- A close for a correlation epilot has never seen **creates it closed** and returns 200. The portal's + wait then answers `complete` with `events_received: 0` immediately — "the partner sent nothing and + never will" — instead of waiting out its budget. Monitoring records it as `CORRELATION_CLOSED_EMPTY`. An event that arrives **after** the correlation was closed is still processed normally. It is counted as a late event and raises a `CORRELATION_LATE_EVENT` warning in monitoring, because by then the @@ -233,8 +340,8 @@ portal has usually already answered the end customer. ## Checking what landed -Two read endpoints, both authorized with the same organization-scoped token you use for ingest. No -cross-organization read is possible: the lookup is partitioned by the token's organization. +Three read endpoints, all authorized with the same organization-scoped token you use for ingest. No +cross-organization read is possible: every lookup is partitioned by the token's organization. ### One event @@ -245,98 +352,122 @@ waits. { "event_id": "0199c3f2-8a41-7d0e-b6c7-2f1a9d3e4b55", "correlation_id": "018f8e9b-5a1b-7c4e-9b2a-4f0f6c1d7a21", + "integration_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "status": "done", "lane": "interactive", "use_case_id": "58cf8956-e737-4b4c-a683-3528be3eedb8", "use_case_slug": "customer_account", "ingested_at": "2026-10-09T09:05:28.412Z", - "stage1_at": "2026-10-09T09:05:29.004Z", - "done_at": "2026-10-09T09:05:31.277Z", - "children_total": 5, - "children_done": 5, + "mapped_at": "2026-10-09T09:05:29.004Z", + "done_at": "2026-10-09T09:05:30.277Z", + "writes_total": 2, + "writes_done": 2, "entities": [ - { "slug": "contact", "_id": "9acc9f54-5c0a-4b3e-8e6f-1d2c3b4a5968", "operation": "created" }, - { "slug": "billing_account", "_id": "3f1b7e20-9c84-4a55-bd21-6e0d8a7c4f13", "operation": "created" } + { + "slug": "contact", + "_id": "9acc9f54-5c0a-4b3e-8e6f-1d2c3b4a5968", + "operation": "created", + "unique_ids": { "customer_number": "4711" } + }, + { + "slug": "billing_account", + "_id": "3f1b7e20-9c84-4a55-bd21-6e0d8a7c4f13", + "operation": "created", + "unique_ids": { "billing_account_number": "300571" } + } ], "errors": [] } ``` -`status` is `accepted`, `processing`, `done`, `partial` or `error`. `children_total` and -`children_done` count the entity writes the event fanned out into, including follow-up writes such as -stub creates, so `children_done == children_total` on a `done` record means the whole tree is -finished — not just the first write. - -The three timestamps are the three steps of the pipeline: `ingested_at` when the event was accepted, -`stage1_at` when the mapping had produced its entity updates, `done_at` when the last of those writes -finished. +`status` is `accepted`, `processing`, `done`, `partial` (a non-retryable failure after some writes +succeeded) or `error`. `writes_total` and `writes_done` count the entity writes the event fanned out +into, including follow-up writes such as stub creates, so `writes_done == writes_total` on a `done` +record means the whole tree is finished — not just the first write. A write that found the entity +already as described still counts, and appears in `entities` with `"operation": "unchanged"`. +The three timestamps follow your data: `ingested_at` when the event was accepted, `mapped_at` when the +mapping had produced its entity updates, `done_at` when the last of those writes finished. `ingested_at` to `done_at` is the first end-to-end latency figure the inbound pipeline has ever exposed. Use it when you want to know whether a slow registration was slow on your side or on ours. +Each item in `errors[]` is `{ "code", "message", "occurred_at" }`, where `code` is a +[monitoring code](./monitoring/codes.md). + ### A correlation, with a wait `GET /v3/erp/updates/events?correlation_id={id}` returns the summary across every event of one -correlation. With `wait` it long-polls, and with a requirement set it returns as soon as what the -caller needs has arrived. +correlation. With `wait` it long-polls, and with requirements it returns as soon as what the caller +needs has arrived. | Parameter | Meaning | |---|---| | `correlation_id` | Required. The correlation to look at. | -| `wait` | Seconds to wait, `0` to `25`. Omit or `0` for an immediate answer. The ceiling is 25 s because the HTTP API cuts off at 30 s. | -| `wait_for_entities` | Comma-separated entity requirements, each `slug` or `slug[key=value]`. | -| `wait_for_use_cases` | Comma-separated inbound **use case slugs**. | +| `wait` | Seconds to wait, `0` to `25`. Omit or `0` for an immediate answer. The ceiling is 25 s because the HTTP API cuts off at 30 s. A value outside the range is rejected with **400** naming the maximum, not silently clamped. | +| `wait_for_entities` | Entity requirements, each `slug` or `slug[key=value]`. Repeat the parameter for several, or separate them with commas. | +| `wait_for_use_cases` | Inbound **use case slugs**. Repeat the parameter or separate with commas. | Both requirement sets are optional, and both are only valid together with `correlation_id` — sending one without it returns 400. When both are given, **both** must be satisfied before the call answers `requirements_met`. -```bash title="Wait for a contact and a contract" +```bash title="Wait for the customer's contact and a contract" curl -G 'https://integration-toolkit.sls.epilot.io/v3/erp/updates/events' \ -H 'Authorization: Bearer ' \ --data-urlencode 'correlation_id=018f8e9b-5a1b-7c4e-9b2a-4f0f6c1d7a21' \ --data-urlencode 'wait=25' \ - --data-urlencode 'wait_for_entities=contact[customer_number=4711],contract' \ + --data-urlencode 'wait_for_entities=contact[customer_number=4711]' \ + --data-urlencode 'wait_for_entities=contract' \ --data-urlencode 'wait_for_use_cases=customer_account,contract' ``` ```json title="Response" { "correlation_id": "018f8e9b-5a1b-7c4e-9b2a-4f0f6c1d7a21", + "integration_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "completion": "requirements_met", "closed": true, "closed_at": "2026-10-09T09:05:31.902Z", - "events_received": 7, - "events_done": 7, + "events_received": 2, + "events_done": 2, "late_events": 0, + "first_ingested_at": "2026-10-09T09:05:28.412Z", + "last_done_at": "2026-10-09T09:05:33.118Z", + "use_cases": [ + { "use_case_slug": "customer_account", "received": 1, "done": 1, "error": 0 }, + { "use_case_slug": "contract", "received": 1, "done": 1, "error": 0 } + ], "entities": [ { "slug": "contact", "_id": "9acc9f54-5c0a-4b3e-8e6f-1d2c3b4a5968", "operation": "created", - "unique_ids": { - "customer_number": "4711", - "identity_id": "9a9ce1d1-0f7b-4c6a-9d58-7b2e4a1c6f30" - } + "unique_ids": { "customer_number": "4711" } + }, + { + "slug": "billing_account", + "_id": "3f1b7e20-9c84-4a55-bd21-6e0d8a7c4f13", + "operation": "created", + "unique_ids": { "billing_account_number": "300571" } }, { "slug": "contract", "_id": "c0d9a1b8-4e37-4f52-8a90-5b6c7d8e9f01", "operation": "created", - "unique_ids": { - "external_id": "V4ey7pQ1", - "contract_number": "200184" - } + "unique_ids": { "contract_number": "200184" } } ], - "use_cases": [ - { "use_case_slug": "customer_account", "received": 4, "done": 4, "error": 0 }, - { "use_case_slug": "contract", "received": 3, "done": 3, "error": 0 } - ], "errors": [] } ``` +`first_ingested_at` is when the first event of the correlation was accepted and `last_done_at` when +the most recent one finished, so the pair is the end-to-end time of the whole correlation. `use_cases` +has one item per use case the correlation's events were routed to, with how many were `received`, are +`done` and ended in `error`. Each item in `errors[]` is +`{ "code", "message", "event_id", "occurred_at" }` — the same shape as on the single event, plus the +`event_id` that failed. + #### What "arrived" means A requirement is satisfied when the event that carries it reached **`done`** — the entity writes @@ -350,20 +481,47 @@ come. #### Entity requirements, with selectors on `unique_ids` -Every entity in the response carries the `unique_ids` the toolkit resolved for its lookup — the use -case's unique-id set, plus the identity id where the mapping writes one. That is what lets a caller -pick the right entity when a bundle contains several of a type, which is common: one ERP message can -produce a business-partner contact and a profile contact, or a customer contact and a separate -billing contact. - -- `contact` — satisfied by **any** processed contact under the correlation. Only safe when the bundle - cannot contain more than one. -- `contact[identity_id=9a9ce1d1-0f7b-4c6a-9d58-7b2e4a1c6f30]` — satisfied only by a contact whose - `unique_ids` contain that pair. -- `contract[contract_number=200184]` — the same, keyed on the contract number the end customer typed. +Every entity in the response carries `unique_ids`, and that map holds **exactly the `unique_ids` +configured for that entity target in the use case — and nothing else**. In the example above the +contact carries `customer_number` because that is its target's only unique id; its `email`, +`first_name` and every other attribute the mapping wrote are not in the response, however identifying +they look. + +That decides what a selector can name: + +:::caution A selector may only name a member of the target's `unique_ids` +`slug[key=value]` matches a processed entity of that slug whose `unique_ids` contain the pair. Since +`unique_ids` contains only the configured unique ids, **a selector on any other attribute can never +match**. It does not fail loudly: there is no 400 and no error in the response, just a requirement that +is never satisfied and a wait that runs to its budget and answers `timeout` — which looks exactly like +a partner that did not deliver. Check the selector against the use case's configured `unique_ids` +before you rely on it. +::: -Percent-encode the value if it can contain a comma, a bracket or an equals sign; the `curl -G ---data-urlencode` form above does that for you. +In the example integration, the contact target is keyed on `customer_number`, so the end customer's +customer number makes an exact selector: + +- `contact[customer_number=4711]` — satisfied only by a contact whose `unique_ids` contain + `customer_number: "4711"`. A selector on `email` or `last_name` would never match, because neither is + in that target's `unique_ids`. +- `contract[contract_number=200184]` — the same for the contract target, keyed on `contract_number`. + Use it when the caller already knows the contract, as when adding a contract to an existing login. +- `contact` — satisfied by **any** processed contact under the correlation. Only safe when the + correlation cannot contain more than one, and that is rarer than it looks: some ERPs write two + contacts for one business partner, and other use cases — document metadata, for example — can write + contacts under the same unique-id keys a registration push uses. + +When the identifier the caller holds is not one of the configured unique ids, it either derives one +that is (a typed composite number whose parts include a unique id, for instance), or it stops selecting +that entity by attribute: wait on a requirement that is unambiguous — such as `contract` — and reach the +right contact through the delivered contract's `customer` relation, by `_id`. A contract is only +reported once its relations are set, so that relation is there when the wait returns. + +**Selector syntax.** Inside `slug[key=value]` the key ends at the **first** `=`, and the value runs to +the final `]`, so a value may itself contain `=` and `]`. The value is percent-decoded once after that +split. To pass a value that contains a comma, either repeat the parameter — one requirement per +occurrence, which avoids the comma separator altogether — or percent-encode the comma. The +`curl -G --data-urlencode` form above encodes for you. #### Use case requirements @@ -374,38 +532,117 @@ slug is unique within its integration and immutable once set. `event_name` is pa a fallback; it is not a stable key for a requirement. A requirement is met when **at least one event of that use case slug is done**. There are no -selectors on use case slugs in this version — several events of one use case within a correlation are -normal and all of them count towards the same requirement. The response's `use_cases` array gives the -per-use-case summary (`received`, `done`, `error`) so a caller can see which side of the contract is -still open. +selectors on use case slugs in this version. The response's `use_cases` array gives the per-use-case +summary so a caller can see which side of the contract is still open. + +#### One use case per partner event type + +A use case requirement is exactly as fine-grained as the use case configuration behind it. Nothing +enforces one use case per kind of message, so two configuration rules decide whether waiting on use +cases works at all: + +:::tip If you want to wait on use cases +1. **Model one inbound use case per partner event type.** If your ERP sends a customer message and a + contract message, give each its own use case. A single use case `erp_customer_data` that maps + both is satisfied by the customer message alone, so `wait_for_use_cases=erp_customer_data` cannot + tell "the contract arrived" from "only the customer arrived" — and the caller has to fall back to + entity requirements. +2. **Give every use case a slug.** A use case without a slug is still processed and counted, but it + cannot be named in `wait_for_use_cases` at all: the only name it has is an id that differs between + your sandbox and your production organization. +::: + +The example integration on this page follows both rules: `customer_account` and `contract` are +separate use cases, each with a slug. + +#### Partial results: the first match wins + +**Every requirement is satisfied by the first matching event, not by all of them.** This is the one +thing to understand about the wait, and it holds for both requirement sets: a use case requirement is +met by the first event of that use case to reach `done`, and an entity requirement without a selector +by the first entity of that slug. Neither says anything about the rest, and a requirement never carries +a count. + +A worked example. Anna Schmitz has three contracts, `200184`, `200185` and `200186`, and your ERP sends +one `contract` event for each, a second apart. + +| The caller waits on | It returns `requirements_met` when | +|---|---| +| `wait_for_use_cases=contract` | the first of the three contract events is done. The other two are still on their way. | +| `wait_for_entities=contract` | the first contract entity is written. Same as above. | +| `wait_for_entities=contract[contract_number=200185]` | contract `200185` is written — that one and no other. | +| no requirements, waiting for `completion: "complete"` | you sent `correlation_complete: true` and all three contracts are done. | + +The same holds for a relocation, which typically sends two contract events for one customer: the first +one meets a `contract` requirement. + +What follows from that: + +- **Naming several use cases covers a bundle that spans use cases** — `customer_account,contract` needs + one done event of each, which is the common registration and contract-addition shape. It does not + cover one use case firing several times. +- **When the caller knows the identifier, select on it.** Adding a contract to an existing login is the + typical case: the end customer typed the contract number, so `contract[contract_number=…]` waits for + exactly that contract. Registration cannot build such a selector for data it has never seen, and + accepts the first match. +- **Without `correlation_complete`, `requirements_met` does not mean you have finished.** A correlation + whose partner never sends `correlation_complete` can return `requirements_met` while more data for the + same customer is still arriving; the portal shows what has landed, and the rest appears on the next + page load. `correlation_complete` is the only signal that says nothing further is coming. That is why + it is strongly recommended — and it is still never required. #### Which requirement set to use -Use **entity requirements** when you need the entity itself: they express the user-visible goal — *a -contact and a contract exist* — and the response hands you the ids to link and verify. This is what -the portal uses for registration and contract addition. +Use **entity requirements** when you need the entity itself: they express the user-visible goal — *the +customer's contact and a contract exist* — and the response hands you the ids to link and verify. This +is what the portal uses for registration and contract addition. Use **use case requirements** when you need to know that the partner sent the kind of data it promised, rather than that a specific entity appeared. They are the right key when: - the **entity set varies per push** — one customer brings two contracts and a meter, the next brings one contract and nothing else, and no fixed list of slugs describes both; -- an expected use case **legitimately produces no entity at all** — the push results in a file, a - relation on an existing entity, or a no-op update. An entity requirement could never be met there, - so the wait would burn its whole budget and answer `timeout` on a correlation that in fact - completed successfully. +- an expected use case **produces no entity at all** — a meter reading, a file, a relation on an + existing entity, or an event the mapping filters out. An entity requirement could never be met + there, so the wait would burn its whole budget and answer `timeout` on a correlation that in fact + completed successfully. For meter readings, a use case requirement is the only requirement that can + name them. -Combining them is normal: `wait_for_entities=contact,contract` says what the portal needs, -`wait_for_use_cases=customer_account,contract` says the partner finished sending it. +Combining them is normal: `wait_for_entities=contact[customer_number=4711]` says which entity the +portal needs to link, and `wait_for_use_cases=customer_account,contract` lets the failure message name +the kind of data that never came. Neither set can say that you have finished; only +`correlation_complete` can. #### Values of `completion` | Value | Meaning | |---|---| -| `requirements_met` | Every requirement given — entity requirements, use case requirements, or both — is satisfied. The wait returns the moment this becomes true. | +| `requirements_met` | Every requirement given — entity requirements, use case requirements, or both — is satisfied. The wait returns the moment this becomes true, **even if an unrelated event of the correlation failed**: a caller waiting for a contact and a contract has what it asked for. | | `complete` | The correlation is closed and every event received for it is done. This is the hard stop for a caller that wants everything — and it also ends a requirement wait that can no longer be satisfied, which is how the portal learns that you sent an account but no contract. | | `timeout` | The wait budget elapsed. The body still lists everything that landed so far; call again to continue waiting. | -| `error` | At least one event of the correlation ended in a non-retryable error. `errors[]` carries the [monitoring codes](./monitoring/codes.md). | +| `error` | A requirement can no longer be satisfied, because an event it depends on failed non-retryably — or, when the caller named no requirements, the correlation closed with failures. | + +`errors[]` lists every failure in the correlation whatever the verdict, so a `requirements_met` that +ignored an unrelated failure still tells you about it. If the contract event of the example had +arrived without a contract number, the wait for `contract` would end like this: + +```json title="Response (excerpt) — a requirement that can no longer be met" +{ + "completion": "error", + "use_cases": [ + { "use_case_slug": "customer_account", "received": 1, "done": 1, "error": 0 }, + { "use_case_slug": "contract", "received": 1, "done": 0, "error": 1 } + ], + "errors": [ + { + "code": "MISSING_UNIQUE_IDENTIFIERS", + "message": "contract: unique identifier contract_number is missing", + "event_id": "0199c3f5-1b07-7a2e-8d44-9e3c6f0a2b18", + "occurred_at": "2026-10-09T09:05:32.640Z" + } + ] +} +``` There is no quiet-window heuristic: with no requirements and no close, a wait runs to its budget and then returns `timeout`. @@ -416,10 +653,62 @@ exactly the ambiguity `correlation_complete` exists to remove. :::note Concurrent waits are capped Long polls are limited per organization and globally. Past the cap the call answers **429** with a -`Retry-After` header instead of waiting; callers fall back to a non-waiting call and retry. The -portal already does this. +`Retry-After` header instead of waiting, and raises `CORRELATION_WAIT_REJECTED` in monitoring; callers +fall back to a non-waiting call and retry. The portal already does this. ::: +### Recent correlations + +`GET /v3/erp/updates/correlations` lists the organization's correlations, **newest first**. It is the +way to find a correlation that went wrong when you do not already have its id — a correlation that is +merely stuck has raised no monitoring code, so nothing else points at it. + +| Parameter | Meaning | +|---|---| +| `integration_id` | Optional. Only correlations of this integration. | +| `limit` | Page size. Default 50; values above 100 are treated as 100. | +| `cursor` | Opaque. Pass the previous page's `next_cursor` to continue. | + +```bash title="The latest correlations of one integration" +curl -G 'https://integration-toolkit.sls.epilot.io/v3/erp/updates/correlations' \ + -H 'Authorization: Bearer ' \ + --data-urlencode 'integration_id=a1b2c3d4-e5f6-7890-abcd-ef1234567890' \ + --data-urlencode 'limit=50' +``` + +```json title="Response (excerpt)" +{ + "results": [ + { + "correlation_id": "018f8e9b-5a1b-7c4e-9b2a-4f0f6c1d7a21", + "integration_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "closed": true, + "closed_at": "2026-10-09T09:05:31.902Z", + "events_received": 2, + "events_done": 2, + "late_events": 0, + "first_ingested_at": "2026-10-09T09:05:28.412Z", + "last_done_at": "2026-10-09T09:05:33.118Z", + "use_cases": [ + { "use_case_slug": "customer_account", "received": 1, "done": 1, "error": 0 }, + { "use_case_slug": "contract", "received": 1, "done": 1, "error": 0 } + ], + "entities": [ "..." ], + "errors": [] + } + ], + "next_cursor": "eyJzayI6IjIwMjYtMTAtMDlUMDk6MDU6MjguNDEyWiJ9", + "horizon_days": 7 +} +``` + +Each item in `results` is the same correlation summary the wait returns, without the long poll. +`next_cursor` is `null` on the last page. + +`horizon_days` states how far back the listing reaches: **7 days**, which is how long correlation +records are kept. A correlation older than that is gone, not hidden, and the response says so rather +than returning less without comment. Single event records are kept for 24 hours. + ## Rate limits The interactive lane is only worth having if it stays fast, and it stays fast only if no single @@ -430,7 +719,7 @@ minutes with a burst of 40**, raised by agreement for organizations with higher When the budget is exhausted, nothing is rejected and nothing is lost: - The events are accepted and processed on the organization's **normal lane**. The per-result `lane` - field names it (`priority`, `default` or `deferred`). + field names it (`priority`, `default` or `deferred`), and `interactive` stays `true`. - The **processing records are still written**, so `correlation_complete`, the wait and the status endpoints keep working exactly as before — the answer simply takes longer to arrive, and the portal's wait may run to `timeout` and retry. @@ -456,17 +745,18 @@ inbound request fields for you. That is all the wait needs. The bridge maps the extension to `correlation_id` and sets `interactive: true` on any batch that carries one, so the portal's requirements are satisfied the -moment the matching events finish processing — entity by entity, or use case by use case. +moment the matching events finish processing — entity by entity, or use case by use case. The +[first match wins](#partial-results-the-first-match-wins) here exactly as it does over HTTP. Two improvements on top: - **Publish a terminal event last, on the same message key** as the data events — a name such as `AssignmentCompleted`, with no business payload. The bridge translates it into `correlation_complete: true` on the inbound request that carries it and does not forward it as - data, so it needs no mapping and no use case. It is not required for the wait to finish early; what - it buys is the opposite case — when the operation produced less than the portal needs, the wait - ends immediately as `complete` with the missing requirement named, instead of running out its - budget and answering `timeout`. + data, so it needs no mapping and no use case. It is strongly recommended and not required. What it + buys is the difference between missing and slow: without it, data you never send looks exactly like + data that is still on its way until the budget expires; with it, the wait ends immediately as + `complete` with the missing requirement named. - **Emit the full set of events the operation produced.** An assignment that changes a business partner but publishes no contract or meter event still fails for the end customer. The difference is that it now fails in seconds with the missing requirement named, rather than as an unexplained @@ -497,33 +787,51 @@ the data landed. It works, and it is supported. It is also the worse design: continues in the browser. When your hook owns it, a timeout is a failed hook. - It gains nothing. The portal links the entity it finds, not the one your response describes. -Answer the hook as soon as the ERP has validated the identifiers, return the correlation id, and let -the portal wait. +Answer the hook as soon as the ERP has validated the identifiers, push with the portal's correlation +id, and let the portal wait. + +**Do not open a correlation for a customer you could not find.** Answer the hook with an error and +push nothing; see [When the ERP cannot deliver](#when-the-erp-cannot-deliver). + +**Do not select on an attribute that is not a configured unique id.** It never matches and never says +so; see [Entity requirements](#entity-requirements-with-selectors-on-unique_ids). ## Monitoring Interactive traffic raises its own codes in the -[Monitoring tab](./monitoring/overview.md) alongside the usual entity events, and every one of them -is filterable by correlation id: +[Monitoring tab](./monitoring/overview.md) alongside the usual entity events. Every one of them carries +the `correlation_id`, so you can filter a single registration end to end: | Code | Level | Emitted when | |---|---|---| | `INTERACTIVE_RATE_LIMITED` | warning | An interactive request exceeded the organization's budget and ran on the normal lane | | `INTERACTIVE_NOT_ALLOWED` | info | `interactive: true` arrived for a use case configured `interactive: disabled` | +| `INTERACTIVE_CROSS_LANE_WRITE` | warning | An interactive write landed on an entity that another lane had written within the last 60 seconds — the same entity was in flight on two lanes at once, typically a bulk sync and a registration push for the same customer | | `CORRELATION_COMPLETED` | info | A correlation reached `complete`, with its event and entity counts and the ingest-to-complete duration | +| `CORRELATION_CLOSED_EMPTY` | info | A correlation was closed without a single event — the partner said nothing is coming for this customer, including a close that created the correlation | | `CORRELATION_LATE_EVENT` | warning | An event arrived after `correlation_complete` | -| `CORRELATION_WAIT_TIMEOUT` | info | A wait with requirements ended by timeout, or by `complete` without them met — the unsatisfied entity slugs and use case slugs are in the event details | +| `CORRELATION_ID_MISSING` | error | A request set `correlation_complete: true` without a request-level `correlation_id` and was rejected with 400; nothing was closed | +| `CORRELATION_REQUIREMENT_FAILED` | error | A wait ended `error` because an event a requirement depends on failed non-retryably; the details name the requirement and the failing event | +| `CORRELATION_WAIT_TIMEOUT` | warning | A wait with requirements ended by timeout, or by `complete` without them met. The details list what was not satisfied as `missing_entities` and `missing_use_cases` | +| `CORRELATION_WAIT_REJECTED` | warning | A wait was refused with 429 because the organization's or the global cap on concurrent waits was reached | + +The two codes that end a correlation without data are ranked deliberately. `CORRELATION_WAIT_TIMEOUT` +is the warning: an end customer waited, and was shown nothing. `CORRELATION_CLOSED_EMPTY` is only +info: a partner that closes a correlation it has nothing for is doing exactly what this page asks, and +the portal got its answer at once. ## A runnable reference The [erpilot-integration](https://github.com/epilot-dev/erpilot-integration) demo implements this -page end to end against a demo ERP: the hook handler that returns the correlation id, the pushes that -carry it, and the final request that closes the correlation. Start there if you would rather read -working code than a contract. +page end to end against a demo ERP: the hook handler that takes the portal's correlation id, the pushes +that carry it, the final request that closes the correlation, and both failure cases. Start there if you +would rather read working code than a contract. ## Next steps - [Inbound Getting Started](./inbound/getting-started.md) — the inbound API this builds on +- [Unique Identifiers](./inbound/unique-identifiers.md) — how `unique_ids` are configured, and so what a + selector can name - [Use Cases](./use-cases.md) — the catalogue of standard inbound and outbound flows - [Portal Extensions](/docs/apps/components/portal-extension) — the hooks the portal calls during registration and contract addition diff --git a/docs/integrations/integration-toolkit/overview.md b/docs/integrations/integration-toolkit/overview.md index 8c2f5d5..a6dab67 100644 --- a/docs/integrations/integration-toolkit/overview.md +++ b/docs/integrations/integration-toolkit/overview.md @@ -161,7 +161,7 @@ Push data from your ERP into epilot. Typical flows: [Inbound Integration Guide](./inbound/getting-started.md) -Inbound ingestion is asynchronous by design. For the flows where an end customer is waiting for the push to land — portal self-registration, adding a contract — [Interactive Registration](./interactive-registration.md) adds a correlation-id contract, a dedicated processing lane and a status endpoint the portal waits on, so nobody has to guess whether the data is still coming. +Inbound ingestion is asynchronous by design. For the flows where an end customer is waiting for the push to land — portal self-registration, adding a contract — [Interactive Registration](./interactive-registration.md) adds a correlation-id contract, a dedicated processing lane and status endpoints the portal waits on, so nobody has to guess whether the data is still coming. ### Outbound (epilot to ERP) diff --git a/docs/integrations/integration-toolkit/use-cases.md b/docs/integrations/integration-toolkit/use-cases.md index e7befe1..50db9ab 100644 --- a/docs/integrations/integration-toolkit/use-cases.md +++ b/docs/integrations/integration-toolkit/use-cases.md @@ -357,20 +357,20 @@ flowchart LR **What happens in epilot:** - The portal mints a correlation id and passes it to your [extension hook](/docs/apps/components/portal-extension) together with the identifiers the end customer entered -- Instead of polling entity search for data that may or may not be coming, the portal waits on that correlation id until the entities it needs exist -- On success it links the returned contact and shows the contracts; on a closed correlation that never produced a contract it says so, instead of timing out silently +- Instead of polling entity search for data that may or may not be coming, the portal waits on that correlation id until the entities it needs exist — and the first matching entity is enough, so further data for the same customer may still be arriving +- On success it links the contact and shows the contracts; on a closed correlation that never produced a contract it says so, instead of timing out silently **What happens in the ERP:** -- Middle layer validates the identifiers in the ERP and answers the hook with `{ "correlation_id": "..." }` -- Middle layer pushes the customer's data to `/v3/erp/updates/events` with that same `correlation_id` and `interactive: true`, reusing the existing inbound mappings -- The last request carries `correlation_complete: true`, so the portal learns immediately when nothing more is coming +- Middle layer validates the identifiers in the ERP and answers the hook as it does today — or, when the customer cannot be found, with an error, pushing nothing +- Middle layer pushes the customer's data to `/v3/erp/updates/events` with the portal's `correlation_id` and `interactive: true` — the only two fields required — reusing the existing inbound mappings +- Strongly recommended: the last request carries `correlation_complete: true`, the only way the portal learns that nothing more is coming — including when the ERP has the customer but not the whole bundle **Core Entities:** [`contact`](/docs/entities/core-entities#contact), [`billing_account`](/docs/entities/core-entities#billing_account), [`contract`](/docs/entities/core-entities#contract), [`meter`](/docs/entities/core-entities#meter) -**Typical unique identifier:** whatever the end customer typed — `customer_number`, `contract_number`, `meter_number` — plus the portal user's identity id where the mapping writes one +**Typical unique identifier:** the portal can only select an entity by an attribute that is one of its target's configured `unique_ids` — for example `customer_number` or `contract_number` when the end customer types them. Anything else never matches. :::tip -The full recipe, including the request and response contract, the `interactive` use case option, the rate limits and the Kafka variant, is on the [Interactive Registration](./interactive-registration.md) page. +The full recipe, including the request and response contract, the failure cases, the `interactive` use case option, the rate limits and the Kafka variant, is on the [Interactive Registration](./interactive-registration.md) page. ::: --- From 4c84e24f311b040ed399ed49d9a5242f400e3ae6 Mon Sep 17 00:00:00 2001 From: Karl Rankla Date: Fri, 9 Oct 2026 17:57:16 +0300 Subject: [PATCH 04/19] docs(integration-toolkit): document the parallel close, correlation id rules and the new codes Partners that push in parallel close with a zero-event request once every push is accepted; sequential pushers keep the close on the last request. A correlation id is unique per identification, 1-255 characters of [A-Za-z0-9_-] on waitable requests, and holds at most 32 events. A selector key outside the configured unique ids is now a 400. Monitoring lists CORRELATION_CLOSE_IGNORED, CORRELATION_CLOSED_INCOMPLETE and CORRELATION_EVENT_LIMIT_EXCEEDED, and the timeout counts once per correlation. --- .../interactive-registration.md | 140 ++++++++++++++---- .../integration-toolkit/use-cases.md | 6 +- 2 files changed, 117 insertions(+), 29 deletions(-) diff --git a/docs/integrations/integration-toolkit/interactive-registration.md b/docs/integrations/integration-toolkit/interactive-registration.md index 1730ac7..4c64e49 100644 --- a/docs/integrations/integration-toolkit/interactive-registration.md +++ b/docs/integrations/integration-toolkit/interactive-registration.md @@ -104,6 +104,12 @@ bulk pipeline free of the bookkeeping — so a push that carries the correlation interactive leaves nothing to wait on, and the portal answers `timeout` exactly as it would have before. +**Use the id the hook passed, and only for that customer.** A `correlation_id` is unique per +identification: one per hook call, never reused across customers or attempts, and never a constant. +If your middleware sends a fixed or hard-coded correlation id today — some integrations built on +Talend or similar tools put the same id on every push — replace it with the one the hook passes. See +[Correlation ids](#correlation-ids) for the format and the limits. + Your hook response does not change. The portal minted the id, so it already knows it, and when your response carries no `correlation_id` it waits on its own id. @@ -121,11 +127,19 @@ usually serves both the nightly batch and the registration push, and only the re which. ::: -**Strongly recommended:** send `correlation_complete: true` on your **last** request for the -customer. It is never required — the wait works without it — but without it the portal can never -know whether you have finished. With it, a wait whose requirements cannot be met ends at once with the -gap named ("account data arrived, no contract") instead of running out its budget, and a caller that -needs everything can wait for the correlation to be complete rather than for the first match. +**Strongly recommended:** close the correlation with `correlation_complete: true` once everything for +the customer is sent. It is never required — the wait works without it — but without it the portal +can never know whether you have finished. With it, a wait whose requirements cannot be met ends at +once with the gap named ("account data arrived, no contract") instead of running out its budget, and a +caller that needs everything can wait for the correlation to be complete rather than for the first +match. Where the close goes depends on how you push: + +- **One request after another:** set `correlation_complete: true` on your last request. +- **Several requests in parallel:** send every event, wait until each request has been accepted, then + send the close as its own request with no events. + +[Closing a correlation](#closing-a-correlation) has both patterns. If you cannot place the close +correctly, leave it out: a close that arrives too early does more harm than no close. A request without `correlation_id` behaves exactly as it does today. Nothing breaks if you never adopt any of this; the portal simply keeps polling entity search. @@ -137,8 +151,10 @@ made of how you handle the two failure cases, so get these right. ### The customer exists, but the bundle is incomplete -Push what exists and set `correlation_complete: true` **on that same request**. Then answer the hook -with success as usual, and name what is missing in your response for your own logs and support. +Push what exists and close the correlation: set `correlation_complete: true` **on that same request**, +or — if you push in parallel — send the close as its own request once every push was accepted (see +[Closing a correlation](#closing-a-correlation)). Then answer the hook with success as usual, and name +what is missing in your response for your own logs and support. ```bash title="Account found, no contract in the ERP yet — push and close in one request" curl -X POST 'https://integration-toolkit.sls.epilot.io/v3/erp/updates/events' \ @@ -168,7 +184,8 @@ curl -X POST 'https://integration-toolkit.sls.epilot.io/v3/erp/updates/events' \ ``` The portal's wait ends `complete` as soon as that one event is processed, its contract requirement -unmet, and it can tell the end customer "we found your account, your contract is not in our system +unmet — monitoring records that as `CORRELATION_CLOSED_INCOMPLETE`, naming the missing contract — and +it can tell the end customer "we found your account, your contract is not in our system yet" — in seconds. The wrong move is to push the partial bundle and leave the correlation open in the hope that the rest turns up: the portal then waits out its whole budget and the end customer gets a timeout instead of an explanation. @@ -257,9 +274,9 @@ curl -X POST 'https://integration-toolkit.sls.epilot.io/v3/erp/updates/events' \ | Field | Type | Meaning | |---|---|---| -| `correlation_id` | string | The id the portal handed to your hook. Already part of the API — what is new is that it is now the key the portal waits on, so it must be the portal's id, or the id you returned from the hook if you use one of your own. | +| `correlation_id` | string | The id the portal handed to your hook. Already part of the API — what is new is that it is now the key the portal waits on, so it must be the portal's id, or the id you returned from the hook if you use one of your own. Unique per identification, 1 to 255 characters of `[A-Za-z0-9_-]`; see [Correlation ids](#correlation-ids). | | `interactive` | boolean | Request the interactive lane for this request's events. | -| `correlation_complete` | boolean | No further events will follow for this request's `correlation_id`. Send it on your **last** request only. | +| `correlation_complete` | boolean | No further events will follow for this request's `correlation_id`. Send it once, when you are done: on your last request, or as its own request with no events if you push in parallel. | The response gains the correlation id and, per result, how the event was processed: @@ -288,9 +305,27 @@ The two result fields answer different questions and are independent of each oth `deferred`. Over the rate limit an event is still `"interactive": true` but runs on `"lane": "default"` (or the organization's other normal lane); see [Rate limits](#rate-limits). +### Correlation ids + +- **Unique per identification.** Use one id per hook call, for that customer only. Never reuse an id + across customers or attempts, and never send a constant. A reused id pours every registration into + one correlation, and every wait on it degrades. +- **Format.** 1 to 255 characters of `[A-Za-z0-9_-]`; a UUID fits. The rule applies to every request + that can be waited on — `interactive: true` on the request or on an event, or + `correlation_complete` — and a request that breaks it is rejected with **400** naming the rule. The + wait's `correlation_id` parameter follows the same rule. Plain bulk requests keep accepting the ids + they accepted before, because nobody waits on them. +- **At most 32 events per correlation.** A real registration is far below that. Once a correlation + has received 32 events, further events under its id are processed as plain bulk on the + organization's normal lane, with no processing record, so the wait cannot see them, and monitoring + raises `CORRELATION_EVENT_LIMIT_EXCEEDED` naming the correlation id. If you see that code, you are + almost certainly reusing an id. + ### Closing a correlation -Your last request closes the correlation: +Where the close goes depends on how you push. + +**One request after another.** Your last request closes the correlation: ```bash title="Last push — contract, and close the correlation" curl -X POST 'https://integration-toolkit.sls.epilot.io/v3/erp/updates/events' \ @@ -318,6 +353,26 @@ curl -X POST 'https://integration-toolkit.sls.epilot.io/v3/erp/updates/events' \ }' ``` +**Several requests in parallel.** If you send a bundle as parallel requests — one event each, within a +second or two — there is no last request: whichever one carries the close may arrive before the +others and turn them into late events. Send every event first, wait until each request has been +accepted, then close the correlation with a request of its own that carries no events: + +```bash title="After every parallel push was accepted — close the correlation on its own" +curl -X POST 'https://integration-toolkit.sls.epilot.io/v3/erp/updates/events' \ + -H 'Authorization: Bearer ' \ + -H 'Content-Type: application/json' \ + -d '{ + "integration_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "correlation_id": "018f8e9b-5a1b-7c4e-9b2a-4f0f6c1d7a21", + "correlation_complete": true, + "events": [] + }' +``` + +"Accepted" means the request answered with success for every event. If any push is answered with a +retry (a 422 with partial results, or a 429), retry it until it is accepted before you send the close. + The rules: - `correlation_complete` closes the **request-level** `correlation_id`, and only that one. epilot never @@ -326,8 +381,16 @@ The rules: raises `CORRELATION_ID_MISSING` in monitoring. Nothing is closed. - The correlation closes after the request's own events are registered, so they count as part of it. Closing twice is a no-op. -- A request may carry **no events at all** when it sets `correlation_complete: true` — useful when the - signal that the operation finished arrives on its own, with no data attached. This is the one case +- The close is applied only when **every** event of the request was accepted. A response that asks you + to retry — a 422 with partial results, or a 429 — closes nothing, so a close can never overtake + events that are still being retried. +- A request that carries events of which **none** was processed as interactive — the use case does + not allow it, or the request left out `interactive` — neither creates nor closes the correlation. + Those events went to bulk, where the wait cannot see them, so the close is ignored and monitoring + raises `CORRELATION_CLOSE_IGNORED`. +- A request may carry **no events at all** when it sets `correlation_complete: true` — the parallel + pattern above, or a signal that the operation finished that arrives on its own, with no data + attached. This is the one case in which an empty `events` array is accepted; without the flag, an empty `events` array is still a 400. - A close for a correlation epilot has never seen **creates it closed** and returns 200. The portal's @@ -492,12 +555,25 @@ That decides what a selector can name: :::caution A selector may only name a member of the target's `unique_ids` `slug[key=value]` matches a processed entity of that slug whose `unique_ids` contain the pair. Since `unique_ids` contains only the configured unique ids, **a selector on any other attribute can never -match**. It does not fail loudly: there is no 400 and no error in the response, just a requirement that -is never satisfied and a wait that runs to its budget and answers `timeout` — which looks exactly like -a partner that did not deliver. Check the selector against the use case's configured `unique_ids` -before you rely on it. +match**, so the wait rejects it up front with **400**. The check is against the union of the +`unique_ids` configured for that entity slug across all of the organization's inbound use cases, so a +selector that some use case can satisfy is never rejected, and the check works before the correlation +exists. A `wait_for_entities` entry whose slug no inbound use case writes is a 400 as well. Treat the +400 as a configuration error to fix, not as a reason to retry. ::: +```json title="400 — a selector key that is not a configured unique id" +{ + "message": "wait_for_entities selector 'contact[email=anna.schmitz@example.de]' can never match: 'email' is not a unique id of 'contact' in any inbound use case of this organization. A selector can only name one of: customer_number", + "slug": "contact", + "rejected_key": "email", + "allowed_keys": ["customer_number"] +} +``` + +When the slug itself is written by no inbound use case, `rejected_key` is absent and `allowed_keys` is +empty. + In the example integration, the contact target is keyed on `customer_number`, so the end customer's customer number makes an exact selector: @@ -618,8 +694,8 @@ the kind of data that never came. Neither set can say that you have finished; on | Value | Meaning | |---|---| | `requirements_met` | Every requirement given — entity requirements, use case requirements, or both — is satisfied. The wait returns the moment this becomes true, **even if an unrelated event of the correlation failed**: a caller waiting for a contact and a contract has what it asked for. | -| `complete` | The correlation is closed and every event received for it is done. This is the hard stop for a caller that wants everything — and it also ends a requirement wait that can no longer be satisfied, which is how the portal learns that you sent an account but no contract. | -| `timeout` | The wait budget elapsed. The body still lists everything that landed so far; call again to continue waiting. | +| `complete` | The correlation is closed and every event received for it is done. This is the hard stop for a caller that wants everything — and it also ends a requirement wait that can no longer be satisfied, which is how the portal learns that you sent an account but no contract. That ending raises `CORRELATION_CLOSED_INCOMPLETE` in monitoring, once per correlation, naming what is missing. | +| `timeout` | The wait budget elapsed. The body still lists everything that landed so far; call again to continue waiting. The first wait that runs out on a correlation raises `CORRELATION_WAIT_TIMEOUT`; later ones on the same correlation do not, and a `wait=0` read never does. | | `error` | A requirement can no longer be satisfied, because an event it depends on failed non-retryably — or, when the caller named no requirements, the correlation closed with failures. | `errors[]` lists every failure in the correlation whatever the verdict, so a `requirements_met` that @@ -793,8 +869,15 @@ id, and let the portal wait. **Do not open a correlation for a customer you could not find.** Answer the hook with an error and push nothing; see [When the ERP cannot deliver](#when-the-erp-cannot-deliver). -**Do not select on an attribute that is not a configured unique id.** It never matches and never says -so; see [Entity requirements](#entity-requirements-with-selectors-on-unique_ids). +**Do not reuse a correlation id, or send a constant one.** One id per identification; see +[Correlation ids](#correlation-ids). + +**Do not put the close on one of several parallel requests.** It may arrive before the others and +turn them into late events. Close with a request of its own once every push was accepted, or leave the +close out; see [Closing a correlation](#closing-a-correlation). + +**Do not select on an attribute that is not a configured unique id.** It can never match, so the wait +rejects it with 400; see [Entity requirements](#entity-requirements-with-selectors-on-unique_ids). ## Monitoring @@ -809,16 +892,21 @@ the `correlation_id`, so you can filter a single registration end to end: | `INTERACTIVE_CROSS_LANE_WRITE` | warning | An interactive write landed on an entity that another lane had written within the last 60 seconds — the same entity was in flight on two lanes at once, typically a bulk sync and a registration push for the same customer | | `CORRELATION_COMPLETED` | info | A correlation reached `complete`, with its event and entity counts and the ingest-to-complete duration | | `CORRELATION_CLOSED_EMPTY` | info | A correlation was closed without a single event — the partner said nothing is coming for this customer, including a close that created the correlation | +| `CORRELATION_CLOSE_IGNORED` | warning | A request set `correlation_complete: true` but every one of its events went to bulk (the use case does not allow interactive processing, or `interactive` was left out), so the close was ignored and no correlation was created or closed | +| `CORRELATION_CLOSED_INCOMPLETE` | warning | A correlation reached `complete` without the requirements of a wait on it met — the partner closed without sending what was asked for. Raised once per correlation; the details list what is missing as `missing_entities` and `missing_use_cases` | | `CORRELATION_LATE_EVENT` | warning | An event arrived after `correlation_complete` | +| `CORRELATION_EVENT_LIMIT_EXCEEDED` | warning | A correlation had already received its 32 events, so further events under its id were processed as plain bulk with no processing record; the details name the correlation id | | `CORRELATION_ID_MISSING` | error | A request set `correlation_complete: true` without a request-level `correlation_id` and was rejected with 400; nothing was closed | | `CORRELATION_REQUIREMENT_FAILED` | error | A wait ended `error` because an event a requirement depends on failed non-retryably; the details name the requirement and the failing event | -| `CORRELATION_WAIT_TIMEOUT` | warning | A wait with requirements ended by timeout, or by `complete` without them met. The details list what was not satisfied as `missing_entities` and `missing_use_cases` | +| `CORRELATION_WAIT_TIMEOUT` | warning | The wait budget ran out before the requirements were met. Raised once per correlation, however many waits run out on it, and never by a `wait=0` read. The details list what was not satisfied as `missing_entities` and `missing_use_cases` | | `CORRELATION_WAIT_REJECTED` | warning | A wait was refused with 429 because the organization's or the global cap on concurrent waits was reached | -The two codes that end a correlation without data are ranked deliberately. `CORRELATION_WAIT_TIMEOUT` -is the warning: an end customer waited, and was shown nothing. `CORRELATION_CLOSED_EMPTY` is only -info: a partner that closes a correlation it has nothing for is doing exactly what this page asks, and -the portal got its answer at once. +The codes that end a correlation without data are ranked deliberately. `CORRELATION_WAIT_TIMEOUT` and +`CORRELATION_CLOSED_INCOMPLETE` are the warnings: an end customer waited and was shown nothing, because +the budget ran out or because the partner closed without what was asked for. Each is raised once per +correlation, so a portal that retries on the same correlation counts one customer, not one per +attempt. `CORRELATION_CLOSED_EMPTY` is only info: a partner that closes a correlation it has nothing for +is doing exactly what this page asks, and the portal got its answer at once. ## A runnable reference diff --git a/docs/integrations/integration-toolkit/use-cases.md b/docs/integrations/integration-toolkit/use-cases.md index 50db9ab..0c67c0e 100644 --- a/docs/integrations/integration-toolkit/use-cases.md +++ b/docs/integrations/integration-toolkit/use-cases.md @@ -362,12 +362,12 @@ flowchart LR **What happens in the ERP:** - Middle layer validates the identifiers in the ERP and answers the hook as it does today — or, when the customer cannot be found, with an error, pushing nothing -- Middle layer pushes the customer's data to `/v3/erp/updates/events` with the portal's `correlation_id` and `interactive: true` — the only two fields required — reusing the existing inbound mappings -- Strongly recommended: the last request carries `correlation_complete: true`, the only way the portal learns that nothing more is coming — including when the ERP has the customer but not the whole bundle +- Middle layer pushes the customer's data to `/v3/erp/updates/events` with the portal's `correlation_id` and `interactive: true` — the only two fields required — reusing the existing inbound mappings. The id is the one the hook passed, unique to this identification; a fixed or constant correlation id sent today must be replaced by it +- Strongly recommended: close the correlation with `correlation_complete: true`, the only way the portal learns that nothing more is coming — including when the ERP has the customer but not the whole bundle. Pushing one request after another, the last request carries it; pushing in parallel, it is a request of its own with no events, sent once every push was accepted **Core Entities:** [`contact`](/docs/entities/core-entities#contact), [`billing_account`](/docs/entities/core-entities#billing_account), [`contract`](/docs/entities/core-entities#contract), [`meter`](/docs/entities/core-entities#meter) -**Typical unique identifier:** the portal can only select an entity by an attribute that is one of its target's configured `unique_ids` — for example `customer_number` or `contract_number` when the end customer types them. Anything else never matches. +**Typical unique identifier:** the portal can only select an entity by an attribute that is one of its target's configured `unique_ids` — for example `customer_number` or `contract_number` when the end customer types them. Anything else can never match, and the wait rejects it with 400. :::tip The full recipe, including the request and response contract, the failure cases, the `interactive` use case option, the rate limits and the Kafka variant, is on the [Interactive Registration](./interactive-registration.md) page. From 24120fc0f130246830cbe9057e696ba96571342a Mon Sep 17 00:00:00 2001 From: Karl Rankla Date: Tue, 13 Oct 2026 14:17:24 +0300 Subject: [PATCH 05/19] docs(integration-toolkit): selector values match ignoring case and surrounding whitespace A wait_for_entities selector value is compared the way the toolkit's entity lookup compares unique ids: trimmed and lowercased on both sides, with whitespace inside the value still significant. --- .../integration-toolkit/interactive-registration.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/integrations/integration-toolkit/interactive-registration.md b/docs/integrations/integration-toolkit/interactive-registration.md index 4c64e49..33f681c 100644 --- a/docs/integrations/integration-toolkit/interactive-registration.md +++ b/docs/integrations/integration-toolkit/interactive-registration.md @@ -467,7 +467,7 @@ needs has arrived. |---|---| | `correlation_id` | Required. The correlation to look at. | | `wait` | Seconds to wait, `0` to `25`. Omit or `0` for an immediate answer. The ceiling is 25 s because the HTTP API cuts off at 30 s. A value outside the range is rejected with **400** naming the maximum, not silently clamped. | -| `wait_for_entities` | Entity requirements, each `slug` or `slug[key=value]`. Repeat the parameter for several, or separate them with commas. | +| `wait_for_entities` | Entity requirements, each `slug` or `slug[key=value]`. Repeat the parameter for several, or separate them with commas. A selector value matches ignoring case and surrounding whitespace, like entity lookup. | | `wait_for_use_cases` | Inbound **use case slugs**. Repeat the parameter or separate with commas. | Both requirement sets are optional, and both are only valid together with `correlation_id` — sending @@ -599,6 +599,11 @@ split. To pass a value that contains a comma, either repeat the parameter — on occurrence, which avoids the comma separator altogether — or percent-encode the comma. The `curl -G --data-urlencode` form above encodes for you. +**Value matching.** The value is compared the way the toolkit's own entity lookup compares unique ids: +ignoring letter case and surrounding whitespace on both sides, so `contact[customer_number=ab-1001]` +matches a contact stored with ` AB-1001 `. Whitespace inside the value stays significant: `MELO 001` +does not match `MELO001`. + #### Use case requirements `wait_for_use_cases` takes **inbound use case slugs** — the same `use_case_slug` you already send on From 561c4ee969916c6aa84245bd2df5ead179b0171f Mon Sep 17 00:00:00 2001 From: Karl Rankla Date: Tue, 13 Oct 2026 14:47:21 +0300 Subject: [PATCH 06/19] docs(integration-toolkit): list the interactive lane monitoring codes --- .../integration-toolkit/monitoring/codes.md | 18 +++++++++++++++++- scripts/update-monitoring-codes.js | 3 +++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/docs/integrations/integration-toolkit/monitoring/codes.md b/docs/integrations/integration-toolkit/monitoring/codes.md index 394eebd..d7a0f3b 100644 --- a/docs/integrations/integration-toolkit/monitoring/codes.md +++ b/docs/integrations/integration-toolkit/monitoring/codes.md @@ -15,7 +15,7 @@ Every monitoring event carries a **code** and a **level**. The code says what happened; the level says how much you should care. Both are filterable in the Integration Hub's [Monitoring tab](./overview.md) and through the events API. -There are 85 codes. You are most likely here because you saw one in a failed +There are 98 codes. You are most likely here because you saw one in a failed event — find it below. :::tip @@ -23,6 +23,9 @@ A code's level is fixed. That is what makes alert rules scoped to `_error_` or `_warning_` predictable — see [Alerting](./alerting.md#choosing-what-to-watch). ::: +The interactive lane codes (`INTERACTIVE_*` and `CORRELATION_*`) are explained in +context, with when each is raised, in [Interactive Registration](../interactive-registration.md#monitoring). + ## Error Something failed and the event did not do what it was meant to do. These are what the default alert rules watch. @@ -32,6 +35,8 @@ Something failed and the event did not do what it was meant to do. These are wha | `ATTACHMENT_NOT_FOUND` | The file no longer exists — it was removed between the event and the delivery | | `ATTRIBUTE_TYPE_MISMATCH` | An attribute value did not match the type declared in the entity schema | | `CONDITIONAL_VARIANT_WRITE_FAILED` | Conditional pricing refused one variant write. The item is dropped and not retried; the rest of the batch and the run continue. details.code names the reason where pricing gave one — UNKNOWN_ERROR means it did not | +| `CORRELATION_ID_MISSING` | A request asked for something that only makes sense for a correlation, but did not say which correlation | +| `CORRELATION_REQUIREMENT_FAILED` | Something a caller was waiting for can no longer arrive: every event that would have satisfied the requirement ended in an error | | `DEPRECATED_ENDPOINT` | This endpoint version is deprecated | | `DIRECT_ENTITY_NOT_ALLOWED` | The entity is not permitted by the use case entity allowlist | | `DIRECT_PAYLOAD_INVALID` | The direct mode payload failed validation against the versioned payload schema | @@ -96,8 +101,16 @@ Processing continued, but something needs a human eye — often a retry in fligh |---|---| | `ACK_TIMEOUT` | Acknowledgement timed out waiting for the ERP system | | `CONDITIONAL_VARIANT_WRITE_WARNING` | One variant write succeeded with a warning. The variant is stored and the rest of the batch and the run continue. details.code names the warning | +| `CORRELATION_CLOSED_INCOMPLETE` | The partner declared a correlation complete and everything it sent was processed, but what the caller was waiting for was not in it — see `missing_entities` and `missing_use_cases`. Reported once per correlation | +| `CORRELATION_CLOSE_IGNORED` | The partner declared a correlation complete, but the close was not applied: either the same request has to be retried, or none of its events was processed as interactive (the use case does not allow it, or the request did not ask for it). Retry with `correlation_complete`, or send the events with `interactive: true` | +| `CORRELATION_EVENT_LIMIT_EXCEEDED` | This correlation already holds as many events as one correlation may, so further events sent under it were processed normally and cannot be waited on. A correlation is meant to cover one registration or one change; this usually means the partner sends the same correlation id with every push. Each request that should be waited on needs its own id | +| `CORRELATION_LATE_EVENT` | An event arrived under a correlation the partner had already declared complete. It was processed, but anyone waiting on that correlation had already been answered — the partner is sending `correlation_complete` too early | +| `CORRELATION_WAIT_REJECTED` | A caller tried to wait on a correlation while too many waits were already open, so it was asked to retry. Its data is unaffected — only the answer it was waiting for is slower | +| `CORRELATION_WAIT_TIMEOUT` | A caller waited on a correlation until its time ran out without getting what it asked for — see `missing_entities` and `missing_use_cases` for what had not arrived. Reported once per correlation | | `EXTERNAL_WARNING` | A warning span pushed by an external system via the external monitoring events endpoint. | | `FILE_PROXY_UPLOAD_RETRYING` | A file upload failed with a retryable error and will be retried automatically — one event per attempt | +| `INTERACTIVE_CROSS_LANE_WRITE` | The same entity was updated from two different processing lanes within seconds of each other, one of them the interactive lane. Both updates were applied; this reports that they overlapped, so the order they landed in was decided by timing | +| `INTERACTIVE_RATE_LIMITED` | The organization asked for interactive processing more often than its agreed rate, so these events were processed on its normal lane instead. Nothing was lost and the correlation can still be waited on — it will simply take as long as a bulk event does | | `LOOKUP_UNMAPPED` | A value was not listed in a lookup table and its fallback was used — see lookup_name and lookup_key for the gap | | `MSG_LATE_ARRIVAL` | An event arrived after the poll consumer had already received later events, so it was placed at the end of the stream instead of at its event time — it is delivered, but out of event-time order | | `SOFT_DELETED_ENTITY_MATCHED` | A soft-deleted entity matched the unique ID — it will be resurrected on upsert, or referenced as-is by a relation. Investigate why the ERP source is sending events for a deleted entity. | @@ -130,10 +143,13 @@ Lifecycle markers rather than outcomes: a message was queued, a duplicate was ig | Code | What it means | |---|---| | `ACK_PENDING` | Acknowledgement is pending from the ERP system | +| `CORRELATION_CLOSED_EMPTY` | The partner declared a correlation complete without sending any event for it. That is the partner reporting "I found nothing", and anyone waiting is told so at once instead of waiting it out | +| `CORRELATION_COMPLETED` | Every event the partner sent under this correlation has finished processing and the partner declared the correlation complete | | `DUPLICATE_EVENT` | This event was already processed (duplicate) | | `EXTERNAL_INFO` | An informational span pushed by an external system via the external monitoring events endpoint. | | `FAN_OUT_EMPTY` | The split expression returned an empty list, so nothing was sent — expected for events that carry no relevant items | | `FILE_PROXY_UPLOAD_ENQUEUED` | A per-file upload was accepted for delivery during fan-out | +| `INTERACTIVE_NOT_ALLOWED` | Interactive processing was requested for a use case that is not configured for it, so the events were processed normally. Set `interactive` to `on_request` or `always` on the use case if the caller is meant to be able to wait for these events | | `MSG_ENQUEUED` | Outbound message enqueued to the poll queue, awaiting consumption by the ERP. When the use case maps the payload, the detail carries the mapped payload as delivered and its mapping_version (or payload_omitted when it exceeds 48 KiB) | ## Status-code families diff --git a/scripts/update-monitoring-codes.js b/scripts/update-monitoring-codes.js index 08ff962..f4228f9 100644 --- a/scripts/update-monitoring-codes.js +++ b/scripts/update-monitoring-codes.js @@ -118,6 +118,9 @@ A code's level is fixed. That is what makes alert rules scoped to \`_error_\` or \`_warning_\` predictable — see [Alerting](./alerting.md#choosing-what-to-watch). ::: +The interactive lane codes (\`INTERACTIVE_*\` and \`CORRELATION_*\`) are explained in +context, with when each is raised, in [Interactive Registration](../interactive-registration.md#monitoring). + ${sections.join('\n')}${familySection}`; } From 1fecda9b47f60392a4a6550504eccf0f7cc07c57 Mon Sep 17 00:00:00 2001 From: Karl Rankla Date: Wed, 14 Oct 2026 10:28:45 +0300 Subject: [PATCH 07/19] docs(integration-toolkit): fix the v3 ingest examples Getting Started, the configuration reference and the examples showed the v1 request shape on the v3 endpoint: meta.correlation_id and event_type, both of which v3 drops without an error, so a partner copying them got a generated correlation id and could never wait on its own. Step 2 created a use case without the required slug, and step 3 documented a response body the API never returns. --- .../integration-toolkit/configuration.md | 28 +++++++++++-------- .../integration-toolkit/inbound/examples.md | 3 +- .../inbound/getting-started.md | 21 +++++++++----- 3 files changed, 31 insertions(+), 21 deletions(-) diff --git a/docs/integrations/integration-toolkit/configuration.md b/docs/integrations/integration-toolkit/configuration.md index 4c4be2f..6c7f71d 100644 --- a/docs/integrations/integration-toolkit/configuration.md +++ b/docs/integrations/integration-toolkit/configuration.md @@ -540,15 +540,14 @@ curl -X POST 'https://integration-toolkit.sls.epilot.io/v1/secure-proxy' \ ### Event Message Structure +The structure of a `POST /v3/erp/updates/events` request: + ```json { "integration_id": "uuid", - "meta": { - "correlation_id": "string" - }, + "correlation_id": "string (optional)", "events": [ { - "event_type": "CREATE | UPDATE | DELETE", "use_case_slug": "string (recommended, 1-255 chars, pattern: ^[a-z0-9][a-z0-9_-]*$, required if event_name is not provided)", "event_name": "string (legacy fallback, required if use_case_slug is not provided)", "timestamp": "ISO 8601 datetime", @@ -562,9 +561,15 @@ curl -X POST 'https://integration-toolkit.sls.epilot.io/v1/secure-proxy' \ For each event, include `use_case_slug` whenever possible. `event_name` is supported as a legacy fallback, and `use_case_slug` takes precedence if both are present. +v3 takes `correlation_id` at request level and has no `event_type`. The deprecated v1 and v2 endpoints take +`event_type` and, on v1, a `meta` object; v3 drops both without an error, so a `meta.correlation_id` sent to v3 is +lost. + `use_case_slug` format: `^[a-z0-9][a-z0-9_-]*$` with length `1..255` (must start with a lowercase letter or digit; then lowercase letters, digits, `_`, `-`). -### Event Types +### Event Types (v1 and v2) + +The deprecated v1 and v2 endpoints classify each event with `event_type`. v3 has no such field. | Type | Description | |------|-------------| @@ -578,7 +583,6 @@ For each event, include `use_case_slug` whenever possible. `event_name` is suppo ```json { - "event_type": "UPDATE", "format": "json", "payload": "{\"customerId\":\"C001\",\"name\":\"John Doe\"}" } @@ -588,7 +592,6 @@ For each event, include `use_case_slug` whenever possible. `event_name` is suppo ```json { - "event_type": "UPDATE", "format": "xml", "payload": "C001John Doe" } @@ -604,7 +607,6 @@ Prevent duplicate processing by including a unique deduplication ID: { "events": [ { - "event_type": "UPDATE", "deduplication_id": "customer-C001-update-20240115-001", "payload": "..." } @@ -633,17 +635,19 @@ customer-C001 ### Correlation ID -Include a correlation ID to track related events: +Include a correlation ID at request level to track related events: ```json { - "meta": { - "correlation_id": "" - }, + "integration_id": "uuid", + "correlation_id": "", "events": [...] } ``` +When you omit it, epilot generates one and returns it in the response. For a correlation id an end customer is +waiting on, see [Interactive Registration](./interactive-registration.md#correlation-ids). + ### Using Correlation IDs - Group related events for debugging diff --git a/docs/integrations/integration-toolkit/inbound/examples.md b/docs/integrations/integration-toolkit/inbound/examples.md index a7b63ec..86de31b 100644 --- a/docs/integrations/integration-toolkit/inbound/examples.md +++ b/docs/integrations/integration-toolkit/inbound/examples.md @@ -98,9 +98,8 @@ curl -X POST 'https://integration-toolkit.sls.epilot.io/v3/erp/updates/events' \ -H 'Content-Type: application/json' \ -d '{ "integration_id": "", - "meta": { "correlation_id": "customer-sync-001" }, + "correlation_id": "customer-sync-001", "events": [{ - "event_type": "UPDATE", "use_case_slug": "customer-sync", "timestamp": "2024-01-15T10:00:00Z", "format": "json", diff --git a/docs/integrations/integration-toolkit/inbound/getting-started.md b/docs/integrations/integration-toolkit/inbound/getting-started.md index 13377be..4423ef6 100644 --- a/docs/integrations/integration-toolkit/inbound/getting-started.md +++ b/docs/integrations/integration-toolkit/inbound/getting-started.md @@ -46,7 +46,8 @@ Save the `id` — you'll need it for subsequent API calls. ## Step 2: Create a Use Case -A use case defines how specific data types are mapped and synchronized. Create an inbound use case for customer data: +A use case defines how specific data types are mapped and synchronized. Create an inbound use case for customer data. The +`slug` is required: it is the key you route events to this use case by in step 3. ```bash title="Create a use case" curl -X POST 'https://integration-toolkit.sls.epilot.io/v1/integrations/{integrationId}/use-cases' \ @@ -54,6 +55,7 @@ curl -X POST 'https://integration-toolkit.sls.epilot.io/v1/integrations/{integra -H 'Content-Type: application/json' \ -d '{ "name": "Customer Sync", + "slug": "customer-sync", "type": "inbound", "enabled": true, "configuration": { @@ -83,12 +85,9 @@ curl -X POST 'https://integration-toolkit.sls.epilot.io/v3/erp/updates/events' \ -H 'Content-Type: application/json' \ -d '{ "integration_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", - "meta": { - "correlation_id": "" - }, + "correlation_id": "", "events": [ { - "event_type": "UPDATE", "use_case_slug": "customer-sync", "timestamp": "2024-01-15T10:35:00Z", "format": "json", @@ -101,20 +100,28 @@ curl -X POST 'https://integration-toolkit.sls.epilot.io/v3/erp/updates/events' \ Use `use_case_slug` for event routing (recommended). `event_name` remains available for backward compatibility, and `use_case_slug` takes precedence when both are provided. Format: `^[a-z0-9][a-z0-9_-]*$` (1-255 chars; must start with lowercase letter or digit). +`correlation_id` is a request-level field and optional. v3 has no `event_type` and no `meta` object — they belong to +the deprecated v1 and v2 endpoints — and drops both without an error: a `meta.correlation_id` sent to v3 is lost, and +the response carries a generated id instead. + **Response:** ```json title="Event accepted" { + "correlation_id": "0f5c2a8e-3b7d-4e19-9a64-5d2c8b1e7f30", "results": [ { - "event_id": "evt_abc123", "status": "success", - "message": "Event queued for processing" + "event_id": "0199c3f2-8a41-7d0e-b6c7-2f1a9d3e4b55" } ] } ``` +`correlation_id` echoes the id you sent, or the one epilot generated when you sent none. Each result carries the +`status` of the event (`success`, `skipped` for a duplicate, `ignored` or `error`, the last three with a +`message`) and its `event_id`. + ## Processing Flow Events flow through the following pipeline: From 1c21486632f5220d1f37f923736a125db62c74bd Mon Sep 17 00:00:00 2001 From: Karl Rankla Date: Wed, 14 Oct 2026 10:30:06 +0300 Subject: [PATCH 08/19] docs(integration-toolkit): state the wait selector encoding contract One wait_for_entities requirement per parameter occurrence, never split on commas, each value percent-encoded exactly once - as curl --data-urlencode, URLSearchParams and the SDK do. The comma-separated form stays for wait_for_use_cases only. The page previously offered comma-joined entity requirements and said --data-urlencode carries a comma, which the dev e2e run showed splitting a value into an injected second requirement. --- .../interactive-registration.md | 77 ++++++++++++++++--- 1 file changed, 68 insertions(+), 9 deletions(-) diff --git a/docs/integrations/integration-toolkit/interactive-registration.md b/docs/integrations/integration-toolkit/interactive-registration.md index 33f681c..3a603e4 100644 --- a/docs/integrations/integration-toolkit/interactive-registration.md +++ b/docs/integrations/integration-toolkit/interactive-registration.md @@ -467,12 +467,12 @@ needs has arrived. |---|---| | `correlation_id` | Required. The correlation to look at. | | `wait` | Seconds to wait, `0` to `25`. Omit or `0` for an immediate answer. The ceiling is 25 s because the HTTP API cuts off at 30 s. A value outside the range is rejected with **400** naming the maximum, not silently clamped. | -| `wait_for_entities` | Entity requirements, each `slug` or `slug[key=value]`. Repeat the parameter for several, or separate them with commas. A selector value matches ignoring case and surrounding whitespace, like entity lookup. | -| `wait_for_use_cases` | Inbound **use case slugs**. Repeat the parameter or separate with commas. | +| `wait_for_entities` | Entity requirements, each `slug` or `slug[key=value]`. **One requirement per occurrence**: repeat the parameter for several. An occurrence is never split, so a selector value may contain commas. A selector value matches ignoring case and surrounding whitespace, like entity lookup. See [Selector syntax and encoding](#selector-syntax-and-encoding). | +| `wait_for_use_cases` | Inbound **use case slugs**. Repeat the parameter, or separate the slugs with commas in one occurrence. | Both requirement sets are optional, and both are only valid together with `correlation_id` — sending -one without it returns 400. When both are given, **both** must be satisfied before the call answers -`requirements_met`. +one without it returns 400. Each set takes at most 20 requirements. When both are given, **both** must +be satisfied before the call answers `requirements_met`. ```bash title="Wait for the customer's contact and a contract" curl -G 'https://integration-toolkit.sls.epilot.io/v3/erp/updates/events' \ @@ -593,11 +593,70 @@ that entity by attribute: wait on a requirement that is unambiguous — such as right contact through the delivered contract's `customer` relation, by `_id`. A contract is only reported once its relations are set, so that relation is there when the wait returns. -**Selector syntax.** Inside `slug[key=value]` the key ends at the **first** `=`, and the value runs to -the final `]`, so a value may itself contain `=` and `]`. The value is percent-decoded once after that -split. To pass a value that contains a comma, either repeat the parameter — one requirement per -occurrence, which avoids the comma separator altogether — or percent-encode the comma. The -`curl -G --data-urlencode` form above encodes for you. +##### Selector syntax and encoding + +Inside `slug[key=value]` the key ends at the **first** `=`, and the value runs to the final `]`, so a +value may itself contain `=`, `]` and `,`. + +Each `wait_for_entities` parameter is exactly **one** requirement. epilot never splits it on commas, so +a value that contains a comma needs no special treatment. Encode every parameter **once**, the +standard way for a query string, and epilot decodes it once — which is exactly what +`curl -G --data-urlencode`, `URLSearchParams` and the SDK do. Two things break that: + +- **Encoding the value yourself before handing it to one of those.** The value is then encoded twice, + and epilot receives the escape sequence as text: a value `A,B` that you pre-encode to `A%2CB` + arrives as the text `A%2CB` and matches nothing. +- **Joining several entity requirements with commas into one parameter.** The joined text is read as + a single requirement, which is either rejected with **400** or a selector whose value contains the + rest of the list and never matches. The comma-separated form exists only for `wait_for_use_cases`, + whose slugs cannot contain a comma. + +```bash title="A contract number that contains a comma and a slash" +curl -G 'https://integration-toolkit.sls.epilot.io/v3/erp/updates/events' \ + -H 'Authorization: Bearer ' \ + --data-urlencode 'correlation_id=018f8e9b-5a1b-7c4e-9b2a-4f0f6c1d7a21' \ + --data-urlencode 'wait=25' \ + --data-urlencode 'wait_for_entities=contact[customer_number=4711]' \ + --data-urlencode 'wait_for_entities=contract[contract_number=2001/84,A]' +``` + +On the wire, the second requirement is +`wait_for_entities=contract%5Bcontract_number%3D2001%2F84%2CA%5D` — one parameter, encoded once. + +With the SDK, pass one array item per requirement, with the raw value; the client encodes each item +once and repeats the parameter: + +```ts title="The same wait with the SDK" +import { authorize, getClient } from '@epilot/sdk/integration-toolkit' + +const toolkit = getClient() +authorize(toolkit, () => '') + +const { data } = await toolkit.queryErpUpdateEvents({ + correlation_id: correlationId, + wait: 25, + wait_for_entities: [ + `contact[customer_number=${customerNumber}]`, + `contract[contract_number=${contractNumber}]`, + ], + wait_for_use_cases: ['customer_account', 'contract'], +}) +``` + +Without the SDK, build the query with `URLSearchParams` and `append` each requirement: + +```ts title="The same wait with axios and URLSearchParams" +const params = new URLSearchParams({ correlation_id: correlationId, wait: '25' }) +params.append('wait_for_entities', `contact[customer_number=${customerNumber}]`) +params.append('wait_for_entities', `contract[contract_number=${contractNumber}]`) +params.append('wait_for_use_cases', 'customer_account') +params.append('wait_for_use_cases', 'contract') + +const { data } = await axios.get('https://integration-toolkit.sls.epilot.io/v3/erp/updates/events', { + params, + headers: { Authorization: `Bearer ${token}` }, +}) +``` **Value matching.** The value is compared the way the toolkit's own entity lookup compares unique ids: ignoring letter case and surrounding whitespace on both sides, so `contact[customer_number=ab-1001]` From 0ae5ba2da87b8380669f13cec042c4454275a239 Mon Sep 17 00:00:00 2001 From: Karl Rankla Date: Wed, 14 Oct 2026 10:31:10 +0300 Subject: [PATCH 09/19] docs(integration-toolkit): correct when a wait answers error, timeout and complete - An entity requirement whose event failed answers error only once the correlation is complete; on an open correlation it runs to timeout. The page claimed a wait never hangs on a failed event. - Events that write nothing because the use case is disabled or unconfigured, or because extraction failed, end error or partial. - partial counts as an error for wait_for_use_cases. - A complete correlation's answer is final; late events are processed and counted but never change it. - The error example used MISSING_UNIQUE_IDENTIFIERS, which ingest rejects with 422 before anything is recorded; it now shows ENTITY_REFERENCE_NOT_FOUND. - wait=0 on an undecided correlation reads timeout, CLOSED_INCOMPLETE is raised only by waits above 0, and a wait on an unknown correlation id answers at once instead of holding a long poll. --- .../interactive-registration.md | 65 +++++++++++++------ 1 file changed, 46 insertions(+), 19 deletions(-) diff --git a/docs/integrations/integration-toolkit/interactive-registration.md b/docs/integrations/integration-toolkit/interactive-registration.md index 3a603e4..f06d235 100644 --- a/docs/integrations/integration-toolkit/interactive-registration.md +++ b/docs/integrations/integration-toolkit/interactive-registration.md @@ -184,7 +184,7 @@ curl -X POST 'https://integration-toolkit.sls.epilot.io/v3/erp/updates/events' \ ``` The portal's wait ends `complete` as soon as that one event is processed, its contract requirement -unmet — monitoring records that as `CORRELATION_CLOSED_INCOMPLETE`, naming the missing contract — and +unmet — the wait raises `CORRELATION_CLOSED_INCOMPLETE` in monitoring, naming the missing contract — and it can tell the end customer "we found your account, your contract is not in our system yet" — in seconds. The wrong move is to push the partial bundle and leave the correlation open in the hope that the rest turns up: the portal then waits out its whole budget and the end customer gets a @@ -444,7 +444,14 @@ waits. ``` `status` is `accepted`, `processing`, `done`, `partial` (a non-retryable failure after some writes -succeeded) or `error`. `writes_total` and `writes_done` count the entity writes the event fanned out +succeeded) or `error` (a non-retryable failure with nothing written). An event that cannot write what +its use case describes is a failure, not a success with nothing to do: the use case is disabled +(`USE_CASE_DISABLED`), it has no configuration for the event (`EVENT_NOT_CONFIGURED`), or the mapping +could not extract an entity or a field it should have written. Such an event ends `error`, or +`partial` when other writes of it succeeded. An event that the mapping deliberately filters out +writes nothing and is still `done`. + +`writes_total` and `writes_done` count the entity writes the event fanned out into, including follow-up writes such as stub creates, so `writes_done == writes_total` on a `done` record means the whole tree is finished — not just the first write. A write that found the entity already as described still counts, and appears in `entities` with `"operation": "unchanged"`. @@ -466,7 +473,7 @@ needs has arrived. | Parameter | Meaning | |---|---| | `correlation_id` | Required. The correlation to look at. | -| `wait` | Seconds to wait, `0` to `25`. Omit or `0` for an immediate answer. The ceiling is 25 s because the HTTP API cuts off at 30 s. A value outside the range is rejected with **400** naming the maximum, not silently clamped. | +| `wait` | Seconds to wait, `0` to `25`. Omit or `0` for an immediate answer, which reads `completion: "timeout"` while nothing is decided yet. The ceiling is 25 s because the HTTP API cuts off at 30 s. A value outside the range is rejected with **400** naming the maximum, not silently clamped. | | `wait_for_entities` | Entity requirements, each `slug` or `slug[key=value]`. **One requirement per occurrence**: repeat the parameter for several. An occurrence is never split, so a selector value may contain commas. A selector value matches ignoring case and surrounding whitespace, like entity lookup. See [Selector syntax and encoding](#selector-syntax-and-encoding). | | `wait_for_use_cases` | Inbound **use case slugs**. Repeat the parameter, or separate the slugs with commas in one occurrence. | @@ -538,9 +545,16 @@ finished — not when the event was received. Anything looser would be a lie: a is ready" while the write is still queued would read an entity that does not exist yet, which is the failure mode this whole page exists to remove. -An event that ends in a non-retryable error resolves its requirement too, into -`completion: "error"`. A wait never hangs on a failed event waiting for something that can no longer -come. +A failed event does not always end the wait at once: + +- A **use case requirement** resolves into `completion: "error"` as soon as every event of that use + case received so far has ended in error. +- An **entity requirement** cannot be traced to a single event, so it resolves into `error` only once + the correlation is **complete** — you closed it with `correlation_complete` and every event finished — + with the entity still missing and an event failed. On a correlation that is still open, a wait on an + entity whose event failed runs to its budget and answers `timeout`, although the failure is in + `errors[]` from the moment it happens. Closing the correlation is what turns that into an immediate + `error`. #### Entity requirements, with selectors on `unique_ids` @@ -671,9 +685,11 @@ the ingest event. That is deliberate: the v3 events endpoint recommends `use_cas slug is unique within its integration and immutable once set. `event_name` is partner vocabulary and a fallback; it is not a stable key for a requirement. -A requirement is met when **at least one event of that use case slug is done**. There are no -selectors on use case slugs in this version. The response's `use_cases` array gives the per-use-case -summary so a caller can see which side of the contract is still open. +A requirement is met when **at least one event of that use case slug is done**. An event that ends +`partial` does not meet it: for a use case requirement, `partial` counts as an error, because part of +what the use case should have written is missing. There are no selectors on use case slugs in this +version. The response's `use_cases` array gives the per-use-case summary so a caller can see which side +of the contract is still open. #### One use case per partner event type @@ -758,25 +774,31 @@ the kind of data that never came. Neither set can say that you have finished; on | Value | Meaning | |---|---| | `requirements_met` | Every requirement given — entity requirements, use case requirements, or both — is satisfied. The wait returns the moment this becomes true, **even if an unrelated event of the correlation failed**: a caller waiting for a contact and a contract has what it asked for. | -| `complete` | The correlation is closed and every event received for it is done. This is the hard stop for a caller that wants everything — and it also ends a requirement wait that can no longer be satisfied, which is how the portal learns that you sent an account but no contract. That ending raises `CORRELATION_CLOSED_INCOMPLETE` in monitoring, once per correlation, naming what is missing. | -| `timeout` | The wait budget elapsed. The body still lists everything that landed so far; call again to continue waiting. The first wait that runs out on a correlation raises `CORRELATION_WAIT_TIMEOUT`; later ones on the same correlation do not, and a `wait=0` read never does. | -| `error` | A requirement can no longer be satisfied, because an event it depends on failed non-retryably — or, when the caller named no requirements, the correlation closed with failures. | +| `complete` | The correlation is closed and every event received for it is done. This is the hard stop for a caller that wants everything — and it also ends a requirement wait that can no longer be satisfied, which is how the portal learns that you sent an account but no contract. A wait (`wait` above 0) that ends this way raises `CORRELATION_CLOSED_INCOMPLETE` in monitoring, once per correlation, naming what is missing; a `wait=0` read that sees the same state raises nothing. | +| `timeout` | The wait budget elapsed, or — for a `wait=0` read — nothing is decided yet. The body still lists everything that landed so far; call again to continue waiting. The first wait that runs out on a correlation raises `CORRELATION_WAIT_TIMEOUT`; later ones on the same correlation do not, and a `wait=0` read never does. | +| `error` | A requirement can no longer be satisfied: every event of a required use case failed, or the correlation is complete with a required entity missing and an event failed (see [What "arrived" means](#what-arrived-means)). When the caller named no requirements: the correlation is complete and an event of it failed. | + +**Once a correlation is complete, its answer is final.** The verdict is fixed at the moment the +correlation becomes complete. An event that arrives after that is still processed and counted in +`late_events`, and raises `CORRELATION_LATE_EVENT`, but it never changes `completion` — a waiter that +was told `complete` or `requirements_met` is never contradicted by a later read. `errors[]` lists every failure in the correlation whatever the verdict, so a `requirements_met` that ignored an unrelated failure still tells you about it. If the contract event of the example had -arrived without a contract number, the wait for `contract` would end like this: +linked its billing account by an `_id` that does not exist, a wait for the `contract` use case +(`wait_for_use_cases=contract`) would end like this: ```json title="Response (excerpt) — a requirement that can no longer be met" { "completion": "error", "use_cases": [ - { "use_case_slug": "customer_account", "received": 1, "done": 1, "error": 0 }, - { "use_case_slug": "contract", "received": 1, "done": 0, "error": 1 } + { "use_case_slug": "contract", "received": 1, "done": 0, "error": 1 }, + { "use_case_slug": "customer_account", "received": 1, "done": 1, "error": 0 } ], "errors": [ { - "code": "MISSING_UNIQUE_IDENTIFIERS", - "message": "contract: unique identifier contract_number is missing", + "code": "ENTITY_REFERENCE_NOT_FOUND", + "message": "Referenced entity 7d2e4c19-0b6a-4f3e-9a51-c8e2d0f4b6a7 not found (expected schema 'billing_account')", "event_id": "0199c3f5-1b07-7a2e-8d44-9e3c6f0a2b18", "occurred_at": "2026-10-09T09:05:32.640Z" } @@ -784,12 +806,17 @@ arrived without a contract number, the wait for `contract` would end like this: } ``` +An event that lacks the unique identifiers its target needs never gets this far: ingest rejects it +with **422** before anything is recorded, so it never reaches the correlation. + There is no quiet-window heuristic: with no requirements and no close, a wait runs to its budget and then returns `timeout`. A correlation id that epilot has never seen — or one that belongs to non-interactive traffic — comes -back as `events_received: 0, closed: false`. The caller cannot tell "not yet" from "never", which is -exactly the ambiguity `correlation_complete` exists to remove. +back as `events_received: 0, closed: false` with `completion: "timeout"`. Such a wait does not hold a +long poll for its budget: it answers immediately, at most after a short grace period, and the caller +calls again. The caller cannot tell "not yet" from "never", which is exactly the ambiguity +`correlation_complete` exists to remove. :::note Concurrent waits are capped Long polls are limited per organization and globally. Past the cap the call answers **429** with a From 5f6687ae89cb33c97f05891cf05dd1e8292eaa25 Mon Sep 17 00:00:00 2001 From: Karl Rankla Date: Wed, 14 Oct 2026 10:32:14 +0300 Subject: [PATCH 10/19] docs(integration-toolkit): spell out the close rules, the always mode and the interactive false reasons - always makes every event of the use case interactive and charged, including requests without the flag and without a correlation id. - interactive:false also happens at the 32-event cap, for an always event with an invalid correlation id, and when the state store is unavailable. - A retried closing request closes even when all its events are duplicates, a close beyond the cap still closes, a mixed close covers only the interactive events, and retries and closes need a deduplication_id. - A wait field on ingest is ignored, not a 400. - Monitoring table: CLOSE_IGNORED also covers the retry case, CLOSED_INCOMPLETE and REQUIREMENT_FAILED are raised once and never by wait=0, CROSS_LANE_WRITE is reported in either order, and CORRELATION_ID_MISSING carries no correlation id. --- .../integration-toolkit/configuration.md | 4 +- .../interactive-registration.md | 65 ++++++++++++++----- 2 files changed, 51 insertions(+), 18 deletions(-) diff --git a/docs/integrations/integration-toolkit/configuration.md b/docs/integrations/integration-toolkit/configuration.md index 6c7f71d..bb02028 100644 --- a/docs/integrations/integration-toolkit/configuration.md +++ b/docs/integrations/integration-toolkit/configuration.md @@ -148,7 +148,9 @@ Beyond the mapping itself, an inbound use case's `configuration` accepts the fol With `on_request` — the normal setting — one mapping serves both the nightly batch and the registration push, and the inbound request decides which it is by sending `interactive: true`. With `disabled`, a request that asks for interactive is processed as bulk and told so per event; nothing -is rejected. With `always`, every event of the use case is interactive. +is rejected. With `always`, every event of the use case is interactive — also requests that do not ask +and carry no correlation id — and every one is charged to the organization's interactive budget, so +reserve it for a use case that serves registrations only. If a caller is going to wait on your use cases by slug (`wait_for_use_cases`), model one inbound use case per partner event type and give every use case a slug: a use case that maps several kinds of diff --git a/docs/integrations/integration-toolkit/interactive-registration.md b/docs/integrations/integration-toolkit/interactive-registration.md index f06d235..b759a0b 100644 --- a/docs/integrations/integration-toolkit/interactive-registration.md +++ b/docs/integrations/integration-toolkit/interactive-registration.md @@ -122,9 +122,12 @@ body, and the portal waits on yours instead of its own. If an inbound use case exists only to serve registrations, epilot can set it to `interactive: "always"` (see [Enabling it on the use case](#enabling-it-on-the-use-case)). Every event of that use case is then interactive whether the request says so or not, and your part shrinks to the -`correlation_id` alone. Sending `interactive: true` yourself is the default because one mapping -usually serves both the nightly batch and the registration push, and only the request knows which is -which. +`correlation_id` alone. That includes requests without the flag, events that send +`interactive: false`, and requests without a correlation id, which get a generated one — and every +such event is charged to the organization's interactive budget (see [Rate limits](#rate-limits)). A +bulk sync routed to an `always` use case therefore drains the budget the registrations need. Sending +`interactive: true` yourself is the default because one mapping usually serves both the nightly batch +and the registration push, and only the request knows which is which. ::: **Strongly recommended:** close the correlation with `correlation_complete: true` once everything for @@ -141,8 +144,11 @@ match. Where the close goes depends on how you push: [Closing a correlation](#closing-a-correlation) has both patterns. If you cannot place the close correctly, leave it out: a close that arrives too early does more harm than no close. -A request without `correlation_id` behaves exactly as it does today. Nothing breaks if you never adopt -any of this; the portal simply keeps polling entity search. +A request that does not ask for interactive processing is processed exactly as before. The one visible +change is that every v3 response now carries a `correlation_id` — yours, or a generated one when you +sent none. The exception is a use case set to `interactive: "always"`, whose events are interactive +whatever the request says. Nothing breaks if you never adopt any of this; the portal simply keeps +polling entity search. ## When the ERP cannot deliver @@ -217,7 +223,7 @@ registration push, so the use case grants permission and the request decides. |---|---| | `disabled` (default) | The events are processed as bulk. Each result says `"interactive": false` with the message `use case does not allow interactive processing`, no processing record is written, and an `INTERACTIVE_NOT_ALLOWED` info event appears in monitoring. Nothing is rejected. | | `on_request` | The request's `interactive: true` selects the interactive lane. This is the normal setting. | -| `always` | Every event of this use case is interactive, whether the request asks or not. Use it for a use case that exists only to serve registrations. The per-organization rate limit still applies. | +| `always` | Every event of this use case is interactive, whether the request asks or not — including requests without the flag, events that send `interactive: false` and requests without a correlation id (which get a generated one). Every one of those events is charged to the organization's interactive budget. Use it only for a use case that exists solely to serve registrations. | The option is also exposed in the Integration Hub on the inbound use case. See [Use Case Configuration](./configuration.md#use-case-configuration) for the surrounding contract. @@ -300,7 +306,15 @@ wants the lane, without a portal in the loop, still gets an id it can query late The two result fields answer different questions and are independent of each other: - `interactive` means **processed as interactive**: a processing record was written and the wait works - for this event. It is `false` only when the use case does not allow interactive processing. + for this event. It is `false`, with a `message` saying why, when + - the use case does not allow interactive processing; + - the correlation already holds its 32 events (see [Correlation ids](#correlation-ids)); + - the event belongs to an `always` use case and its correlation id breaks the + [id format](#correlation-ids) — the request did not ask for interactive, so it is not rejected; + - the processing-state store is unavailable, so no record could be written. + + In every case the event is processed as bulk on the organization's normal lane, where the wait + cannot see it. Nothing is rejected. - `lane` is the queue that actually carried the event — `interactive`, `priority`, `default` or `deferred`. Over the rate limit an event is still `"interactive": true` but runs on `"lane": "default"` (or the organization's other normal lane); see [Rate limits](#rate-limits). @@ -373,6 +387,11 @@ curl -X POST 'https://integration-toolkit.sls.epilot.io/v3/erp/updates/events' \ "Accepted" means the request answered with success for every event. If any push is answered with a retry (a 422 with partial results, or a 429), retry it until it is accepted before you send the close. +**Give every event a `deduplication_id`** whenever you may retry the request — which includes every +request that closes a correlation. Without one, a retried event is a new event to epilot: it is +processed and counted a second time, and a closing request whose response was lost cannot be told +apart from a new one. + The rules: - `correlation_complete` closes the **request-level** `correlation_id`, and only that one. epilot never @@ -383,11 +402,20 @@ The rules: Closing twice is a no-op. - The close is applied only when **every** event of the request was accepted. A response that asks you to retry — a 422 with partial results, or a 429 — closes nothing, so a close can never overtake - events that are still being retried. + events that are still being retried. Monitoring raises `CORRELATION_CLOSE_IGNORED`. +- **A retried closing request still closes**, even when every one of its events is a duplicate. If the + response to your closing request was lost and you send it again with the same `deduplication_id`s, + the events are skipped as duplicates and the close is applied to the correlation they were + registered under the first time. +- **A close beyond the cap still closes.** When some or all of the request's events went over the + correlation's 32 events and were processed as plain bulk, the correlation exists, so it is closed. - A request that carries events of which **none** was processed as interactive — the use case does not allow it, or the request left out `interactive` — neither creates nor closes the correlation. Those events went to bulk, where the wait cannot see them, so the close is ignored and monitoring raises `CORRELATION_CLOSE_IGNORED`. +- A request that **mixes** interactive and bulk events — `interactive` set per event, or events for a + use case that does not allow it next to one that does — closes the correlation. `complete` then + covers only its interactive events: the bulk ones are processed, but the wait never sees them. - A request may carry **no events at all** when it sets `correlation_complete: true` — the parallel pattern above, or a signal that the operation finished that arrives on its own, with no data attached. This is the one case @@ -399,7 +427,8 @@ The rules: An event that arrives **after** the correlation was closed is still processed normally. It is counted as a late event and raises a `CORRELATION_LATE_EVENT` warning in monitoring, because by then the -portal has usually already answered the end customer. +portal has usually already answered the end customer. Once the correlation is complete, a late event +does not change its answer; see [Values of `completion`](#values-of-completion). ## Checking what landed @@ -939,7 +968,8 @@ has to hold the completion flag until the earlier offsets are through. Keep one ## What not to do **Do not try to make the ingest call synchronous.** `wait` on `POST /v3/erp/updates/events` is not -supported and returns **400**. Ingest validates, enqueues and answers; it never blocks on processing. +supported: a `wait` field on the request or on an event is ignored, and the request is processed as if +it were not there. Ingest validates, enqueues and answers; it never blocks on processing. That is deliberate: the ordering, deduplication and backpressure guarantees of the inbound pipeline all come from the fact that nothing waits inside the write path. @@ -973,22 +1003,23 @@ rejects it with 400; see [Entity requirements](#entity-requirements-with-selecto ## Monitoring Interactive traffic raises its own codes in the -[Monitoring tab](./monitoring/overview.md) alongside the usual entity events. Every one of them carries -the `correlation_id`, so you can filter a single registration end to end: +[Monitoring tab](./monitoring/overview.md) alongside the usual entity events. Every one of them except +`CORRELATION_ID_MISSING` carries the `correlation_id`, so you can filter a single registration end to +end: | Code | Level | Emitted when | |---|---|---| | `INTERACTIVE_RATE_LIMITED` | warning | An interactive request exceeded the organization's budget and ran on the normal lane | | `INTERACTIVE_NOT_ALLOWED` | info | `interactive: true` arrived for a use case configured `interactive: disabled` | -| `INTERACTIVE_CROSS_LANE_WRITE` | warning | An interactive write landed on an entity that another lane had written within the last 60 seconds — the same entity was in flight on two lanes at once, typically a bulk sync and a registration push for the same customer | +| `INTERACTIVE_CROSS_LANE_WRITE` | warning | The same entity was written on the interactive lane and on another lane within 60 seconds of each other, in either order — the entity was in flight on two lanes at once, typically a bulk sync and a registration push for the same customer | | `CORRELATION_COMPLETED` | info | A correlation reached `complete`, with its event and entity counts and the ingest-to-complete duration | | `CORRELATION_CLOSED_EMPTY` | info | A correlation was closed without a single event — the partner said nothing is coming for this customer, including a close that created the correlation | -| `CORRELATION_CLOSE_IGNORED` | warning | A request set `correlation_complete: true` but every one of its events went to bulk (the use case does not allow interactive processing, or `interactive` was left out), so the close was ignored and no correlation was created or closed | -| `CORRELATION_CLOSED_INCOMPLETE` | warning | A correlation reached `complete` without the requirements of a wait on it met — the partner closed without sending what was asked for. Raised once per correlation; the details list what is missing as `missing_entities` and `missing_use_cases` | -| `CORRELATION_LATE_EVENT` | warning | An event arrived after `correlation_complete` | +| `CORRELATION_CLOSE_IGNORED` | warning | A request set `correlation_complete: true` but the close was not applied: the response asked for a retry (a 422 with partial results, or a 429), or every one of its events went to bulk (the use case does not allow interactive processing, or `interactive` was left out). No correlation was created or closed; the details give the reason | +| `CORRELATION_CLOSED_INCOMPLETE` | warning | A wait (`wait` above 0) ended `complete` without its requirements met — the partner closed without sending what was asked for. Raised once per correlation, never by a `wait=0` read; the details list what is missing as `missing_entities` and `missing_use_cases` | +| `CORRELATION_LATE_EVENT` | warning | An event arrived after `correlation_complete`. It is processed, but does not change a complete correlation's answer | | `CORRELATION_EVENT_LIMIT_EXCEEDED` | warning | A correlation had already received its 32 events, so further events under its id were processed as plain bulk with no processing record; the details name the correlation id | | `CORRELATION_ID_MISSING` | error | A request set `correlation_complete: true` without a request-level `correlation_id` and was rejected with 400; nothing was closed | -| `CORRELATION_REQUIREMENT_FAILED` | error | A wait ended `error` because an event a requirement depends on failed non-retryably; the details name the requirement and the failing event | +| `CORRELATION_REQUIREMENT_FAILED` | error | A wait ended `error` because an event a requirement depends on failed non-retryably. Raised once per correlation, never by a `wait=0` read; the details name the requirement and the failing event | | `CORRELATION_WAIT_TIMEOUT` | warning | The wait budget ran out before the requirements were met. Raised once per correlation, however many waits run out on it, and never by a `wait=0` read. The details list what was not satisfied as `missing_entities` and `missing_use_cases` | | `CORRELATION_WAIT_REJECTED` | warning | A wait was refused with 429 because the organization's or the global cap on concurrent waits was reached | From dd166382d706e25c5a657214f965ecf70b70f555 Mon Sep 17 00:00:00 2001 From: Karl Rankla Date: Wed, 14 Oct 2026 10:32:44 +0300 Subject: [PATCH 11/19] docs(integration-toolkit): document the ingest and read grants and how the interactive budget is charged Ingest requires integration:ingest on the target integration (403 otherwise; existing scoped tokens need the grant) and the status reads require integration:view, which the page described as the same organization-scoped token. The rate limit is charged per request for the events admitted as interactive after validation, deduplication and the cap - min(events, burst), all or nothing - and a zero-event close costs one token, not one token per event. --- .../inbound/getting-started.md | 2 +- .../interactive-registration.md | 28 ++++++++++++++++--- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/docs/integrations/integration-toolkit/inbound/getting-started.md b/docs/integrations/integration-toolkit/inbound/getting-started.md index 4423ef6..392694c 100644 --- a/docs/integrations/integration-toolkit/inbound/getting-started.md +++ b/docs/integrations/integration-toolkit/inbound/getting-started.md @@ -11,7 +11,7 @@ This guide walks you through setting up an inbound integration to synchronize da ## Prerequisites - Access to the epilot platform with admin permissions -- API credentials for your organization +- API credentials for your organization, with the `integration:ingest` grant on the integration you push to - Basic understanding of REST APIs and JSON ## Step 1: Create an Integration diff --git a/docs/integrations/integration-toolkit/interactive-registration.md b/docs/integrations/integration-toolkit/interactive-registration.md index b759a0b..5e1bef9 100644 --- a/docs/integrations/integration-toolkit/interactive-registration.md +++ b/docs/integrations/integration-toolkit/interactive-registration.md @@ -236,6 +236,15 @@ matters too — see [One use case per partner event type](#one-use-case-per-part Two new fields on `POST /v3/erp/updates/events`, both at request level. `interactive` may also be set per event, and the event wins — the same precedence `correlation_id` and `group_id` already follow. +:::note Permissions +Ingest requires the **`integration:ingest`** grant on the target integration, on every inbound +endpoint (v1, v2 and v3). A token without it is answered **403** before anything is charged, recorded +or queued. Roles that hold `integration:*`, the owner role among them, include it. **Existing tokens +need the grant:** if your middleware pushes with a token whose role was scoped before the grant +existed, add `integration:ingest` for each integration it pushes to. Reading what landed needs +`integration:view`; see [Checking what landed](#checking-what-landed). +::: + The examples on the rest of this page share one integration with two inbound use cases. Their entity targets are keyed like this, and the keys matter later: @@ -432,7 +441,8 @@ does not change its answer; see [Values of `completion`](#values-of-completion). ## Checking what landed -Three read endpoints, all authorized with the same organization-scoped token you use for ingest. No +Three read endpoints. They need the **`integration:view`** grant on the integration the event or +correlation belongs to, so a token scoped to `integration:ingest` alone cannot read what it pushed. No cross-organization read is possible: every lookup is partitioned by the token's organization. ### One event @@ -908,9 +918,19 @@ than returning less without comment. Single event records are kept for 24 hours. ## Rate limits The interactive lane is only worth having if it stays fast, and it stays fast only if no single -organization can fill it. Every organization therefore has a token budget for interactive events, -charged one token per event at ingest. The default is a sustained **120 interactive events per 5 -minutes with a burst of 40**, raised by agreement for organizations with higher registration volume. +organization can fill it. Every organization therefore has a token budget for interactive events. The +default is a sustained **120 interactive events per 5 minutes with a burst of 40**, raised by agreement +for organizations with higher registration volume. + +How a request is charged: + +- **Once per request, at ingest, for the events it admits as interactive** — what is left after + validation, deduplication and the 32-event cap. Duplicates, events rejected with 422 and events over + the cap cost nothing, so a retry pays only for the events it newly admits. +- The charge is the number of those events, **capped at the burst**: `min(events, burst)`. +- **All or nothing.** If the budget cannot cover the whole charge, none of the request's events take + the interactive lane; all of them run on the normal lane. A bundle is never split across lanes. +- A close with no events costs **one** token. When the budget is exhausted, nothing is rejected and nothing is lost: From ba1ebd2fbef6edfba83f4132a041a0501a4bfa33 Mon Sep 17 00:00:00 2001 From: Karl Rankla Date: Wed, 14 Oct 2026 10:33:15 +0300 Subject: [PATCH 12/19] docs(integration-toolkit): match the examples to what the status API returns Sort use_cases and entities the way the API does (by slug, then _id) and say so, show event_name on the event record as the routing key, use the message casing the API returns for a disabled use case, and replace the listing cursor example, which decoded to a key the API rejects, with an opaque placeholder. --- .../interactive-registration.md | 47 +++++++++++-------- 1 file changed, 27 insertions(+), 20 deletions(-) diff --git a/docs/integrations/integration-toolkit/interactive-registration.md b/docs/integrations/integration-toolkit/interactive-registration.md index 5e1bef9..ca9395c 100644 --- a/docs/integrations/integration-toolkit/interactive-registration.md +++ b/docs/integrations/integration-toolkit/interactive-registration.md @@ -221,7 +221,7 @@ registration push, so the use case grants permission and the request decides. | Value | Effect on a request that asks for interactive | |---|---| -| `disabled` (default) | The events are processed as bulk. Each result says `"interactive": false` with the message `use case does not allow interactive processing`, no processing record is written, and an `INTERACTIVE_NOT_ALLOWED` info event appears in monitoring. Nothing is rejected. | +| `disabled` (default) | The events are processed as bulk. Each result says `"interactive": false` with the message `Use case does not allow interactive processing`, no processing record is written, and an `INTERACTIVE_NOT_ALLOWED` info event appears in monitoring. Nothing is rejected. | | `on_request` | The request's `interactive: true` selects the interactive lane. This is the normal setting. | | `always` | Every event of this use case is interactive, whether the request asks or not — including requests without the flag, events that send `interactive: false` and requests without a correlation id (which get a generated one). Every one of those events is charged to the organization's interactive budget. Use it only for a use case that exists solely to serve registrations. | @@ -459,23 +459,24 @@ waits. "lane": "interactive", "use_case_id": "58cf8956-e737-4b4c-a683-3528be3eedb8", "use_case_slug": "customer_account", + "event_name": "customer_account", "ingested_at": "2026-10-09T09:05:28.412Z", "mapped_at": "2026-10-09T09:05:29.004Z", "done_at": "2026-10-09T09:05:30.277Z", "writes_total": 2, "writes_done": 2, "entities": [ - { - "slug": "contact", - "_id": "9acc9f54-5c0a-4b3e-8e6f-1d2c3b4a5968", - "operation": "created", - "unique_ids": { "customer_number": "4711" } - }, { "slug": "billing_account", "_id": "3f1b7e20-9c84-4a55-bd21-6e0d8a7c4f13", "operation": "created", "unique_ids": { "billing_account_number": "300571" } + }, + { + "slug": "contact", + "_id": "9acc9f54-5c0a-4b3e-8e6f-1d2c3b4a5968", + "operation": "created", + "unique_ids": { "customer_number": "4711" } } ], "errors": [] @@ -495,6 +496,10 @@ into, including follow-up writes such as stub creates, so `writes_done == writes record means the whole tree is finished — not just the first write. A write that found the entity already as described still counts, and appears in `entities` with `"operation": "unchanged"`. +`event_name` is the key the event was routed by: the `use_case_slug` you sent, or your `event_name` +when you routed by that — not the use case's display name. `entities` is sorted by slug, then `_id`; +find an entity by its slug and `unique_ids`, never by its position. + The three timestamps follow your data: `ingested_at` when the event was accepted, `mapped_at` when the mapping had produced its entity updates, `done_at` when the last of those writes finished. `ingested_at` to `done_at` is the first end-to-end latency figure the inbound pipeline has ever @@ -543,22 +548,22 @@ curl -G 'https://integration-toolkit.sls.epilot.io/v3/erp/updates/events' \ "first_ingested_at": "2026-10-09T09:05:28.412Z", "last_done_at": "2026-10-09T09:05:33.118Z", "use_cases": [ - { "use_case_slug": "customer_account", "received": 1, "done": 1, "error": 0 }, - { "use_case_slug": "contract", "received": 1, "done": 1, "error": 0 } + { "use_case_slug": "contract", "received": 1, "done": 1, "error": 0 }, + { "use_case_slug": "customer_account", "received": 1, "done": 1, "error": 0 } ], "entities": [ - { - "slug": "contact", - "_id": "9acc9f54-5c0a-4b3e-8e6f-1d2c3b4a5968", - "operation": "created", - "unique_ids": { "customer_number": "4711" } - }, { "slug": "billing_account", "_id": "3f1b7e20-9c84-4a55-bd21-6e0d8a7c4f13", "operation": "created", "unique_ids": { "billing_account_number": "300571" } }, + { + "slug": "contact", + "_id": "9acc9f54-5c0a-4b3e-8e6f-1d2c3b4a5968", + "operation": "created", + "unique_ids": { "customer_number": "4711" } + }, { "slug": "contract", "_id": "c0d9a1b8-4e37-4f52-8a90-5b6c7d8e9f01", @@ -573,7 +578,8 @@ curl -G 'https://integration-toolkit.sls.epilot.io/v3/erp/updates/events' \ `first_ingested_at` is when the first event of the correlation was accepted and `last_done_at` when the most recent one finished, so the pair is the end-to-end time of the whole correlation. `use_cases` has one item per use case the correlation's events were routed to, with how many were `received`, are -`done` and ended in `error`. Each item in `errors[]` is +`done` and ended in `error`. `use_cases` is sorted by `use_case_slug`, and `entities` by slug, then +`_id`. Each item in `errors[]` is `{ "code", "message", "event_id", "occurred_at" }` — the same shape as on the single event, plus the `event_id` that failed. @@ -896,20 +902,21 @@ curl -G 'https://integration-toolkit.sls.epilot.io/v3/erp/updates/correlations' "first_ingested_at": "2026-10-09T09:05:28.412Z", "last_done_at": "2026-10-09T09:05:33.118Z", "use_cases": [ - { "use_case_slug": "customer_account", "received": 1, "done": 1, "error": 0 }, - { "use_case_slug": "contract", "received": 1, "done": 1, "error": 0 } + { "use_case_slug": "contract", "received": 1, "done": 1, "error": 0 }, + { "use_case_slug": "customer_account", "received": 1, "done": 1, "error": 0 } ], "entities": [ "..." ], "errors": [] } ], - "next_cursor": "eyJzayI6IjIwMjYtMTAtMDlUMDk6MDU6MjguNDEyWiJ9", + "next_cursor": "", "horizon_days": 7 } ``` Each item in `results` is the same correlation summary the wait returns, without the long poll. -`next_cursor` is `null` on the last page. +`next_cursor` is `null` on the last page. Pass it back exactly as you received it; do not build or +decode one. `horizon_days` states how far back the listing reaches: **7 days**, which is how long correlation records are kept. A correlation older than that is gone, not hidden, and the response says so rather From e59dec4af4378549cb8ce112ed283665f206c500 Mon Sep 17 00:00:00 2001 From: Karl Rankla Date: Thu, 15 Oct 2026 10:32:17 +0300 Subject: [PATCH 13/19] docs(integration-toolkit): regenerate the monitoring codes from the toolkit snapshot --- .../integration-toolkit/monitoring/codes.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/integrations/integration-toolkit/monitoring/codes.md b/docs/integrations/integration-toolkit/monitoring/codes.md index d7a0f3b..0b849cd 100644 --- a/docs/integrations/integration-toolkit/monitoring/codes.md +++ b/docs/integrations/integration-toolkit/monitoring/codes.md @@ -15,7 +15,7 @@ Every monitoring event carries a **code** and a **level**. The code says what happened; the level says how much you should care. Both are filterable in the Integration Hub's [Monitoring tab](./overview.md) and through the events API. -There are 98 codes. You are most likely here because you saw one in a failed +There are 99 codes. You are most likely here because you saw one in a failed event — find it below. :::tip @@ -36,7 +36,7 @@ Something failed and the event did not do what it was meant to do. These are wha | `ATTRIBUTE_TYPE_MISMATCH` | An attribute value did not match the type declared in the entity schema | | `CONDITIONAL_VARIANT_WRITE_FAILED` | Conditional pricing refused one variant write. The item is dropped and not retried; the rest of the batch and the run continue. details.code names the reason where pricing gave one — UNKNOWN_ERROR means it did not | | `CORRELATION_ID_MISSING` | A request asked for something that only makes sense for a correlation, but did not say which correlation | -| `CORRELATION_REQUIREMENT_FAILED` | Something a caller was waiting for can no longer arrive: every event that would have satisfied the requirement ended in an error | +| `CORRELATION_REQUIREMENT_FAILED` | Something a caller was waiting for can no longer arrive: every event that would have satisfied the requirement ended in an error. Reported once per correlation | | `DEPRECATED_ENDPOINT` | This endpoint version is deprecated | | `DIRECT_ENTITY_NOT_ALLOWED` | The entity is not permitted by the use case entity allowlist | | `DIRECT_PAYLOAD_INVALID` | The direct mode payload failed validation against the versioned payload schema | @@ -64,6 +64,7 @@ Something failed and the event did not do what it was meant to do. These are wha | `MSG_HEAD_BLOCKED` | Outbound stream halted by a poison head message (block policy) — requires operator unblock or consumer acknowledgement | | `OAUTH2_TOKEN_FAILURE` | Failed to obtain an OAuth2 access token | | `PAYLOAD_TOO_LARGE` | The payload exceeded the maximum size accepted by the receiving system | +| `PROCESSING_STATE_LIMIT` | The processing record of an interactive event could not be written, because it or its correlation reached the size the store allows, so the event was ended as an error instead of being left processing: its correlation still completes, and anyone waiting on it is answered. The event's data may have been written all the same. Usually a payload with unusually many entities or very long unique ids | | `PRUNE_SCOPE_PARTIAL_FAILURE` | Scope pruning completed with some failures | | `RECURSION_DEPTH_EXCEEDED` | Maximum recursion depth was exceeded during processing | | `RELATION_REF_ITEM_NOT_FOUND` | The relation_ref value could not be matched on the target entity (invalid mapped value, or still no match after writing it to the target) — skipped as non-retryable. Check the mapping configuration and the entity data. | @@ -102,11 +103,11 @@ Processing continued, but something needs a human eye — often a retry in fligh | `ACK_TIMEOUT` | Acknowledgement timed out waiting for the ERP system | | `CONDITIONAL_VARIANT_WRITE_WARNING` | One variant write succeeded with a warning. The variant is stored and the rest of the batch and the run continue. details.code names the warning | | `CORRELATION_CLOSED_INCOMPLETE` | The partner declared a correlation complete and everything it sent was processed, but what the caller was waiting for was not in it — see `missing_entities` and `missing_use_cases`. Reported once per correlation | -| `CORRELATION_CLOSE_IGNORED` | The partner declared a correlation complete, but the close was not applied: either the same request has to be retried, or none of its events was processed as interactive (the use case does not allow it, or the request did not ask for it). Retry with `correlation_complete`, or send the events with `interactive: true` | +| `CORRELATION_CLOSE_IGNORED` | The partner declared a correlation complete, but the close was not applied: the same request has to be retried, the queue refused one of its events, the correlation does not exist and the request recorded nothing under it (its events carried their own correlation ids, were duplicates, matched no use case, or were not processed as interactive), a close without events found the organization over its interactive rate and could not create the correlation, or the correlation belongs to another integration (the one whose event or close created it). The reason names the cause. Retry with `correlation_complete`, send the close under the correlation the events were recorded under, or use a correlation id of your own | | `CORRELATION_EVENT_LIMIT_EXCEEDED` | This correlation already holds as many events as one correlation may, so further events sent under it were processed normally and cannot be waited on. A correlation is meant to cover one registration or one change; this usually means the partner sends the same correlation id with every push. Each request that should be waited on needs its own id | -| `CORRELATION_LATE_EVENT` | An event arrived under a correlation the partner had already declared complete. It was processed, but anyone waiting on that correlation had already been answered — the partner is sending `correlation_complete` too early | +| `CORRELATION_LATE_EVENT` | An event arrived under a correlation the partner had already declared complete. It was processed, but anyone waiting on that correlation had already been answered, and once the correlation is complete the event cannot change that answer — the partner is sending `correlation_complete` too early | | `CORRELATION_WAIT_REJECTED` | A caller tried to wait on a correlation while too many waits were already open, so it was asked to retry. Its data is unaffected — only the answer it was waiting for is slower | -| `CORRELATION_WAIT_TIMEOUT` | A caller waited on a correlation until its time ran out without getting what it asked for — see `missing_entities` and `missing_use_cases` for what had not arrived. Reported once per correlation | +| `CORRELATION_WAIT_TIMEOUT` | A caller waited on a correlation until its time ran out without getting what it asked for — see `missing_entities` and `missing_use_cases` for what had not arrived. Reported once per correlation. When nothing at all was sent under the correlation, it carries no integration: none is known before the first event | | `EXTERNAL_WARNING` | A warning span pushed by an external system via the external monitoring events endpoint. | | `FILE_PROXY_UPLOAD_RETRYING` | A file upload failed with a retryable error and will be retried automatically — one event per attempt | | `INTERACTIVE_CROSS_LANE_WRITE` | The same entity was updated from two different processing lanes within seconds of each other, one of them the interactive lane. Both updates were applied; this reports that they overlapped, so the order they landed in was decided by timing | From 87bc796a5c9315bb07c338a2a33e618e36bb1173 Mon Sep 17 00:00:00 2001 From: Karl Rankla Date: Thu, 15 Oct 2026 10:35:03 +0300 Subject: [PATCH 14/19] docs(integration-toolkit): document correlation_close and when a zero-event close is charged --- .../interactive-registration.md | 77 ++++++++++++++++--- 1 file changed, 66 insertions(+), 11 deletions(-) diff --git a/docs/integrations/integration-toolkit/interactive-registration.md b/docs/integrations/integration-toolkit/interactive-registration.md index ca9395c..85996be 100644 --- a/docs/integrations/integration-toolkit/interactive-registration.md +++ b/docs/integrations/integration-toolkit/interactive-registration.md @@ -291,7 +291,7 @@ curl -X POST 'https://integration-toolkit.sls.epilot.io/v3/erp/updates/events' \ |---|---|---| | `correlation_id` | string | The id the portal handed to your hook. Already part of the API — what is new is that it is now the key the portal waits on, so it must be the portal's id, or the id you returned from the hook if you use one of your own. Unique per identification, 1 to 255 characters of `[A-Za-z0-9_-]`; see [Correlation ids](#correlation-ids). | | `interactive` | boolean | Request the interactive lane for this request's events. | -| `correlation_complete` | boolean | No further events will follow for this request's `correlation_id`. Send it once, when you are done: on your last request, or as its own request with no events if you push in parallel. | +| `correlation_complete` | boolean | No further events will follow for this request's `correlation_id`. Send it once, when you are done: on your last request, or as its own request with no events if you push in parallel. The response's `correlation_close` says whether it was applied; see [What became of the close](#what-became-of-the-close). | The response gains the correlation id and, per result, how the event was processed: @@ -318,6 +318,7 @@ The two result fields answer different questions and are independent of each oth for this event. It is `false`, with a `message` saying why, when - the use case does not allow interactive processing; - the correlation already holds its 32 events (see [Correlation ids](#correlation-ids)); + - the correlation belongs to another integration (see [Correlation ids](#correlation-ids)); - the event belongs to an `always` use case and its correlation id breaks the [id format](#correlation-ids) — the request did not ask for interactive, so it is not rejected; - the processing-state store is unavailable, so no record could be written. @@ -343,6 +344,11 @@ The two result fields answer different questions and are independent of each oth organization's normal lane, with no processing record, so the wait cannot see them, and monitoring raises `CORRELATION_EVENT_LIMIT_EXCEEDED` naming the correlation id. If you see that code, you are almost certainly reusing an id. +- **One integration per correlation.** A correlation belongs to the integration whose event — or + close — created it. Events another integration sends under the same id are processed as bulk, with + `"interactive": false` and the reason in their result, and that integration's close is ignored. + Two integrations never share a correlation, so if you see this, the same id was minted for both: + use an id of your own for each. ### Closing a correlation @@ -411,17 +417,21 @@ The rules: Closing twice is a no-op. - The close is applied only when **every** event of the request was accepted. A response that asks you to retry — a 422 with partial results, or a 429 — closes nothing, so a close can never overtake - events that are still being retried. Monitoring raises `CORRELATION_CLOSE_IGNORED`. + events that are still being retried. The same holds when the queue refused one of the request's + events, which its result reports on a 200. Monitoring raises `CORRELATION_CLOSE_IGNORED`. - **A retried closing request still closes**, even when every one of its events is a duplicate. If the response to your closing request was lost and you send it again with the same `deduplication_id`s, the events are skipped as duplicates and the close is applied to the correlation they were registered under the first time. - **A close beyond the cap still closes.** When some or all of the request's events went over the correlation's 32 events and were processed as plain bulk, the correlation exists, so it is closed. -- A request that carries events of which **none** was processed as interactive — the use case does - not allow it, or the request left out `interactive` — neither creates nor closes the correlation. - Those events went to bulk, where the wait cannot see them, so the close is ignored and monitoring - raises `CORRELATION_CLOSE_IGNORED`. +- **A request that carries events never creates the correlation.** It closes one that exists, whatever + became of its own events. If the correlation does not exist yet and none of the request's events was + recorded under it — the use case does not allow interactive processing, the request left out + `interactive`, or every event carried a `correlation_id` of its own — the close is ignored and + monitoring raises `CORRELATION_CLOSE_IGNORED`. Those events went to bulk, where the wait cannot see + them, and closing their correlation empty would tell the portal "nothing is coming" while the data is + being written. - A request that **mixes** interactive and bulk events — `interactive` set per event, or events for a use case that does not allow it next to one that does — closes the correlation. `complete` then covers only its interactive events: the bulk ones are processed, but the wait never sees them. @@ -430,9 +440,50 @@ The rules: attached. This is the one case in which an empty `events` array is accepted; without the flag, an empty `events` array is still a 400. -- A close for a correlation epilot has never seen **creates it closed** and returns 200. The portal's - wait then answers `complete` with `events_received: 0` immediately — "the partner sent nothing and - never will" — instead of waiting out its budget. Monitoring records it as `CORRELATION_CLOSED_EMPTY`. +- A close **without events** for a correlation epilot has never seen **creates it closed** and returns + 200. The portal's wait then answers `complete` with `events_received: 0` immediately — "the partner + sent nothing and never will" — instead of waiting out its budget. Monitoring records it as + `CORRELATION_CLOSED_EMPTY`. Creating the correlation costs one token of the organization's + [interactive budget](#rate-limits); closing one that exists, or one that is already closed, is free. + When the budget is spent, the close creates nothing and the response says `rate_limited`: send it + again a few seconds later. +- A close of a correlation that **belongs to another integration** is ignored; see + [Correlation ids](#correlation-ids). + +#### What became of the close + +Every request that sets `correlation_complete: true` gets a `correlation_close` object in its response +— on a 200, a 422 and a 429 alike — saying what became of the close. Requests without the flag do not +carry it. + +```json title="Response — a close without events, while the budget is spent" +{ + "correlation_id": "018f8e9b-5a1b-7c4e-9b2a-4f0f6c1d7a21", + "correlation_close": { + "outcome": "rate_limited", + "reasons": ["rate_limited"], + "message": "..." + }, + "results": [] +} +``` + +| `outcome` | Meaning | What to do | +|---|---|---| +| `applied` | The correlation is closed: by this request, by an earlier one (closing twice is a no-op), or created closed and empty by a request without events. | Nothing. | +| `rate_limited` | A close without events found no correlation, and the organization's interactive budget could not pay for creating it (`rate_limit_unavailable` when the budget could not be checked). Until it is created, a waiter on the id is not told that nothing is coming. | Send the same close again a few seconds later. | +| `ignored` | The close was not applied for another reason, named in `reasons`. `message` gives them as text, and monitoring raises `CORRELATION_CLOSE_IGNORED` with the same reasons. | See the reasons below. | + +The `reasons` of an `ignored` close, each listed once: + +| Reason | What to do | +|---|---| +| `retry_requested` | The response is a 422 or a 429. Retry the request with `correlation_complete` still set; it closes then. | +| `queue_refused` | The queue refused one of the request's events. Send the close again once every event is accepted. | +| `other_integration` | The correlation belongs to another integration. Use a correlation id of your own. | +| `events_under_own_correlation_ids`, `events_duplicate`, `events_not_configured`, `events_not_interactive`, `events_unrecorded` | The correlation does not exist, and this request — which carries events — recorded none under it: they carried their own correlation ids, were duplicates, matched no use case, or were not processed as interactive. Send the close under the correlation the events were recorded under, or send the events with `interactive: true` on a use case that allows it. | +| `close_failed` | The close could not be written. Send it again. | +| `interactive_unavailable` | Interactive processing is not available, so there is no correlation to close. | An event that arrives **after** the correlation was closed is still processed normally. It is counted as a late event and raises a `CORRELATION_LATE_EVENT` warning in monitoring, because by then the @@ -937,7 +988,11 @@ How a request is charged: - The charge is the number of those events, **capped at the burst**: `min(events, burst)`. - **All or nothing.** If the budget cannot cover the whole charge, none of the request's events take the interactive lane; all of them run on the normal lane. A bundle is never split across lanes. -- A close with no events costs **one** token. +- A close with no events costs **one** token, and only when it creates the correlation. Closing a + correlation that exists, or one that is already closed, costs nothing. When the budget is spent, such + a close still closes a correlation that exists but creates none, and its `correlation_close` says + `rate_limited`: send it again a few seconds later. See + [What became of the close](#what-became-of-the-close). When the budget is exhausted, nothing is rejected and nothing is lost: @@ -1041,7 +1096,7 @@ end: | `INTERACTIVE_CROSS_LANE_WRITE` | warning | The same entity was written on the interactive lane and on another lane within 60 seconds of each other, in either order — the entity was in flight on two lanes at once, typically a bulk sync and a registration push for the same customer | | `CORRELATION_COMPLETED` | info | A correlation reached `complete`, with its event and entity counts and the ingest-to-complete duration | | `CORRELATION_CLOSED_EMPTY` | info | A correlation was closed without a single event — the partner said nothing is coming for this customer, including a close that created the correlation | -| `CORRELATION_CLOSE_IGNORED` | warning | A request set `correlation_complete: true` but the close was not applied: the response asked for a retry (a 422 with partial results, or a 429), or every one of its events went to bulk (the use case does not allow interactive processing, or `interactive` was left out). No correlation was created or closed; the details give the reason | +| `CORRELATION_CLOSE_IGNORED` | warning | A request set `correlation_complete: true` but the close was not applied: the response asked for a retry, the queue refused one of its events, the correlation does not exist and the request recorded nothing under it, a close without events could not be paid for, or the correlation belongs to another integration. No correlation was created or closed; `detail.reasons` names the cause, the same reasons the response's [`correlation_close`](#what-became-of-the-close) carries | | `CORRELATION_CLOSED_INCOMPLETE` | warning | A wait (`wait` above 0) ended `complete` without its requirements met — the partner closed without sending what was asked for. Raised once per correlation, never by a `wait=0` read; the details list what is missing as `missing_entities` and `missing_use_cases` | | `CORRELATION_LATE_EVENT` | warning | An event arrived after `correlation_complete`. It is processed, but does not change a complete correlation's answer | | `CORRELATION_EVENT_LIMIT_EXCEEDED` | warning | A correlation had already received its 32 events, so further events under its id were processed as plain bulk with no processing record; the details name the correlation id | From 99d0da8f7fe2ec2769827b857fdca95d36a89815 Mon Sep 17 00:00:00 2001 From: Karl Rankla Date: Thu, 15 Oct 2026 10:35:30 +0300 Subject: [PATCH 15/19] docs(integration-toolkit): state the selector refusals, the fresh configuration check and the record-less wait cap --- .../interactive-registration.md | 50 +++++++++++++------ 1 file changed, 35 insertions(+), 15 deletions(-) diff --git a/docs/integrations/integration-toolkit/interactive-registration.md b/docs/integrations/integration-toolkit/interactive-registration.md index 85996be..e529053 100644 --- a/docs/integrations/integration-toolkit/interactive-registration.md +++ b/docs/integrations/integration-toolkit/interactive-registration.md @@ -569,8 +569,8 @@ needs has arrived. |---|---| | `correlation_id` | Required. The correlation to look at. | | `wait` | Seconds to wait, `0` to `25`. Omit or `0` for an immediate answer, which reads `completion: "timeout"` while nothing is decided yet. The ceiling is 25 s because the HTTP API cuts off at 30 s. A value outside the range is rejected with **400** naming the maximum, not silently clamped. | -| `wait_for_entities` | Entity requirements, each `slug` or `slug[key=value]`. **One requirement per occurrence**: repeat the parameter for several. An occurrence is never split, so a selector value may contain commas. A selector value matches ignoring case and surrounding whitespace, like entity lookup. See [Selector syntax and encoding](#selector-syntax-and-encoding). | -| `wait_for_use_cases` | Inbound **use case slugs**. Repeat the parameter, or separate the slugs with commas in one occurrence. | +| `wait_for_entities` | Entity requirements, each `slug` or `slug[key=value]`. **One requirement per occurrence**: repeat the parameter for several. An occurrence is never split, so a selector value may contain commas. A selector value matches ignoring case and surrounding whitespace, like entity lookup. Only `wait_for_entities=…` is accepted: a bracketed form such as `wait_for_entities[]=` or `wait_for_entities[0]=` is a **400**. See [Selector syntax and encoding](#selector-syntax-and-encoding). | +| `wait_for_use_cases` | Inbound **use case slugs**. Repeat the parameter, or separate the slugs with commas in one occurrence. A bracketed form such as `wait_for_use_cases[]=` is a **400**. | Both requirement sets are optional, and both are only valid together with `correlation_id` — sending one without it returns 400. Each set takes at most 20 requirements. When both are given, **both** must @@ -670,6 +670,9 @@ match**, so the wait rejects it up front with **400**. The check is against the selector that some use case can satisfy is never rejected, and the check works before the correlation exists. A `wait_for_entities` entry whose slug no inbound use case writes is a 400 as well. Treat the 400 as a configuration error to fix, not as a reason to retry. + +The check refuses a requirement only against the configuration as read after your request arrived, so +a use case you saved or changed seconds before the wait is honoured — there is no cache to wait out. ::: ```json title="400 — a selector key that is not a configured unique id" @@ -706,7 +709,9 @@ reported once its relations are set, so that relation is there when the wait ret ##### Selector syntax and encoding Inside `slug[key=value]` the key ends at the **first** `=`, and the value runs to the final `]`, so a -value may itself contain `=`, `]` and `,`. +value may itself contain `=`, `]` and `,` — anything but `],` followed by another selector. A value +longer than 256 characters is a **400**: unique ids that long are not recorded, so it could never +match. Each `wait_for_entities` parameter is exactly **one** requirement. epilot never splits it on commas, so a value that contains a comma needs no special treatment. Encode every parameter **once**, the @@ -716,10 +721,11 @@ standard way for a query string, and epilot decodes it once — which is exactly - **Encoding the value yourself before handing it to one of those.** The value is then encoded twice, and epilot receives the escape sequence as text: a value `A,B` that you pre-encode to `A%2CB` arrives as the text `A%2CB` and matches nothing. -- **Joining several entity requirements with commas into one parameter.** The joined text is read as - a single requirement, which is either rejected with **400** or a selector whose value contains the - rest of the list and never matches. The comma-separated form exists only for `wait_for_use_cases`, - whose slugs cannot contain a comma. +- **Joining several entity requirements with commas into one parameter.** An occurrence holding + selectors joined by a comma, such as `contact[customer_number=4711],contract[contract_number=200184]`, + is rejected with **400** telling you to repeat the parameter. A comma inside one selector's value is + fine — only a comma between selectors is refused. The comma-separated form exists only for + `wait_for_use_cases`, whose slugs cannot contain a comma. ```bash title="A contract number that contains a comma and a slash" curl -G 'https://integration-toolkit.sls.epilot.io/v3/erp/updates/events' \ @@ -781,6 +787,11 @@ the ingest event. That is deliberate: the v3 events endpoint recommends `use_cas slug is unique within its integration and immutable once set. `event_name` is partner vocabulary and a fallback; it is not a stable key for a requirement. +A slug that no inbound use case of the organization has can never match and is refused with **400**, +naming it in `use_case_slug`. Since a correlation belongs to one integration and counts only that +integration's events, a wait on a correlation that already belongs to one also refuses a slug that only +**another** integration's use cases have. + A requirement is met when **at least one event of that use case slug is done**. An event that ends `partial` does not meet it: for a use case requirement, `partial` counts as an error, because part of what the use case should have written is missing. There are no selectors on use case slugs in this @@ -908,16 +919,22 @@ with **422** before anything is recorded, so it never reaches the correlation. There is no quiet-window heuristic: with no requirements and no close, a wait runs to its budget and then returns `timeout`. -A correlation id that epilot has never seen — or one that belongs to non-interactive traffic — comes -back as `events_received: 0, closed: false` with `completion: "timeout"`. Such a wait does not hold a -long poll for its budget: it answers immediately, at most after a short grace period, and the caller -calls again. The caller cannot tell "not yet" from "never", which is exactly the ambiguity +A correlation id that epilot has never seen — or one that belongs to non-interactive traffic — reads +as `events_received: 0, closed: false`. Waiting before anything was sent is the portal's normal case: +it mints the id, calls your hook and waits before you push. Such a wait long-polls its budget like any +other and picks the correlation up as soon as its first event is ingested; if nothing arrives, it +answers `timeout`. The caller cannot tell "not yet" from "never", which is exactly the ambiguity `correlation_complete` exists to remove. :::note Concurrent waits are capped -Long polls are limited per organization and globally. Past the cap the call answers **429** with a -`Retry-After` header instead of waiting, and raises `CORRELATION_WAIT_REJECTED` in monitoring; callers -fall back to a non-waiting call and retry. The portal already does this. +Long polls are limited per organization and globally. A wait that starts before anything was sent +under its correlation counts against a separate cap instead — **10 per organization** — for its whole +duration, so waits on ids nothing is ever sent under cannot crowd out waits on correlations that exist. +Past a cap the call answers **429** with a `Retry-After` header instead of waiting, and raises +`CORRELATION_WAIT_REJECTED` in monitoring. An early wait can therefore meet a 429 while that cap is +full; retry after `Retry-After`, or fall back to a non-waiting call (`wait=0`), which never needs a +slot. The portal already does this. A call that does not have to wait — `wait=0`, or a correlation +that is already decided — is never refused. ::: ### Recent correlations @@ -1082,6 +1099,9 @@ close out; see [Closing a correlation](#closing-a-correlation). **Do not select on an attribute that is not a configured unique id.** It can never match, so the wait rejects it with 400; see [Entity requirements](#entity-requirements-with-selectors-on-unique_ids). +**Do not join entity requirements with commas.** Repeat `wait_for_entities` once per requirement; a +comma between selectors is a 400. See [Selector syntax and encoding](#selector-syntax-and-encoding). + ## Monitoring Interactive traffic raises its own codes in the @@ -1103,7 +1123,7 @@ end: | `CORRELATION_ID_MISSING` | error | A request set `correlation_complete: true` without a request-level `correlation_id` and was rejected with 400; nothing was closed | | `CORRELATION_REQUIREMENT_FAILED` | error | A wait ended `error` because an event a requirement depends on failed non-retryably. Raised once per correlation, never by a `wait=0` read; the details name the requirement and the failing event | | `CORRELATION_WAIT_TIMEOUT` | warning | The wait budget ran out before the requirements were met. Raised once per correlation, however many waits run out on it, and never by a `wait=0` read. The details list what was not satisfied as `missing_entities` and `missing_use_cases` | -| `CORRELATION_WAIT_REJECTED` | warning | A wait was refused with 429 because the organization's or the global cap on concurrent waits was reached | +| `CORRELATION_WAIT_REJECTED` | warning | A wait was refused with 429 because the organization's or the global cap on concurrent waits was reached, or — `scope: recordless` — the organization's cap on waits for correlations nothing was sent under yet | The codes that end a correlation without data are ranked deliberately. `CORRELATION_WAIT_TIMEOUT` and `CORRELATION_CLOSED_INCOMPLETE` are the warnings: an end customer waited and was shown nothing, because From bf614ca2e187d402ececb698cf7e6f1e5b830366 Mon Sep 17 00:00:00 2001 From: Karl Rankla Date: Thu, 15 Oct 2026 10:36:18 +0300 Subject: [PATCH 16/19] docs(integration-toolkit): document the bounded status lists, errors_total and PROCESSING_STATE_LIMIT --- .../interactive-registration.md | 42 ++++++++++++++++--- 1 file changed, 37 insertions(+), 5 deletions(-) diff --git a/docs/integrations/integration-toolkit/interactive-registration.md b/docs/integrations/integration-toolkit/interactive-registration.md index e529053..0622f69 100644 --- a/docs/integrations/integration-toolkit/interactive-registration.md +++ b/docs/integrations/integration-toolkit/interactive-registration.md @@ -530,7 +530,8 @@ waits. "unique_ids": { "customer_number": "4711" } } ], - "errors": [] + "errors": [], + "errors_total": 0 } ``` @@ -559,6 +560,20 @@ exposed. Use it when you want to know whether a slow registration was slow on yo Each item in `errors[]` is `{ "code", "message", "occurred_at" }`, where `code` is a [monitoring code](./monitoring/codes.md). +Both lists are bounded, so one unusual event cannot grow its record without limit: + +- `errors` holds the event's **first 10 failures**, each message cut at 1,000 characters; a failure + repeated within one item of your data is listed once. `errors_total` counts every failure, listed or + not. +- `entities` lists at most **200 entity writes**. Writes past that are counted in `entities_omitted`, + which is present only when it is not zero. Those entities were written all the same; they are only + missing from the list. + +If the processing record itself cannot be written because it reached the size the store allows — +usually a payload with unusually many entities or very long unique ids — the event is ended `error` +with `PROCESSING_STATE_LIMIT` instead of being left `processing`. Its data may have been written all +the same, its correlation still completes, and anyone waiting on it is answered. + ### A correlation, with a wait `GET /v3/erp/updates/events?correlation_id={id}` returns the summary across every event of one @@ -593,6 +608,8 @@ curl -G 'https://integration-toolkit.sls.epilot.io/v3/erp/updates/events' \ "completion": "requirements_met", "closed": true, "closed_at": "2026-10-09T09:05:31.902Z", + "complete": true, + "completed_at": "2026-10-09T09:05:33.140Z", "events_received": 2, "events_done": 2, "late_events": 0, @@ -622,7 +639,8 @@ curl -G 'https://integration-toolkit.sls.epilot.io/v3/erp/updates/events' \ "unique_ids": { "contract_number": "200184" } } ], - "errors": [] + "errors": [], + "errors_total": 0 } ``` @@ -634,6 +652,14 @@ has one item per use case the correlation's events were routed to, with how many `{ "code", "message", "event_id", "occurred_at" }` — the same shape as on the single event, plus the `event_id` that failed. +The correlation lists its **first 50 failures** in `errors`, and `errors_total` counts them all — +including those of events that arrived after the correlation completed, which carry +`"after_completion": true` and do not change its answer. `entities` lists at most **200 entity +writes**; `entities_omitted` counts the rest, including those of an event that ended +`PROCESSING_STATE_LIMIT`. An entity that is only counted there may well have been written, but a +`wait_for_entities` requirement cannot be met by it, so such a wait ends on `complete`. A real +registration is far below either bound. + #### What "arrived" means A requirement is satisfied when the event that carries it reached **`done`** — the entity writes @@ -886,7 +912,8 @@ the kind of data that never came. Neither set can say that you have finished; on | `error` | A requirement can no longer be satisfied: every event of a required use case failed, or the correlation is complete with a required entity missing and an event failed (see [What "arrived" means](#what-arrived-means)). When the caller named no requirements: the correlation is complete and an event of it failed. | **Once a correlation is complete, its answer is final.** The verdict is fixed at the moment the -correlation becomes complete. An event that arrives after that is still processed and counted in +correlation becomes complete, which the summary states as `complete: true` and `completed_at`. Read +those rather than comparing `events_done` with `events_received`. An event that arrives after that is still processed and counted in `late_events`, and raises `CORRELATION_LATE_EVENT`, but it never changes `completion` — a waiter that was told `complete` or `requirements_met` is never contradicted by a later read. @@ -909,7 +936,8 @@ linked its billing account by an `_id` that does not exist, a wait for the `cont "event_id": "0199c3f5-1b07-7a2e-8d44-9e3c6f0a2b18", "occurred_at": "2026-10-09T09:05:32.640Z" } - ] + ], + "errors_total": 1 } ``` @@ -964,6 +992,8 @@ curl -G 'https://integration-toolkit.sls.epilot.io/v3/erp/updates/correlations' "integration_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "closed": true, "closed_at": "2026-10-09T09:05:31.902Z", + "complete": true, + "completed_at": "2026-10-09T09:05:33.140Z", "events_received": 2, "events_done": 2, "late_events": 0, @@ -974,7 +1004,8 @@ curl -G 'https://integration-toolkit.sls.epilot.io/v3/erp/updates/correlations' { "use_case_slug": "customer_account", "received": 1, "done": 1, "error": 0 } ], "entities": [ "..." ], - "errors": [] + "errors": [], + "errors_total": 0 } ], "next_cursor": "", @@ -1123,6 +1154,7 @@ end: | `CORRELATION_ID_MISSING` | error | A request set `correlation_complete: true` without a request-level `correlation_id` and was rejected with 400; nothing was closed | | `CORRELATION_REQUIREMENT_FAILED` | error | A wait ended `error` because an event a requirement depends on failed non-retryably. Raised once per correlation, never by a `wait=0` read; the details name the requirement and the failing event | | `CORRELATION_WAIT_TIMEOUT` | warning | The wait budget ran out before the requirements were met. Raised once per correlation, however many waits run out on it, and never by a `wait=0` read. The details list what was not satisfied as `missing_entities` and `missing_use_cases` | +| `PROCESSING_STATE_LIMIT` | error | An interactive event's processing record reached the size the store allows, so the event was ended `error` instead of being left `processing`. Its correlation still completes and waiters are answered; its data may have been written all the same | | `CORRELATION_WAIT_REJECTED` | warning | A wait was refused with 429 because the organization's or the global cap on concurrent waits was reached, or — `scope: recordless` — the organization's cap on waits for correlations nothing was sent under yet | The codes that end a correlation without data are ranked deliberately. `CORRELATION_WAIT_TIMEOUT` and From 872e86aeaab3ebefbd1a3a808b2fe25893e3a71d Mon Sep 17 00:00:00 2001 From: Karl Rankla Date: Thu, 15 Oct 2026 10:36:41 +0300 Subject: [PATCH 17/19] docs(integration-toolkit): document the ingest and replay grants and that an empty relation reference sets no relation --- docs/integrations/integration-toolkit/configuration.md | 10 +++++++--- .../integration-toolkit/inbound/relations.md | 8 ++++++++ .../integration-toolkit/interactive-registration.md | 3 ++- .../integration-toolkit/monitoring/investigating.md | 3 +++ 4 files changed, 20 insertions(+), 4 deletions(-) diff --git a/docs/integrations/integration-toolkit/configuration.md b/docs/integrations/integration-toolkit/configuration.md index bb02028..a743f4e 100644 --- a/docs/integrations/integration-toolkit/configuration.md +++ b/docs/integrations/integration-toolkit/configuration.md @@ -704,6 +704,7 @@ specific integration. |---|---| | `integration:view` | Read integrations and use cases; read monitoring events, stats, time series, traces, access logs, notification history and status | | `integration:manage` | Create, update and delete integrations and use cases; edit notification configuration; push external monitoring events; send test notifications; replay events | +| `integration:ingest` | Push inbound events to the integration (`POST /v1/erp/updates/events`, `/v2/…`, `/v3/…`). It grants nothing else — not reading what landed, and not replay | | `integration:consume` | Poll and acknowledge the outbound queue. Scoped to [Pollable Outbound](./pollable-outbound.md) only — it grants nothing else | The `view` / `manage` split is the one that catches people out when minting a scoped @@ -716,7 +717,9 @@ token for a middleware: | `GET /v2/integrations/{id}/notifications/history`, `…/status` | `integration:view` | | `POST /v2/integrations/{id}/monitoring/external-events` | `integration:manage` | | `POST /v2/integrations/{id}/notifications/test` | `integration:manage` | -| `POST /v1/integrations/{id}/events/replay` | `integration:manage` | +| `POST /v1/integrations/{id}/events/replay` | `integration:manage`, checked organization-wide | +| `POST /v3/erp/updates/events` (and v1, v2) | `integration:ingest` | +| `GET /v3/erp/updates/events/{event_id}`, `GET /v3/erp/updates/events?correlation_id=…`, `GET /v3/erp/updates/correlations` | `integration:view` | | `POST /v1/integrations/{id}/outbound/messages/poll`, `…/ack` | `integration:consume` | The monitoring events list (`POST /v2/integrations/{id}/monitoring/events`) returns @@ -724,5 +727,6 @@ The monitoring events list (`POST /v2/integrations/{id}/monitoring/events`) retu data — a mapped [poll message](./pollable-outbound.md#what-a-mapped-message-looks-like-in-monitoring) includes its delivered payload. -Inbound event submission authenticates as the integration's own API token rather -than through these actions — see [Inbound Getting Started](./inbound/getting-started.md). +Inbound event submission needs `integration:ingest` on the integration it pushes to — +see [Inbound Getting Started](./inbound/getting-started.md). A replay of inbound events +needs `integration:manage` instead; a push token's `integration:ingest` does not cover it. diff --git a/docs/integrations/integration-toolkit/inbound/relations.md b/docs/integrations/integration-toolkit/inbound/relations.md index 2ccbc9c..9b8416a 100644 --- a/docs/integrations/integration-toolkit/inbound/relations.md +++ b/docs/integrations/integration-toolkit/inbound/relations.md @@ -513,6 +513,14 @@ The `value` field in a relation_ref item supports the same value sources as regu | `jsonataExpression` | `{ "attribute": "address", "jsonataExpression": "{ \"street\": street, \"city\": city }" }` | | `constant` | `{ "attribute": "type", "constant": "BILLING" }` | +### An Empty Reference Means No Relation + +A relation item is built only when every unique id it is looked up by has a value. When the value +is missing, `null`, an empty string (`""`) or an empty list (`[]`), the item is skipped with a +warning: no relation is set and **no stub entity is created** — the toolkit never looks up or creates +an entity whose unique id is empty. A relation your payload leaves empty on purpose therefore stays +unset, and the event still ends `done`. + ## Relation Resolution Strategy The Integration Toolkit uses an **all-or-nothing** strategy: diff --git a/docs/integrations/integration-toolkit/interactive-registration.md b/docs/integrations/integration-toolkit/interactive-registration.md index 0622f69..f45a46f 100644 --- a/docs/integrations/integration-toolkit/interactive-registration.md +++ b/docs/integrations/integration-toolkit/interactive-registration.md @@ -242,7 +242,8 @@ endpoint (v1, v2 and v3). A token without it is answered **403** before anything or queued. Roles that hold `integration:*`, the owner role among them, include it. **Existing tokens need the grant:** if your middleware pushes with a token whose role was scoped before the grant existed, add `integration:ingest` for each integration it pushes to. Reading what landed needs -`integration:view`; see [Checking what landed](#checking-what-landed). +`integration:view`; see [Checking what landed](#checking-what-landed). Replaying monitoring events +needs `integration:manage`, checked organization-wide; `integration:ingest` does not cover it. ::: The examples on the rest of this page share one integration with two inbound use cases. Their entity diff --git a/docs/integrations/integration-toolkit/monitoring/investigating.md b/docs/integrations/integration-toolkit/monitoring/investigating.md index 66acb73..a5bf188 100644 --- a/docs/integrations/integration-toolkit/monitoring/investigating.md +++ b/docs/integrations/integration-toolkit/monitoring/investigating.md @@ -94,6 +94,9 @@ curl -X POST 'https://integration-toolkit.sls.epilot.io/v1/integrations/{integra Things worth knowing before you use it: +- **Needs `integration:manage`.** Replay is an operator action, checked organization-wide rather + than against the integration in the path. The `integration:ingest` grant a partner's push token + carries does not cover it. - **Inbound only.** Replay reconstructs a received inbound event. It does not re-send outbound deliveries. - **Maximum 100 event ids per request.** From 66a7bd96db99f8ac7e96e59e20356b12f1b7bb74 Mon Sep 17 00:00:00 2001 From: Karl Rankla Date: Thu, 15 Oct 2026 13:20:20 +0300 Subject: [PATCH 18/19] docs(integration-toolkit): document undecided entity requirements, configuration propagation and the selector bounds - A correlation lists at most 200 entity writes; a wait_for_entities requirement no listed entity satisfies is undecided (undecided_entity_requirements), never error, and the wait ends complete once the correlation completes. - Processing picks up use case changes within up to 5 minutes; the wait accepts a new slug at once and a deleted one for up to about a minute. - The 256 limit counts UTF-16 code units after trimming; each entity write is recorded within 1,024 bytes and a unique id that does not fit is not recorded. - More than 20 occurrences of a requirement parameter is a 400, empty ones count. - Selector values are compared after trimming and Unicode lower-casing, which can differ from entity lookup for special case rules and non-breaking spaces. - Only a null or absent next_cursor ends a listing; the cursor is opaque but not secret. - A malformed use case slug in the wait is refused naming use_case_slug; an always use case event with a malformed correlation id says so in its message; an applied close carries only its outcome. --- .../interactive-registration.md | 120 ++++++++++++++---- 1 file changed, 95 insertions(+), 25 deletions(-) diff --git a/docs/integrations/integration-toolkit/interactive-registration.md b/docs/integrations/integration-toolkit/interactive-registration.md index f45a46f..068ab1a 100644 --- a/docs/integrations/integration-toolkit/interactive-registration.md +++ b/docs/integrations/integration-toolkit/interactive-registration.md @@ -231,6 +231,25 @@ The option is also exposed in the Integration Hub on the inbound use case. See If anyone is going to wait on your data by use case rather than by entity, the way you model use cases matters too — see [One use case per partner event type](#one-use-case-per-partner-event-type). +### When a configuration change takes effect + +A change to an inbound use case does not reach every part of the pipeline at the same moment: + +- **Ingest** reads the use case as saved. The `interactive` option, for instance, applies from the next + push on. +- **The wait** accepts a use case slug, or a selector on a unique id, that you saved seconds before + the call — its check reads the configuration fresh. A use case you **deleted** may still be accepted + for up to about a minute; a wait on it then cannot be met, and ends `complete` or `timeout`. +- **Processing** picks up a changed configuration — its unique ids, its mapping, enabling or disabling + it — within up to **5 minutes**. Events pushed in the meantime are processed with the previous + configuration. + +So a wait that names a unique id you have just added is accepted at once, but an event pushed in the +next few minutes may still be mapped without that key and cannot meet it: the wait ends `complete` +once the correlation completes, or `timeout`, with the requirement unmet. Likewise, a use case you have just disabled may still process events normally for +a few minutes before they fail with `USE_CASE_DISABLED`. After changing a use case, give it 5 minutes +before you test the change with a push. + ## Sending interactive events Two new fields on `POST /v3/erp/updates/events`, both at request level. `interactive` may also be set @@ -321,7 +340,8 @@ The two result fields answer different questions and are independent of each oth - the correlation already holds its 32 events (see [Correlation ids](#correlation-ids)); - the correlation belongs to another integration (see [Correlation ids](#correlation-ids)); - the event belongs to an `always` use case and its correlation id breaks the - [id format](#correlation-ids) — the request did not ask for interactive, so it is not rejected; + [id format](#correlation-ids) — the request did not ask for interactive, so it is not rejected, and + the `message` names the correlation id format as the reason; - the processing-state store is unavailable, so no record could be written. In every case the event is processed as bulk on the organization's normal lane, where the wait @@ -475,6 +495,9 @@ carry it. | `rate_limited` | A close without events found no correlation, and the organization's interactive budget could not pay for creating it (`rate_limit_unavailable` when the budget could not be checked). Until it is created, a waiter on the id is not told that nothing is coming. | Send the same close again a few seconds later. | | `ignored` | The close was not applied for another reason, named in `reasons`. `message` gives them as text, and monitoring raises `CORRELATION_CLOSE_IGNORED` with the same reasons. | See the reasons below. | +An applied close carries nothing but its outcome — `"correlation_close": { "outcome": "applied" }`. +`reasons` and `message` are present only when the close was not applied. + The `reasons` of an `ignored` close, each listed once: | Reason | What to do | @@ -539,7 +562,7 @@ waits. `status` is `accepted`, `processing`, `done`, `partial` (a non-retryable failure after some writes succeeded) or `error` (a non-retryable failure with nothing written). An event that cannot write what its use case describes is a failure, not a success with nothing to do: the use case is disabled -(`USE_CASE_DISABLED`), it has no configuration for the event (`EVENT_NOT_CONFIGURED`), or the mapping +(`USE_CASE_DISABLED`; see [When a configuration change takes effect](#when-a-configuration-change-takes-effect)), it has no configuration for the event (`EVENT_NOT_CONFIGURED`), or the mapping could not extract an entity or a field it should have written. Such an event ends `error`, or `partial` when other writes of it succeeded. An event that the mapping deliberately filters out writes nothing and is still `done`. @@ -585,12 +608,14 @@ needs has arrived. |---|---| | `correlation_id` | Required. The correlation to look at. | | `wait` | Seconds to wait, `0` to `25`. Omit or `0` for an immediate answer, which reads `completion: "timeout"` while nothing is decided yet. The ceiling is 25 s because the HTTP API cuts off at 30 s. A value outside the range is rejected with **400** naming the maximum, not silently clamped. | -| `wait_for_entities` | Entity requirements, each `slug` or `slug[key=value]`. **One requirement per occurrence**: repeat the parameter for several. An occurrence is never split, so a selector value may contain commas. A selector value matches ignoring case and surrounding whitespace, like entity lookup. Only `wait_for_entities=…` is accepted: a bracketed form such as `wait_for_entities[]=` or `wait_for_entities[0]=` is a **400**. See [Selector syntax and encoding](#selector-syntax-and-encoding). | +| `wait_for_entities` | Entity requirements, each `slug` or `slug[key=value]`. **One requirement per occurrence**: repeat the parameter for several. An occurrence is never split, so a selector value may contain commas. A selector value is compared after trimming surrounding whitespace and lower-casing; see [Value matching](#value-matching). Only `wait_for_entities=…` is accepted: a bracketed form such as `wait_for_entities[]=` or `wait_for_entities[0]=` is a **400**. See [Selector syntax and encoding](#selector-syntax-and-encoding). | | `wait_for_use_cases` | Inbound **use case slugs**. Repeat the parameter, or separate the slugs with commas in one occurrence. A bracketed form such as `wait_for_use_cases[]=` is a **400**. | Both requirement sets are optional, and both are only valid together with `correlation_id` — sending -one without it returns 400. Each set takes at most 20 requirements. When both are given, **both** must -be satisfied before the call answers `requirements_met`. +one without it returns 400. Each set takes at most 20 requirements, and each parameter at most 20 +occurrences — **empty occurrences count**, so 20 selectors plus one empty `wait_for_entities=` is a +**400** naming the limit. When both are given, **both** must be satisfied before the call answers +`requirements_met`. ```bash title="Wait for the customer's contact and a contract" curl -G 'https://integration-toolkit.sls.epilot.io/v3/erp/updates/events' \ @@ -657,9 +682,28 @@ The correlation lists its **first 50 failures** in `errors`, and `errors_total` including those of events that arrived after the correlation completed, which carry `"after_completion": true` and do not change its answer. `entities` lists at most **200 entity writes**; `entities_omitted` counts the rest, including those of an event that ended -`PROCESSING_STATE_LIMIT`. An entity that is only counted there may well have been written, but a -`wait_for_entities` requirement cannot be met by it, so such a wait ends on `complete`. A real -registration is far below either bound. +`PROCESSING_STATE_LIMIT`, and is present only when it is not zero. A real registration is far below +either bound. + +An entity that is only counted in `entities_omitted` may well have been written, but the wait cannot +see it. So once a correlation has omitted entities, a `wait_for_entities` requirement that no listed +entity satisfies is **undecided** — neither met nor missing: + +- the response names it, exactly as you passed it, in `undecided_entity_requirements`; +- it never makes `completion` `error`; +- the wait keeps waiting until the correlation completes and then ends `complete` — `complete` rather + than `requirements_met` even when every other requirement is met. + +Look such an entity up yourself, for instance by its unique id through the entity API. Use case +requirements are never undecided, because their counts are never omitted. + +```json title="Response (excerpt) — an entity requirement the correlation could not list" +{ + "completion": "complete", + "entities_omitted": 14, + "undecided_entity_requirements": ["contract[contract_number=200184]"] +} +``` #### What "arrived" means @@ -685,7 +729,8 @@ Every entity in the response carries `unique_ids`, and that map holds **exactly configured for that entity target in the use case — and nothing else**. In the example above the contact carries `customer_number` because that is its target's only unique id; its `email`, `first_name` and every other attribute the mapping wrote are not in the response, however identifying -they look. +they look. A configured unique id whose value is too long to record is left out as well; see +[Selector syntax and encoding](#selector-syntax-and-encoding). That decides what a selector can name: @@ -698,8 +743,9 @@ selector that some use case can satisfy is never rejected, and the check works b exists. A `wait_for_entities` entry whose slug no inbound use case writes is a 400 as well. Treat the 400 as a configuration error to fix, not as a reason to retry. -The check refuses a requirement only against the configuration as read after your request arrived, so -a use case you saved or changed seconds before the wait is honoured — there is no cache to wait out. +The check reads the configuration fresh, so a unique id you added seconds before the wait is accepted. +Processing takes up to 5 minutes to pick up the same change, so an event pushed in between may still +be mapped without it; see [When a configuration change takes effect](#when-a-configuration-change-takes-effect). ::: ```json title="400 — a selector key that is not a configured unique id" @@ -737,8 +783,16 @@ reported once its relations are set, so that relation is there when the wait ret Inside `slug[key=value]` the key ends at the **first** `=`, and the value runs to the final `]`, so a value may itself contain `=`, `]` and `,` — anything but `],` followed by another selector. A value -longer than 256 characters is a **400**: unique ids that long are not recorded, so it could never -match. +longer than **256** is a **400**: unique ids that long are not recorded, so it could never match. The +length is counted after trimming surrounding whitespace, in UTF-16 code units — what JavaScript's +`length` returns — so `é` and `漢` count 1 each, and an emoji counts 2. + +Recording has a second bound. Each entity write is recorded in at most **1,024 bytes**: its slug, +`_id`, `operation` and unique ids, encoded as JSON in UTF-8. Unique ids are recorded in alphabetical +order of their keys while they fit, and a value that does not fit is not recorded — it is missing +from `unique_ids`, so a selector on it is accepted but can never match. Customer, contract and meter +numbers are nowhere near this; it takes several long unique ids on one entity, such as two of 256 +multi-byte characters, to reach it. Each `wait_for_entities` parameter is exactly **one** requirement. epilot never splits it on commas, so a value that contains a comma needs no special treatment. Encode every parameter **once**, the @@ -801,10 +855,18 @@ const { data } = await axios.get('https://integration-toolkit.sls.epilot.io/v3/e }) ``` -**Value matching.** The value is compared the way the toolkit's own entity lookup compares unique ids: -ignoring letter case and surrounding whitespace on both sides, so `contact[customer_number=ab-1001]` -matches a contact stored with ` AB-1001 `. Whitespace inside the value stays significant: `MELO 001` -does not match `MELO001`. +##### Value matching + +The selector value and the recorded unique id are both compared after trimming surrounding whitespace +and lower-casing them with Unicode lower-case rules, so `contact[customer_number=ab-1001]` matches a +contact stored with ` AB-1001 `. Whitespace inside the value stays significant: `MELO 001` does not +match `MELO001`. + +For the ASCII identifiers ERPs use, this behaves like the toolkit's entity lookup by unique id. For +letters with special case rules — the Turkish dotted `İ`, the Greek final `ς` — and for non-breaking +spaces, the two may compare differently: a selector can miss an entity that lookup treats as the +same one, or match one that lookup treats as different. For such values, build the selector from +exactly the string you push. #### Use case requirements @@ -815,9 +877,12 @@ slug is unique within its integration and immutable once set. `event_name` is pa a fallback; it is not a stable key for a requirement. A slug that no inbound use case of the organization has can never match and is refused with **400**, -naming it in `use_case_slug`. Since a correlation belongs to one integration and counts only that -integration's events, a wait on a correlation that already belongs to one also refuses a slug that only -**another** integration's use cases have. +naming it in `use_case_slug`. So is an entry that is not a well-formed slug at all: slugs are lower +case, so `Customer_Account` is refused rather than matched against `customer_account`. Since a +correlation belongs to one integration and counts only that integration's events, a wait on a +correlation that already belongs to one also refuses a slug that only **another** integration's use +cases have. A use case you have just deleted may still be accepted for up to about a minute; see +[When a configuration change takes effect](#when-a-configuration-change-takes-effect). A requirement is met when **at least one event of that use case slug is done**. An event that ends `partial` does not meet it: for a use case requirement, `partial` counts as an error, because part of @@ -908,9 +973,9 @@ the kind of data that never came. Neither set can say that you have finished; on | Value | Meaning | |---|---| | `requirements_met` | Every requirement given — entity requirements, use case requirements, or both — is satisfied. The wait returns the moment this becomes true, **even if an unrelated event of the correlation failed**: a caller waiting for a contact and a contract has what it asked for. | -| `complete` | The correlation is closed and every event received for it is done. This is the hard stop for a caller that wants everything — and it also ends a requirement wait that can no longer be satisfied, which is how the portal learns that you sent an account but no contract. A wait (`wait` above 0) that ends this way raises `CORRELATION_CLOSED_INCOMPLETE` in monitoring, once per correlation, naming what is missing; a `wait=0` read that sees the same state raises nothing. | +| `complete` | The correlation is closed and every event received for it is done. This is the hard stop for a caller that wants everything — and it also ends a requirement wait that can no longer be satisfied, which is how the portal learns that you sent an account but no contract. A wait whose only open requirements are [undecided](#a-correlation-with-a-wait) ends this way too. A wait (`wait` above 0) that ends this way raises `CORRELATION_CLOSED_INCOMPLETE` in monitoring, once per correlation, naming what is missing; a `wait=0` read that sees the same state raises nothing. | | `timeout` | The wait budget elapsed, or — for a `wait=0` read — nothing is decided yet. The body still lists everything that landed so far; call again to continue waiting. The first wait that runs out on a correlation raises `CORRELATION_WAIT_TIMEOUT`; later ones on the same correlation do not, and a `wait=0` read never does. | -| `error` | A requirement can no longer be satisfied: every event of a required use case failed, or the correlation is complete with a required entity missing and an event failed (see [What "arrived" means](#what-arrived-means)). When the caller named no requirements: the correlation is complete and an event of it failed. | +| `error` | A requirement can no longer be satisfied: every event of a required use case failed, or the correlation is complete with a required entity missing and an event failed (see [What "arrived" means](#what-arrived-means)). An [undecided](#a-correlation-with-a-wait) entity requirement never counts as missing. When the caller named no requirements: the correlation is complete and an event of it failed. | **Once a correlation is complete, its answer is final.** The verdict is fixed at the moment the correlation becomes complete, which the summary states as `complete: true` and `completed_at`. Read @@ -1015,8 +1080,13 @@ curl -G 'https://integration-toolkit.sls.epilot.io/v3/erp/updates/correlations' ``` Each item in `results` is the same correlation summary the wait returns, without the long poll. -`next_cursor` is `null` on the last page. Pass it back exactly as you received it; do not build or -decode one. + +**Only a `null` or absent `next_cursor` ends the listing.** A page can be shorter than `limit`, or even +empty, and still carry a cursor — a listing filtered by `integration_id` reads a bounded slice of the +organization's correlations per call — so keep paging until the cursor is gone. Pass it back exactly +as you received it; do not build or decode one. The cursor is opaque but not secret: it may encode +correlation ids and times outside your filter, including those of other integrations, so treat it like +the listing itself and keep it out of places that people without access to it can read. `horizon_days` states how far back the listing reaches: **7 days**, which is how long correlation records are kept. A correlation older than that is gone, not hidden, and the response says so rather @@ -1149,7 +1219,7 @@ end: | `CORRELATION_COMPLETED` | info | A correlation reached `complete`, with its event and entity counts and the ingest-to-complete duration | | `CORRELATION_CLOSED_EMPTY` | info | A correlation was closed without a single event — the partner said nothing is coming for this customer, including a close that created the correlation | | `CORRELATION_CLOSE_IGNORED` | warning | A request set `correlation_complete: true` but the close was not applied: the response asked for a retry, the queue refused one of its events, the correlation does not exist and the request recorded nothing under it, a close without events could not be paid for, or the correlation belongs to another integration. No correlation was created or closed; `detail.reasons` names the cause, the same reasons the response's [`correlation_close`](#what-became-of-the-close) carries | -| `CORRELATION_CLOSED_INCOMPLETE` | warning | A wait (`wait` above 0) ended `complete` without its requirements met — the partner closed without sending what was asked for. Raised once per correlation, never by a `wait=0` read; the details list what is missing as `missing_entities` and `missing_use_cases` | +| `CORRELATION_CLOSED_INCOMPLETE` | warning | A wait (`wait` above 0) ended `complete` without its requirements met — the partner closed without sending what was asked for. Raised once per correlation, never by a `wait=0` read; the details list what is missing as `missing_entities` and `missing_use_cases`, and undecided entity requirements as `undecided_entities` | | `CORRELATION_LATE_EVENT` | warning | An event arrived after `correlation_complete`. It is processed, but does not change a complete correlation's answer | | `CORRELATION_EVENT_LIMIT_EXCEEDED` | warning | A correlation had already received its 32 events, so further events under its id were processed as plain bulk with no processing record; the details name the correlation id | | `CORRELATION_ID_MISSING` | error | A request set `correlation_complete: true` without a request-level `correlation_id` and was rejected with 400; nothing was closed | From d37cbb4a760e36596454e1dd3379f05262d63908 Mon Sep 17 00:00:00 2001 From: Karl Rankla Date: Thu, 15 Oct 2026 21:07:25 +0300 Subject: [PATCH 19/19] docs(integration-toolkit): a partner's own correlation id needs result_correlation_id on the hook --- .../integration-toolkit/interactive-registration.md | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/docs/integrations/integration-toolkit/interactive-registration.md b/docs/integrations/integration-toolkit/interactive-registration.md index 068ab1a..c8a08af 100644 --- a/docs/integrations/integration-toolkit/interactive-registration.md +++ b/docs/integrations/integration-toolkit/interactive-registration.md @@ -110,13 +110,18 @@ If your middleware sends a fixed or hard-coded correlation id today — some int Talend or similar tools put the same id on every push — replace it with the one the hook passes. See [Correlation ids](#correlation-ids) for the format and the limits. -Your hook response does not change. The portal minted the id, so it already knows it, and when your -response carries no `correlation_id` it waits on its own id. +Your hook response does not change. The portal minted the id, so it already knows it, and it waits on +its own id whatever your response carries. **Optional: return the id from the hook.** You only need to do this if you push under an id of your own rather than the portal's — for example because your middleware already has a transaction id it -threads through every push. Then answer the hook with that id as `correlation_id` in the JSON response -body, and the portal waits on yours instead of its own. +threads through every push. Return that id in your hook response, and have the app's hook declare where +it is with `result_correlation_id`, e.g. `{{CallResponse.data.acknowledgeId}}`; the portal then waits +on yours instead of its own. There is no default: a `correlation_id` field in your response is ignored +unless the hook declares it, so an unrelated field of that name (a trace id, say) can never redirect +the wait. A declared id that is missing from the response falls back to the portal's id; one that does +not match the [id format](#correlation-ids) leaves nothing to wait on, and the portal falls back to +searching for the customer. :::note When the use case is dedicated to registrations If an inbound use case exists only to serve registrations, epilot can set it to