Skip to main content

Direct Mode

Direct mode lets your integration send payloads that are already in entity-operation format, skipping the mapping engine entirely. It is built for middleware that already produces entity-shaped data โ€” for example an integration layer on your side that knows your epilot schemas and does its own transformation.

Direct mode removes only the transformation step. Everything else in the inbound pipeline behaves exactly as in mapped mode:

  • Ingest deduplication (deduplication_id)
  • Buffering, per-entity ordering, and fair processing
  • Create-vs-update resolution against existing entities
  • Relation and relation-reference resolution, including automatic stub creation
  • Meter-reading matching
  • Echo prevention โ€” inbound writes do not trigger automations by default and never flow back out
  • Monitoring events

What you give up is the mapping engine itself: field mappings, JSONata and JSONPath expressions, constants, and the mapping-only field types (see Unsupported in Direct Mode). If your source system emits raw ERP payloads that need transformation, use Mapping instead. A single integration can freely mix direct and mapped use cases.

How It Worksโ€‹

flowchart TD A["Middleware sends POST /v3/erp/updates/events<br/>(payload = direct payload)"] --> B["epilot resolves the use case configuration<br/>(direct: true)"] B --> C{"Payload valid?<br/>schema ยท version ยท entity allowlist"} C -- no --> X["Rejected per-event, never processed<br/>HTTP 422 + DIRECT_* monitoring event"] C -- yes --> D["Event queued<br/>deduplication ยท ordering ยท fairness unchanged"] D --> E["Operations translated to internal<br/>entity / meter-reading updates<br/>(no mapping engine involved)"] E --> F["Create-vs-update, relations, and meter readings<br/>resolve as in mapped mode"] F --> G["Monitoring events emitted<br/>(ENTITY_CREATED, ENTITY_UPDATED, โ€ฆ)"]

Enabling Direct Modeโ€‹

Direct mode is enabled per inbound use case. All events routed to that use case are then interpreted as direct payloads โ€” a use case is either fully direct or fully mapped, never both.

In the epilot 360 UI โ€” the usual way to enable it: open your integration's inbound use case and flip the Direct mode toggle. The JSON configuration editor disappears (direct use cases are configured visually), and a Write access choice appears: Open (any entity may be written) or Restricted (an entity allowlist built with schema and unique-id attribute pickers). Flipping the toggle on an existing mapped use case pre-fills the allowlist from the mapped entities' schemas and unique ids, and direct use cases are marked with a "Direct" badge in the use case overview.

Via the API, the same switch is direct: true on the use case's configuration โ€” the JSON below is exactly what the toggle produces.

The minimal configuration:

Open mode โ€” any entity may be written
{
"direct": true
}

With an optional entity allowlist:

Allowlist mode
{
"direct": true,
"entities": [
{ "entity_schema": "contract", "unique_ids": ["contract_number"] },
{ "entity_schema": "contact", "unique_ids": ["customer_number"] }
]
}

Notes:

  • Allowlist entries need no fields array โ€” there is nothing to map.
  • Every allowlist entry must declare at least one unique id โ€” saving a configuration with an empty unique_ids array is rejected (the UI enforces the same rule inline).
  • Declared unique_ids are checked against your real entity schemas when the use case is saved, so typos surface at design time.
  • Direct mode requires the v3 events endpoint (POST /v3/erp/updates/events with integration_id). The deprecated v1/v2 endpoints do not support it.

Open Mode vs Entity Allowlistโ€‹

If entities is absent or empty, the use case runs in open mode: any entity_slug is accepted.

If entities is non-empty, it acts as an allowlist for entity operations:

  1. The operation's entity_slug must equal the entity_schema of some allowlist entry, otherwise the operation is rejected with DIRECT_ENTITY_NOT_ALLOWED.
  2. The operation's unique_ids keys must be exactly the declared set (order-insensitive), or exactly ["_id"] โ€” referencing an entity directly by its epilot ID is always allowed. Any other key set is rejected with DIRECT_ENTITY_NOT_ALLOWED, and the error message names the expected keys.

Meter-reading operations are not allowlist-gated โ€” the allowlist applies to entity operations only.

Payload Contract (Version "1")โ€‹

The direct payload travels inside the existing ErpEventV3.payload โ€” either as a JSON object or as a serialized JSON string (with format: "json"). XML is rejected for direct use cases.

The schema is snake_case throughout. All object schemas are strict: unknown keys are rejected with a clear error naming the offending path. The single exception is attributes, which is a free-form record โ€” attribute values pass through verbatim, except relation envelopes, which are validated and translated (see Relations).

Versioning Policyโ€‹

The version field pins the payload schema:

  • Changes within a version are additive-only โ€” existing payloads never break within version "1".
  • If a breaking revision is ever needed, it arrives as version "2" with both versions accepted in parallel โ€” none is currently planned.
  • An unknown version is rejected with DIRECT_VERSION_UNSUPPORTED.

Envelopeโ€‹

{
"version": "1",
"operations": [
{
"entity_slug": "contact",
"unique_ids": { "customer_number": "42" },
"attributes": {
"first_name": "Erika",
"last_name": "Mustermann"
}
}
]
}
FieldRequiredDescription
versionYesPayload schema version. Currently only "1" (string). Unknown values are rejected with DIRECT_VERSION_UNSUPPORTED.
operationsYes1 to 100 operations, applied in order. Each is an entity operation or a meter reading operation.

Entity Operationsโ€‹

An operation with type omitted or set to "entity":

{
"type": "entity",
"entity_slug": "contract",
"unique_ids": { "contract_number": "C-123" },
"mode": "upsert",
"attributes": {
"status": "active",
"contract_start_date": "2026-09-01"
}
}
FieldRequiredDescription
typeNo"entity" (default when omitted).
entity_slugYesTarget epilot entity schema (e.g. "contact", "contract"). Non-empty string.
unique_idsYesObject with at least one key. Keys are attribute names used to find the existing entity; values are strings or numbers (numbers are coerced to strings). Values that are empty after trimming are rejected. Use the single key "_id" to reference an entity directly by its epilot ID. See Unique Identifiers for lookup behavior.
unique_id_typesNoPer-field type hint, "email" or "phone" โ€” overrides the server-side schema derivation. See Email and Phone Unique Identifiers.
modeNo"upsert" (default), "delete", or "purge".
attributesYes for upsertThe attributes to write, verbatim. Optional for delete / purge. Relation envelopes inside attributes are validated and translated (see Relations); everything else passes through untouched.

Unknown keys on the operation object are rejected (strict schema), while unknown keys inside attributes are free-form and pass through.

Delete example โ€” attributes may be omitted:

{
"entity_slug": "contract",
"unique_ids": { "contract_number": "C-123" },
"mode": "delete"
}

Behavior notes:

  • Restore on upsert. An upsert whose unique ids match a soft-deleted entity restores it before applying the attributes (reported as ENTITY_UPDATED, alongside a SOFT_DELETED_ENTITY_MATCHED warning).
  • Purge. purge deletes irrecoverably and is reported as ENTITY_DELETED with mode purge in the monitoring detail โ€” there is no separate purge code.
  • Unique id not in the schema. A unique-id attribute that does not exist in the entity schema never matches anything โ€” every event then creates a new entity, and each write also emits an error-level UNIQUE_ID_NOT_IN_SCHEMA monitoring event. Catch this before go-live with simulateDirect's schema warnings.
  • _id references never create. unique_ids: {"_id": โ€ฆ} pointing at a nonexistent (or purged) entity fails the event after retries โ€” the _id form is strictly a reference to an entity that exists.

Meter Reading Operationsโ€‹

An operation with type: "meter_reading":

{
"type": "meter_reading",
"meter": { "unique_ids": { "meter_number": "M-42" } },
"counter": { "unique_ids": { "obis_number": "1-0:1.8.0" } },
"mode": "upsert",
"reading_matching": "strict-date",
"attributes": {
"external_id": "R-9",
"timestamp": "2026-08-24T06:00:00Z",
"source": "ERP",
"value": 12345.6,
"direction": "feed-in"
}
}
FieldRequiredDescription
typeYesMust be "meter_reading" for this variant.
meterYes{ "unique_ids": { ... } } with at least one key โ€” identifies the meter.
counterNo{ "unique_ids": { ... } } with at least one key when present โ€” identifies the meter counter.
modeNo"upsert" (default) or "delete".
reading_matchingNo"external_id" or "strict-date". See Reading Matching Strategies for semantics.
attributesYesThe reading itself; see the table below.

Reading attributes:

AttributeRequiredDescription
external_idYesString or number (numbers are coerced to strings).
timestampYesISO-8601 date or datetime.
sourceYesOne of ECP, ERP, 360, journey-submission.
valueYesNumber, or a numeric string (coerced to a number). Non-numeric values are rejected.
directionNoFor example "feed-in".
other keysNoExtras such as reason, read_by, status, metadata pass through verbatim.
caution

reading_matching: "strict-date" requires counter. Date-based matching searches for existing readings on the counter โ€” without a counter it can never find a match, so the combination is rejected at validation time.

Relationsโ€‹

Relations use the same envelope concepts as mapped mode, written directly as attribute values. Resolution, tags, and automatic stub creation behave exactly as described in Relations.

Relation Operationsโ€‹

On any attribute inside attributes, a relation value is either a bare array (shorthand for _set) or an object with exactly one operation key โ€” _set, _append, or _append_all, with the same semantics as in mapped mode (see Relation Operations):

"contacts": { "$relation": [ { "schema": "contact", "unique_ids": { "customer_number": "42" } } ] }
"contacts": { "$relation": { "_append": [ { "schema": "contact", "unique_ids": { "customer_number": "43" } } ] } }

Only those three operation keys are accepted โ€” any other key is rejected at validation time, so a typo like _apend fails fast with an actionable error instead of producing an unresolved relation downstream.

Relation Itemsโ€‹

Each item in the operation array is one of two forms.

Already-resolved reference โ€” you know the epilot entity ID:

{ "entity_id": "0195c3d2-7f4a-71b8-9e02-4c1a5d6e8f90", "tags": ["primary"] }

Lookup by unique identifiers โ€” the pipeline resolves (or stub-creates) the target:

{
"schema": "contact",
"unique_ids": { "customer_number": "42" },
"tags": ["primary"]
}
FieldRequiredDescription
entity_idresolved formThe epilot entity ID of the target.
schemalookup formEntity schema of the target.
unique_idslookup formAt least one key; same value rules as top-level unique_ids.
unique_id_typesNoPer-field "email" / "phone" override, same as top level.
tagsNoRelation tags (labels), e.g. ["primary"].

Using "_id" as the sole key of unique_ids โ€” at the top level of an entity operation or inside a relation lookup item โ€” references the entity directly by its epilot ID, skipping the search. The ["_id"] key set is always accepted by the entity allowlist.

Email and Phone Unique Identifiersโ€‹

Email and phone attributes are repeatable field types in epilot and need type-aware matching (see Special Identifier Types). In direct mode this is handled server-side: the pipeline inspects the target entity schema and derives the field type for each unique identifier whose schema attribute has type email or phone. You normally do not need to do anything:

{
"entity_slug": "contact",
"unique_ids": { "email": "erika@example.com" },
"attributes": { "first_name": "Erika" }
}

To override or supplement the derivation, set unique_id_types explicitly โ€” explicit values always win:

{
"entity_slug": "contact",
"unique_ids": { "email": "erika@example.com" },
"unique_id_types": { "email": "email" },
"attributes": { "first_name": "Erika" }
}

Derivation applies to the top-level unique_ids and to relation / relation-reference lookup items (per item schema). If a schema cannot be resolved, derivation is skipped for that schema with a warning โ€” explicit unique_id_types still apply, so set them explicitly for custom schemas you rely on.

Relation Referencesโ€‹

A relation reference points at an item inside a repeatable attribute of another entity โ€” for example one address out of a contact's address list. Same concept as Relation References in mapped mode, expressed directly:

"billing_address": {
"$relation_ref": {
"_append": [
{
"schema": "contact",
"unique_ids": { "customer_number": "42" },
"path": "address",
"value": { "street": "Main Street", "city": "Berlin" }
}
]
}
}
FieldRequiredDescription
schemaYesEntity schema of the target entity.
unique_idsYesIdentifies the target entity; same rules as relation lookup items.
unique_id_typesNoPer-field "email" / "phone" override.
pathYesAttribute on the target entity holding the repeatable array (e.g. "address").
valueYesThe item to match (or create) at that path.

The operation envelope follows the same rules as $relation.

Sending Direct Eventsโ€‹

Direct events use the same endpoint as mapped events: POST /v3/erp/updates/events. The only difference is the payload content โ€” the routing (use_case_slug or event_name), timestamps, deduplication, and ordering fields are unchanged.

{
"integration_id": "123e4567-e89b-12d3-a456-426614174000",
"events": [
{
"use_case_slug": "customer-sync",
"timestamp": "2026-08-24T06:00:00Z",
"format": "json",
"deduplication_id": "customer-42-2026-08-24T060000-001",
"payload": {
"version": "1",
"operations": [
{
"entity_slug": "contact",
"unique_ids": { "customer_number": "42" },
"attributes": {
"first_name": "Erika",
"last_name": "Mustermann"
}
},
{
"entity_slug": "contract",
"unique_ids": { "contract_number": "C-123" },
"attributes": {
"status": "active",
"contacts": {
"$relation": [
{ "schema": "contact", "unique_ids": { "customer_number": "42" }, "tags": ["primary"] }
]
}
}
}
]
}
}
]
}

The payload may also be a serialized JSON string (per the ErpEventV3 contract); it is parsed on arrival. format: "xml" is rejected for direct use cases.

Deduplicationโ€‹

deduplication_id works exactly as in mapped mode โ€” see Deduplication.

One caveat matters more in direct mode: the pipeline additionally applies content-based deduplication โ€” two byte-identical events within a 5-minute window collapse silently into one. Direct payloads are far more likely to be byte-identical than raw ERP payloads (no incidental timestamps or sequence fields from the source system). If your middleware can legitimately send the same operation twice in quick succession and both must be processed, make each event distinguishable โ€” set a unique deduplication_id per logical event (for example, include a source sequence number or timestamp).

Orderingโ€‹

Ordering is derived exactly as in mapped mode:

  • The request-level and event-level group_id control cross-event parallelism (omit for strict per-integration ordering).
  • Within the pipeline, per-entity ordering is derived from entity_slug + unique_ids โ€” two operations targeting the same entity are always processed in order, regardless of which events carried them.

Validation and Failure Behaviorโ€‹

Direct payloads are validated fully on arrival โ€” schema, version, and allowlist โ€” using the same code path the pipeline uses, so there is no drift between what the API accepts and what the pipeline processes.

  • A malformed request envelope (invalid ErpUpdatesEventsV3Request) is rejected with HTTP 400.
  • An event whose direct payload fails validation gets a per-event status: "error" in the response, and the overall request returns HTTP 422. Rejected events are never processed โ€” fix and resend.
  • Error messages are actionable: they name the operation index and the field path, e.g. operations[3].unique_ids: at least one unique identifier is required.
  • Each rejection also emits a monitoring event with the matching DIRECT_* code (see Monitoring).

An event accepted at ingest can still fail inside the pipeline in one scenario: the use case configuration changed between acceptance and processing (configurations are cached โ€” see configuration propagation). Such failures do not retry โ€” the event is dropped and reported via a monitoring event with the matching DIRECT_* code.

Dry Run: simulateDirectโ€‹

POST /v1/erp/updates/direct_simulation (operation ID simulateDirect) validates a direct payload against a configuration without persisting anything โ€” the direct-mode counterpart of simulateMappingV2. Use it while developing your middleware, and in CI against your fixture payloads.

The same dry run is available in the epilot 360 UI: the use case's Test tab works for direct use cases as it does for mapped ones โ€” paste a payload and review the verdict, collected errors, and translated preview without writing anything.

note

Request-level validation intercepts some contract violations before the dry run executes โ€” an unsupported version, more than 100 operations, or a structurally malformed envelope return HTTP 400 with schema errors instead of a 200 verdict. The collected-errors behavior below applies to the checks the dry run itself performs (allowlist, relation envelopes, meter-reading rules, unique-id values, โ€ฆ). On the live events endpoint the same defects surface as per-event errors with DIRECT_* codes.

Request:

{
"event_configuration": {
"direct": true,
"entities": [
{ "entity_schema": "contract", "unique_ids": ["contract_number"] }
]
},
"payload": {
"version": "1",
"operations": [
{
"entity_slug": "contract",
"unique_ids": { "contract_number": "C-123" },
"attributes": { "status": "active" }
},
{
"entity_slug": "order",
"unique_ids": { "order_number": "O-9" },
"attributes": { "status": "open" }
}
]
}
}

Response โ€” all errors are collected across operations (the simulation does not stop at the first failure, unlike live ingest where the event is all-or-nothing):

{
"valid": false,
"errors": [
{
"code": "DIRECT_ENTITY_NOT_ALLOWED",
"message": "operations[1]: entity_slug \"order\" is not allowed by the use case's entity allowlist",
"operation_index": 1
}
]
}

When the payload is valid, the response includes the translated internal update previews โ€” exactly what the pipeline would process โ€” plus schema warnings for unique identifiers that do not exist in the target schema (same design-time check as mapping simulation):

{
"valid": true,
"errors": [],
"warnings": [
{
"entity_schema": "contract",
"field": "erp_contract_key",
"message": "Unique identifier \"erp_contract_key\" not found in schema \"contract\""
}
],
"entity_updates": [
{
"entity_slug": "contract",
"unique_identifiers": { "contract_number": "C-123" },
"mode": "upsert",
"attributes": { "status": "active" }
}
],
"meter_reading_updates": []
}

Schema warnings do not fail the simulation โ€” an unknown unique identifier is a warning because lookups on it will simply never match, causing every event to create a new entity. That is almost always a configuration mistake worth fixing before go-live.

Monitoringโ€‹

Direct mode adds three monitoring codes. All three are error-level:

CodeLevelCategoryMeaning
DIRECT_PAYLOAD_INVALIDerrorvalidationDirect payload failed schema validation, JSON parsing, or used the XML format.
DIRECT_VERSION_UNSUPPORTEDerrorvalidationPayload version is not in the supported set.
DIRECT_ENTITY_NOT_ALLOWEDerrorconfigurationentity_slug or unique_ids keys not permitted by the use case's entity allowlist.

Success paths reuse the existing codes โ€” direct operations are indistinguishable from mapped ones once translated: ENTITY_CREATED, ENTITY_UPDATED, ENTITY_DELETED, ENTITY_NO_OP, METER_READING_UPSERTED, METER_READING_DELETED.

The existing pipeline warning codes also apply unchanged; the ones you are most likely to meet in direct mode: SOFT_DELETED_ENTITY_MATCHED (upsert matched a soft-deleted entity โ€” it is restored), UNIQUE_ID_MULTIPLE_MATCHES, RELATION_REF_VALUE_UNDEFINED / RELATION_REF_ITEM_NOT_FOUND, and the error-level UNIQUE_ID_NOT_IN_SCHEMA.

Batch shape: several readings for the same meter/counter in one event are written as one batch and produce one METER_READING_UPSERTED event whose detail carries the reading count and external ids โ€” not one event per reading.

Unsupported in Direct Modeโ€‹

The following are intentionally not supported in direct mode. In each case, mapped mode remains fully available โ€” a single integration can mix direct and mapped use cases freely.

Not supportedWhyWhat to use instead
Prune-scope operations (upsert-prune-scope-*)Destructive bulk semantics need their own design before being exposed on a raw wire format.Mapped mode Operation Modes.
File proxy URL constructionThe proxy URL embeds server-side context the integrator does not have.Mapped mode File Proxy URL Mapping.
portal_refResolved from server-side portal configuration.Mapped mode Portal Reference Mapping.
env_var_refEnvironment variables and secrets are resolved server-side and must not round-trip through the integrator.Mapped mode Environment Variable Reference Mapping.
PricingCouples to the pricing engine's server-side product and price resolution.Mapped mode Pricing.
XML payloadsThe direct contract is JSON-only by design.Send JSON; for XML-emitting sources use mapped mode.
CSV imports against direct use casesCSV imports emit mapping-shaped events; routed to a direct use case they fail with DIRECT_PAYLOAD_INVALID.Route CSV imports to mapped use cases.
v1/v2 events endpointsThe legacy configuration paths do not carry the direct flag.POST /v3/erp/updates/events with integration_id.
Meter-reading allowlist gatingThe entity allowlist covers entity operations only.Open by design; gate entity operations if needed.

Operational Notesโ€‹

  • Size budget. Each event is processed as a single message with a 256 KiB limit (1 MiB for meter-reading batches). A payload holds at most 100 operations; split larger batches across multiple events. The operation limit is additive to raise in a future revision if needed.
  • No entity-attribute validation โ€” by design. Attributes are written verbatim; attributes not defined in the schema are stored but not indexed. This is intentional and common integration practice (mapped mode behaves the same way). The design-time guards are simulateDirect and the unique-identifier schema warnings.
  • Content-based deduplication. Byte-identical events within 5 minutes collapse silently โ€” set deduplication_id deliberately (see Deduplication).
  • Automations and echo. Inbound sync writes do not trigger entity automations by default โ€” an automation can opt in via its trigger's "Ignore system activities?" setting โ€” and they never echo back out through outbound delivery. Same behavior as mapped mode.