From f488834bbc8cbae73eb1659ec04161db0a5c0fd2 Mon Sep 17 00:00:00 2001 From: Karl Rankla Date: Fri, 14 Aug 2026 10:55:51 +0300 Subject: [PATCH 1/6] docs: add outbound file delivery guide --- docs/integrations/core-events.md | 4 +- .../integration-toolkit/configuration.md | 21 +- .../external-monitoring-events.md | 2 +- .../integration-toolkit/file-proxy.md | 4 + .../outbound-file-delivery.md | 298 ++ .../integration-toolkit/overview.md | 5 +- .../CustomerRequestSubmitted.config.json | 209 ++ .../CustomerRequestSubmitted.sample.json | 300 +++ .../CustomerRequestSubmitted.schema.json | 2398 +++++++++++++++++ 9 files changed, 3234 insertions(+), 7 deletions(-) create mode 100644 docs/integrations/integration-toolkit/outbound-file-delivery.md create mode 100644 static/events/CustomerRequestSubmitted.config.json create mode 100644 static/events/CustomerRequestSubmitted.sample.json create mode 100644 static/events/CustomerRequestSubmitted.schema.json diff --git a/docs/integrations/core-events.md b/docs/integrations/core-events.md index 02edf5b5..6df4e83f 100644 --- a/docs/integrations/core-events.md +++ b/docs/integrations/core-events.md @@ -36,6 +36,8 @@ All events include these fields: ### Customer + + ### Billing Account @@ -76,4 +78,4 @@ These events are triggered manually via automation. - \ No newline at end of file + diff --git a/docs/integrations/integration-toolkit/configuration.md b/docs/integrations/integration-toolkit/configuration.md index 500764b2..4d9a3347 100644 --- a/docs/integrations/integration-toolkit/configuration.md +++ b/docs/integrations/integration-toolkit/configuration.md @@ -122,7 +122,7 @@ curl -X POST 'https://erp-integration.sls.epilot.io/v1/integrations/{integration | `configuration` | object | Yes | Type-specific configuration (see sections below) | :::info -- `file_proxy` — On-demand file serving from external document systems. See the [File Proxy guide](./file-proxy.md). +- `file_proxy` — On-demand file serving from external systems or outbound delivery of epilot files. See the [File Proxy](./file-proxy.md) and [Outbound File Delivery](./outbound-file-delivery.md) guides. - `managed_call` — Synchronous external API calls with JSONata mapping. See [Managed Call Use Cases](#managed-call-use-cases). - `secure_proxy` — Route requests through epilot's secure proxy for static IP or VPN access. See [Secure Proxy Use Cases](#secure-proxy-use-cases). ::: @@ -181,11 +181,11 @@ curl -X POST 'https://erp-integration.sls.epilot.io/v1/integrations/{integration | Property | Type | Required | Description | |----------|------|----------|-------------| -| `id` | string (UUID) | Yes | Unique identifier for the mapping | +| `id` | string (UUID) | No | Unique identifier for the mapping; generated when omitted | | `name` | string | Yes | Display name for the mapping | | `enabled` | boolean | Yes | Whether this mapping is active | -| `jsonata_expression` | string | For `webhook` delivery | JSONata expression to transform the event payload. Required for `webhook` delivery; ignored for `poll` delivery | -| `delivery` | object | Yes | How the event is delivered — discriminated on `type`: `webhook` or `poll` | +| `jsonata_expression` | string | For `webhook` delivery | JSONata expression to transform the event payload. Required for `webhook`, ignored for `poll`, and rejected for `file_proxy` delivery | +| `delivery` | object | Yes | How the event is delivered — discriminated on `type`: `webhook`, `poll`, or `file_proxy` | #### Delivery Types @@ -228,6 +228,19 @@ curl -X POST 'https://erp-integration.sls.epilot.io/v1/integrations/{integration Everything beyond the configuration contract — the polling and acknowledgement API, lease and ordering semantics, retention and expiry behavior, the dead-letter queue and operator actions, and poll-mode monitoring — is documented on the dedicated [Pollable Outbound](./pollable-outbound.md) page. +**File proxy delivery (push):** points to an upload-direction `file_proxy` use case in the same integration. The referenced recipe owns fan-out, payload mapping, file selection, authentication, and HTTP steps. `jsonata_expression` is rejected on this mapping type. See [Outbound File Delivery](./outbound-file-delivery.md) for the complete setup and runtime behavior. + +```json +"delivery": { + "type": "file_proxy", + "use_case_slug": "customer-request-document-upload" +} +``` + +| Property | Type | Required | Description | +| --- | --- | --- | --- | +| `use_case_slug` | string | Yes | Slug of an upload-direction `file_proxy` use case in the same integration | + ## Mapping Configuration Schema ### Version 2.0 Structure diff --git a/docs/integrations/integration-toolkit/external-monitoring-events.md b/docs/integrations/integration-toolkit/external-monitoring-events.md index 5f25fef6..7214abf9 100644 --- a/docs/integrations/integration-toolkit/external-monitoring-events.md +++ b/docs/integrations/integration-toolkit/external-monitoring-events.md @@ -1,5 +1,5 @@ --- -sidebar_position: 7 +sidebar_position: 8 title: External Monitoring Events description: Push monitoring events from an external system (e.g. an integration middleware) into epilot so the Integration Hub is your central monitoring point, with a cross-system event trace slug: /integrations/integration-toolkit/external-monitoring-events diff --git a/docs/integrations/integration-toolkit/file-proxy.md b/docs/integrations/integration-toolkit/file-proxy.md index bdb30367..83566c9d 100644 --- a/docs/integrations/integration-toolkit/file-proxy.md +++ b/docs/integrations/integration-toolkit/file-proxy.md @@ -7,6 +7,10 @@ slug: /integrations/integration-toolkit/file-proxy # File Proxy +:::info File direction +This page covers files moving **into epilot on demand** from an external archive. To deliver files referenced by epilot events to an external API, see [Outbound File Delivery](./outbound-file-delivery.md). +::: + The File Proxy enables epilot to serve files from external document systems (e.g., ERP document archives, document management systems) **on demand**, without migrating files into epilot's storage. During [inbound sync](./inbound/getting-started.md), file entities are created with a `custom_download_url` pointing to the file proxy. When a user views the file, epilot's file service verifies the request and the proxy fetches the document from the external system in real time. Each file proxy configuration is stored as a **use case** with `type: 'file_proxy'` within an integration. The configuration describes how to authenticate, which HTTP calls to make, and how to extract the file from the response — all declaratively, without code changes for new integrations. diff --git a/docs/integrations/integration-toolkit/outbound-file-delivery.md b/docs/integrations/integration-toolkit/outbound-file-delivery.md new file mode 100644 index 00000000..822b8a88 --- /dev/null +++ b/docs/integrations/integration-toolkit/outbound-file-delivery.md @@ -0,0 +1,298 @@ +--- +sidebar_position: 7 +title: Outbound File Delivery +description: Deliver files referenced by epilot events to an external API with fan-out, mapping, retries, and monitoring +slug: /integrations/integration-toolkit/outbound-file-delivery +--- + +# Outbound File Delivery + +Outbound file delivery sends files referenced by an epilot event to an external document API. The Integration Toolkit fetches each file from epilot, maps the event and file into the target API's format, and runs a declarative HTTP workflow. + +`CustomerRequestSubmitted` is the main example: one submitted request can contain several `event_attachments`, and each attachment is delivered and monitored independently. + +:::info Upload versus download +This page covers files moving **out of epilot**. To serve files from an external archive when a user opens them in epilot, use the [download File Proxy](./file-proxy.md). +::: + +## How it works + +The setup uses two use cases in the same integration: + +| Use case | Decides | Configuration | +| --- | --- | --- | +| `outbound` | **When** to deliver | Event Catalog event, optional event filter, and a pointer to the upload recipe | +| `file_proxy` with `direction: "upload"` | **What and how** to deliver | Fan-out, file selection, mapping, authentication, HTTP steps, limits, and success criteria | + +```mermaid +sequenceDiagram + participant Event as Event Catalog + participant Outbound as Outbound delivery + participant Queue as File delivery queue + participant File as epilot File API + participant Target as External document API + + Event->>Outbound: CustomerRequestSubmitted + Outbound->>Outbound: Apply event_filter and fan_out + loop Each event attachment + Outbound->>Queue: Enqueue durable delivery + Queue->>File: Fetch entity_id + version_index + File-->>Queue: File bytes and metadata + Queue->>Queue: Evaluate lookups and params_mapping + Queue->>Target: Execute configured HTTP steps + Target-->>Queue: Result / external document ID + end +``` + +The outbound delivery is a pure pointer to the file-proxy use-case slug. The recipe is resolved at runtime, so you can create the two use cases in either order. A missing or disabled target is visible through monitoring and the outbound status endpoint. + +## Before you start + +You need: + +- an Integration Toolkit integration; +- an Event Catalog event containing a file reference, such as [`CustomerRequestSubmitted`](/docs/integrations/core-events#customer); +- an external HTTP endpoint that accepts the file; and +- environment values for target URLs and credentials. + +The examples use the Integration Toolkit API at `https://integration-toolkit.sls.epilot.io`. + +## 1. Create the upload recipe + +Create a `file_proxy` use case with `direction: "upload"`: + +```bash +curl -X POST 'https://integration-toolkit.sls.epilot.io/v1/integrations/{integrationId}/use-cases' \ + -H 'Authorization: Bearer ' \ + -H 'Content-Type: application/json' \ + -d '{ + "name": "Customer request document upload", + "slug": "customer-request-document-upload", + "type": "file_proxy", + "enabled": true, + "configuration": { + "direction": "upload", + "fan_out": { + "enabled": true, + "split_expression": "event_attachments" + }, + "lookups": { + "documentType": { + "source": "ticket._purpose[0]", + "entries": { + "termination": "CANCELLATION", + "complaint": "COMPLAINT" + }, + "default": "OTHER", + "on_miss": "warn" + } + }, + "constants": { + "sourceSystem": "epilot" + }, + "params_mapping": "{ \"customerNumber\": contact.customer_number, \"filename\": $item.filename, \"mimeType\": $file.mime_type, \"fileData\": $file_base64, \"documentType\": $lookups.documentType, \"submittedAt\": $germanDate($now()) }", + "required_params": ["customerNumber", "filename", "fileData"], + "auth": { + "type": "oauth2_client_credentials", + "token_url": "\\{{env.DOCUMENT_TOKEN_URL}}", + "client_id": "\\{{env.DOCUMENT_CLIENT_ID}}", + "client_secret": "\\{{env.DOCUMENT_CLIENT_SECRET}}" + }, + "steps": [ + { + "method": "POST", + "url": "\\{{env.DOCUMENT_API_URL}}/documents", + "headers": { + "Authorization": "Bearer {{auth_token}}", + "Content-Type": "application/json", + "Idempotency-Key": "{{custom_key}}" + }, + "body": "{\"customerNumber\":{{json params.customerNumber}},\"filename\":{{json params.filename}},\"mimeType\":{{json params.mimeType}},\"fileData\":{{json params.fileData}},\"documentType\":{{json params.documentType}},\"sourceSystem\":{{json params.sourceSystem}}}", + "response_type": "json" + } + ], + "upload": { + "max_file_bytes": 104857600, + "max_delivery_attempts": 8, + "success_when": "statusCode >= 200 and statusCode < 300", + "external_id": "steps[-1].body.documentId" + } + } + }' +``` + +### Upload configuration + +| Field | Required | Description | +| --- | --- | --- | +| `direction` | Yes | Must be `upload`. Omitting it means the existing download behavior. | +| `steps` | Yes | One or more HTTP requests. Upload steps support `GET`, `POST`, `PUT`, and `PATCH`. | +| `upload` | Yes | File-size limit, retry limit, optional success predicate, and external ID expression. | +| `params_mapping` | Yes | JSONata expression returning the `params` object used by HTTP templates. | +| `fan_out` | No | Splits one event into independent deliveries. Required for one upload per attachment. | +| `file_source` | No | JSONata selecting the attachment-shaped file reference when the fan-out item is not itself an attachment. | +| `lookups` | No | Named code translations evaluated before `params_mapping`. | +| `constants` | No | Static values merged underneath the mapped parameters. | +| `required_params` | No | Parameters that must not be absent, `null`, or empty before any HTTP request is sent. | +| `auth` | No | OAuth2 client credentials or password authentication. | +| `secure_proxy` | No | Routes the steps through a `secure_proxy` use case in the same integration. | + +Download-only fields such as `params`, `response`, `allowed_origins`, and `prevent_indirect_serving` are rejected for upload recipes. + +## 2. Subscribe to the event + +Create an outbound use case whose delivery points to the upload recipe's slug: + +```bash +curl -X POST 'https://integration-toolkit.sls.epilot.io/v1/integrations/{integrationId}/use-cases' \ + -H 'Authorization: Bearer ' \ + -H 'Content-Type: application/json' \ + -d '{ + "name": "Deliver customer request documents", + "slug": "deliver-customer-request-documents", + "type": "outbound", + "enabled": true, + "configuration": { + "event_catalog_event": "CustomerRequestSubmitted", + "event_filter": "$count(event_attachments) > 0", + "ack_tracking": "off", + "mappings": [ + { + "name": "External document archive", + "enabled": true, + "delivery": { + "type": "file_proxy", + "use_case_slug": "customer-request-document-upload" + } + } + ] + } + }' +``` + +Do not add `jsonata_expression` to a `file_proxy` outbound mapping. The referenced recipe owns payload mapping, and the API rejects an expression that would otherwise be ignored. Mapping IDs and timestamps are generated when omitted. + +Use `ack_tracking: "off"` for a file-only outbound use case: durable delivery state is tracked per file. If a webhook mapping in the same outbound use case requires acknowledgements, keep acknowledgement tracking enabled or separate the webhook and file deliveries. + +Check target resolution with: + +```http +GET /v1/integrations/{integrationId}/outbound-status +``` + +## Fan-out and file selection + +`fan_out.split_expression` runs once when the event is enqueued and must return an array. Every item becomes an independent delivery with its own status, retries, and idempotency record. + +For `CustomerRequestSubmitted`, use `event_attachments`. Each item already contains `entity_id` and `version_index`, so the worker recognizes it as a file reference without `file_source`. + +```json +"fan_out": { + "enabled": true, + "split_expression": "event_attachments[$count(_tags[$ = 'customer-document']) > 0]" +} +``` + +Use `event_filter` to decide whether the event should be handled at all. Use `fan_out.split_expression` only to select the items delivered from a handled event. + +- An empty, missing, or `null` result produces no deliveries and records `FAN_OUT_EMPTY`. +- A non-array result fails with `FAN_OUT_INVALID_RESULT`. +- With fan-out disabled, the event produces one delivery and `$item` is not bound. + +If you split over something other than attachments, set `file_source` to an expression that returns the corresponding attachment: + +```json +"file_source": "event_attachments[entity_id = $item.file_id][0]" +``` + +If no file resolves, the workflow can still send metadata. Include the mapped file value in `required_params` when bytes are mandatory. + +## Mapping values + +The JSONata root is the complete event. Event fields such as `contact.customer_number` and `ticket._purpose` are read directly. Per-delivery values are `$`-prefixed bindings: + +| JSONata value | Contains | +| --- | --- | +| `$item` | Current fan-out item | +| `$file_base64` | File content encoded as base64 | +| `$file` | `filename`, `mime_type`, and `size_bytes` from the resolved file | +| `$constants` | Configured static values | +| `$lookups` | Resolved lookup results | +| `$ack_id` | Triggering event acknowledgement ID, when present | +| `$now()` | Current ISO timestamp | +| `$germanDate(iso)` | An ISO timestamp formatted as a German date | + +:::warning JSONata bindings +Write `$item.filename` and `$file_base64`, including the `$`. `item.filename` reads a root event property named `item` and silently produces no value. +::: + +Lookups support `on_miss: "default"`, `"warn"`, or `"fail"`. Warning mode continues with the configured default and records `LOOKUP_UNMAPPED`; fail mode stops that delivery. + +## Building HTTP steps safely + +HTTP steps use Handlebars after JSONata has produced `params`. The template context provides `params.*`, `item`, `event.id`, `event.name`, `custom_key`, earlier `steps.N.*`, and `auth_token`. + +Always use the `json` helper for values inserted into JSON bodies: + +```handlebars +{ + "filename": {{json params.filename}}, + "fileData": {{json params.fileData}} + {{#if params.customerNumber}}, + "customerNumber": {{json params.customerNumber}} + {{/if}} +} +``` + +Plain interpolation does not safely serialize quotes or control characters. `#if` is supported in JSON bodies; unsafe `#each` and `#with` blocks are rejected during validation. + +Environment values are resolved after Handlebars. In the JSON sent to the API, write `"\\{{env.NAME}}"`. The leading backslash preserves the placeholder through Handlebars and is removed before environment resolution. + +## Delivery behavior + +- Delivery is **at least once**. Normal queue redelivery is deduplicated by a durable per-file record retained for 30 days. +- A failure after the target accepts a file but before epilot commits success can still repeat the request. Pass `{{custom_key}}` to the target and make it an idempotency key. +- The upload recipe is read again for queued retries, subject to a short cache. Correcting configuration affects later attempts without replaying the source event. +- `upload.max_delivery_attempts` defaults to 8 and supports 1–100 attempts with jittered backoff. +- `upload.max_file_bytes` defaults to the platform maximum of 100 MiB. Known size is checked before fetch; the limit is always enforced while buffering. +- `upload.success_when` can reject a nominally successful response, such as an HTTP 200 response containing an error envelope. +- `upload.external_id` records the target document ID without failing an otherwise successful delivery if extraction is unsuccessful. + +## Monitoring + +Every per-file monitoring event uses the source Event Catalog `_event_id` as both `event_id` and `correlation_id`. Details include the use case, mapping, attachment, item index, attempt, and external ID where available. + +| Level | Code | Meaning | +| --- | --- | --- | +| Success | `FILE_PROXY_UPLOADED` | The target accepted the file workflow. | +| Info | `FILE_PROXY_UPLOAD_ENQUEUED` | A per-file delivery was queued. | +| Info | `FAN_OUT_EMPTY` | The event had no matching items. | +| Warning | `FILE_PROXY_UPLOAD_RETRYING` | The delivery failed and will be attempted again. | +| Warning / error | `LOOKUP_UNMAPPED` | A lookup missed; `warn` continues with a fallback, while `fail` stops the delivery. | +| Error | `FILE_PROXY_UPLOAD_FAILED` | Delivery failed terminally or exhausted attempts. | +| Error | `FILE_FETCH_FAILED` | File API did not return usable content. | +| Error | `FILE_TOO_LARGE` | The file exceeded the recipe or platform limit. | +| Error | `ATTACHMENT_NOT_FOUND` | The referenced file entity or version was not found. | +| Error | `REQUIRED_PARAM_MISSING` | A required mapped parameter was absent. | +| Error | `FAN_OUT_INVALID_RESULT` | The split expression returned a non-array value. | + +General configuration and mapping codes, including `USE_CASE_NOT_FOUND`, `USE_CASE_DISABLED`, and `MAPPING_EXPRESSION_FAILED`, can also apply. These are epilot-produced monitoring codes; they are separate from the `EXTERNAL_*` codes described in [External Monitoring Events](./external-monitoring-events.md). + +## Troubleshooting + +| Symptom | Check | +| --- | --- | +| Target is unresolved | The delivery's `use_case_slug` must match an enabled upload-direction `file_proxy` use case in the same integration. | +| Mapped filename or bytes are empty | Per-item bindings require `$item` and `$file_base64`, including the `$`. | +| Target URL or credentials are empty | Persist environment placeholders with a leading backslash: `\\{{env.NAME}}` in JSON. | +| JSON body breaks for some filenames | Replace plain interpolation with `{{json params.filename}}`. | +| Event succeeds without uploading | Inspect `FAN_OUT_EMPTY`, the split expression, and `event_filter`. | +| File fetch fails | Verify `entity_id`, `version_index`, and the configured file-size limit. | + +## Related documentation + +- [Core Events](/docs/integrations/core-events) +- [Configuration](./configuration.md) +- [File Proxy downloads](./file-proxy.md) +- [External Monitoring Events](./external-monitoring-events.md) +- [Pollable Outbound](./pollable-outbound.md) diff --git a/docs/integrations/integration-toolkit/overview.md b/docs/integrations/integration-toolkit/overview.md index 3c02db60..39ea430a 100644 --- a/docs/integrations/integration-toolkit/overview.md +++ b/docs/integrations/integration-toolkit/overview.md @@ -35,6 +35,7 @@ The Integration Toolkit is composed of the following components. Each plays a sp | **[Pollable Outbound](./pollable-outbound.md)** | Pull-based outbound delivery — ERPs poll a queue instead of receiving webhooks | In progress | | **[JSONata Mapping](#jsonata-mapping)** | Transformation language for inbound and outbound data | Stable | | **[File Proxy](./file-proxy.md)** | Serve files from external archives on demand without migrating them into epilot | Stable | +| **[Outbound File Delivery](./outbound-file-delivery.md)** | Deliver files referenced by epilot events to external document APIs | Stable | | **[Managed Calls](#managed-calls)** | Synchronous external API calls with JSONata mapping via connector integrations | Stable | | **[Secure Proxy](#secure-proxy)** | Route HTTP requests through epilot's secure proxy for static IP egress or VPN access | Stable | | **[Monitoring and ACKs](#monitoring-and-acks)** | Central logging, error tracking, and event replay | In progress | @@ -87,6 +88,8 @@ See the [Use Cases](./use-cases.md) page for a complete list of inbound and outb The [File Proxy](./file-proxy.md) enables epilot to serve files from external document systems (e.g., ERP archives, DMS) on demand. Instead of migrating file content during inbound sync, file entities are created with a `custom_download_url` pointing to the proxy. When a user views the file, the proxy fetches the document from the external system in real time using a declarative, multi-step HTTP configuration. +The upload direction is documented separately as [Outbound File Delivery](./outbound-file-delivery.md). It subscribes to Event Catalog events, fans one event out into per-file deliveries, fetches the referenced epilot files, and runs a declarative external HTTP workflow with independent retries and monitoring. + ### Managed Calls Managed Calls enable synchronous API calls to external partner systems with built-in authentication, JSONata mapping, and optional inbound routing. They are configured as `managed_call` use cases within `connector`-type integrations. @@ -152,7 +155,7 @@ Push epilot events to your ERP via webhooks. Typical flows: - Self-service requests (IBAN changes, installment adjustments) - Meter reading submissions -Outbound events use [Core Events](/docs/integrations/core-events) and are delivered through [Webhooks](/docs/integrations/webhooks). JSONata transforms simplify payloads before delivery. +Outbound events use [Core Events](/docs/integrations/core-events). They can be delivered through [Webhooks](/docs/integrations/webhooks), made available through [Pollable Outbound](./pollable-outbound.md), or used to [deliver referenced files](./outbound-file-delivery.md) to an external API. JSONata transforms simplify event and file payloads before delivery. For ERPs that cannot receive webhooks (firewalled, on-prem, or batch-oriented systems), [Pollable Outbound](./pollable-outbound.md) offers a pull-based alternative: your middleware polls a queue inside the ERP Integration API on its own schedule and acknowledges items once consumed. diff --git a/static/events/CustomerRequestSubmitted.config.json b/static/events/CustomerRequestSubmitted.config.json new file mode 100644 index 00000000..2608f816 --- /dev/null +++ b/static/events/CustomerRequestSubmitted.config.json @@ -0,0 +1,209 @@ +{ + "event_name": "CustomerRequestSubmitted", + "event_title": "Customer Request Submitted", + "event_description": "Triggered when a customer request ticket is created, including all files attached to the ticket", + "event_version": "1.0", + "event_tags": [ + "builtin", + "erp", + "automation", + "ticket", + "attachment", + "customer-request" + ], + "schema_fields": { + "operation": { + "json_schema": { + "type": "string", + "enum": [ + "createEntity" + ], + "description": "The entity operation type that triggered this event" + }, + "required": true + }, + "activity_id": { + "json_schema": { + "type": "string", + "description": "Activity ID associated with this operation (ULID)", + "example": "01F130Q52Q6MWSNS8N2AVXV4JN" + }, + "required": false + }, + "activity_type": { + "json_schema": { + "type": "string", + "description": "Activity type (e.g., EntityCreated, EntityUpdated)", + "example": "EntityUpdated" + }, + "required": false + }, + "trigger_entity": { + "json_schema": { + "type": "string", + "format": "uuid", + "description": "Entity ID of the entity that triggered this event" + }, + "required": true + }, + "event_attachments": { + "json_schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "entity_id": { + "type": "string", + "format": "uuid", + "description": "Entity ID of the file" + }, + "filename": { + "type": "string", + "description": "Name of the file" + }, + "mime_type": { + "type": "string", + "description": "MIME type of the file (e.g., application/pdf)" + }, + "size_bytes": { + "type": "integer", + "minimum": 0, + "description": "File size in bytes" + }, + "s3ref": { + "type": "object", + "properties": { + "bucket": { + "type": "string", + "description": "S3 bucket name" + }, + "key": { + "type": "string", + "description": "S3 object key" + } + }, + "required": [ + "bucket", + "key" + ], + "additionalProperties": false, + "description": "S3 reference for the file content" + }, + "version_index": { + "type": "integer", + "minimum": 0, + "description": "Latest version index of the file (versions[].length - 1, or 0 if no versions array)" + }, + "readable_size": { + "type": "string", + "description": "Human-readable file size (e.g., \"200 KB\")" + }, + "_tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Tags on the file entity (semantic labels set by uploader/automation)" + }, + "relation_tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Tags on the ticket→file relation (e.g. journey step label like \"_hidden_ Neuer Schritt - Nachweis\" plus the upload block's configured tags)" + }, + "category": { + "type": "string", + "description": "File category select value (e.g., \"address-suggestions\", \"unknown\")" + }, + "file_date": { + "type": "string", + "description": "User-provided file date" + }, + "_created_at": { + "type": "string", + "format": "date-time", + "description": "When the file entity was created — useful for time-window filtering" + } + }, + "required": [ + "entity_id", + "version_index" + ], + "additionalProperties": false + }, + "description": "All files related to the ticket. A single submission can carry many documents across several upload blocks. Consumers identify a given upload via `relation_tags` / `_tags` and scope the use case via the `_purpose` taxonomy IDs on the ticket node (IDs only — resolving them to names is up to the consumer)." + }, + "required": false, + "graph_source": "[ticket_files.{\"entity_id\": _id, \"filename\": filename, \"mime_type\": mime_type, \"size_bytes\": size_bytes, \"s3ref\": s3ref, \"version_index\": ($count(versions) > 0 ? $count(versions) - 1 : 0), \"readable_size\": readable_size, \"_tags\": _tags, \"relation_tags\": relation_tags, \"category\": category, \"file_date\": file_date, \"_created_at\": _created_at}]" + } + }, + "entity_graph": { + "nodes": [ + { + "id": "ticket", + "schema": "ticket", + "cardinality": "one" + }, + { + "id": "submission", + "schema": "submission", + "cardinality": "one", + "fields": [ + "_id" + ] + }, + { + "id": "contact", + "schema": "contact", + "cardinality": "one" + }, + { + "id": "contract", + "schema": "contract", + "cardinality": "one" + }, + { + "id": "billing_account", + "schema": "billing_account", + "cardinality": "one" + }, + { + "id": "ticket_files", + "schema": "file", + "cardinality": "many" + } + ], + "edges": [ + { + "from": "ticket", + "to": "submission" + }, + { + "from": "ticket", + "to": "contact" + }, + { + "from": "ticket", + "to": "contract" + }, + { + "from": "ticket", + "to": "billing_account" + }, + { + "from": "ticket", + "to": "ticket_files" + } + ] + }, + "entity_operation": { + "operation": [ + "createEntity" + ], + "schema": [ + "ticket" + ] + }, + "automation_trigger": true +} \ No newline at end of file diff --git a/static/events/CustomerRequestSubmitted.sample.json b/static/events/CustomerRequestSubmitted.sample.json new file mode 100644 index 00000000..887d0d0d --- /dev/null +++ b/static/events/CustomerRequestSubmitted.sample.json @@ -0,0 +1,300 @@ +{ + "_org_id": "123", + "_event_time": "1970-01-01T00:00:00.000Z", + "_event_id": "string", + "_event_name": "CustomerRequestSubmitted", + "_event_version": "1.0", + "_event_source": "string", + "_ack_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "_trigger_source_type": "api", + "_trigger_source": "api_123456", + "_has_pending_changesets": true, + "_changeset_edit_modes": [ + "string" + ], + "_changeset_attributes": [ + "string" + ], + "_changeset_edit_modes_by_attribute": {}, + "operation": "createEntity", + "activity_id": "01F130Q52Q6MWSNS8N2AVXV4JN", + "activity_type": "EntityUpdated", + "trigger_entity": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "event_attachments": [ + { + "entity_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "filename": "string", + "mime_type": "string", + "size_bytes": 0, + "s3ref": { + "bucket": "string", + "key": "string" + }, + "version_index": 0, + "readable_size": "string", + "_tags": [ + "string" + ], + "relation_tags": [ + "string" + ], + "category": "string", + "file_date": "string", + "_created_at": "1970-01-01T00:00:00.000Z" + } + ], + "ticket": { + "_schema": "ticket", + "_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "_org": "123", + "_title": "John Doe", + "_tags": [ + "family:label" + ], + "_purpose": {}, + "ticket_title": "string", + "customer": {}, + "ticket_number": "string", + "status": "Open", + "payment": [ + { + "type": "payment_sepa", + "data": { + "bank_name": "Deutsche Bank", + "iban": "DE89370400440532013000", + "bic_number": "DEUTDEFF", + "fullname": "John Doe" + } + } + ], + "source": {}, + "workflows": {}, + "_files": {}, + "_created_at": "1970-01-01T00:00:00.000Z", + "_updated_at": "1970-01-01T00:00:00.000Z" + }, + "submission": { + "_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "_schema": "submission", + "_org": "123" + }, + "contact": { + "_schema": "contact", + "_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "_org": "123", + "_title": "John Doe", + "_tags": [ + "family:label" + ], + "_purpose": {}, + "salutation": "null", + "title": "null", + "first_name": "string", + "last_name": "string", + "customer_number": "string", + "birthdate": "2025-01-01", + "email": [ + { + "_tags": [ + "string" + ], + "email": "user@example.com" + } + ], + "phone": [ + { + "_tags": [ + "string" + ], + "phone": "+491234567890" + } + ], + "communication_preference": "postal", + "address": [ + { + "_tags": [ + "billing" + ], + "street": "Hauptstraße", + "street_number": "123", + "postal_code": "50668", + "city": "Cologne", + "country": "DE", + "additional_info": "string", + "company_name": "string", + "first_name": "string", + "last_name": "string", + "salutation": "string", + "title": "string", + "coordinates": "50.948484, 6.943147", + "plot_of_land": "string", + "plot_area": "string", + "start_date": "1970-01-01", + "end_date": "1970-01-01" + } + ], + "payment": [ + { + "type": "payment_sepa", + "data": { + "bank_name": "Deutsche Bank", + "iban": "DE89370400440532013000", + "bic_number": "DEUTDEFF", + "fullname": "John Doe" + } + } + ], + "account": {}, + "marketing_permission": true, + "contact_owner": {}, + "consent_email_marketing": {}, + "consent_sms_marketing": {}, + "consent_phone_call": {}, + "consent_print_marketing": {}, + "portal_users": {}, + "opportunities": {}, + "orders": {}, + "contracts": {}, + "external_id": "string", + "workflows": {}, + "_files": {}, + "_created_at": "1970-01-01T00:00:00.000Z", + "_updated_at": "1970-01-01T00:00:00.000Z" + }, + "contract": { + "_schema": "contract", + "_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "_org": "123", + "_title": "John Doe", + "_tags": [ + "family:label" + ], + "_purpose": {}, + "contract_name": "string", + "contract_number": "string", + "assignee": {}, + "payment": {}, + "status": "draft", + "description": "string", + "billing_account": {}, + "account_number": "string", + "branch": "power", + "move_in_date": "2025-01-01", + "move_out_date": "2025-01-01", + "billing_address": {}, + "delivery_address": [ + { + "_tags": [ + "billing" + ], + "street": "Hauptstraße", + "street_number": "123", + "postal_code": "50668", + "city": "Cologne", + "country": "DE", + "additional_info": "string", + "company_name": "string", + "first_name": "string", + "last_name": "string", + "salutation": "string", + "title": "string", + "coordinates": "50.948484, 6.943147", + "plot_of_land": "string", + "plot_area": "string", + "start_date": "1970-01-01", + "end_date": "1970-01-01" + } + ], + "additional_addresses": {}, + "termination_date": "2025-01-01", + "termination_reason": "string", + "start_date": "2025-01-01", + "end_date": "2025-01-01", + "customer": {}, + "order": {}, + "type": "one_time", + "billing_period": "weekly", + "billing_duration_amount": "string", + "billing_duration_unit": "weeks", + "notice_time_amount": "string", + "notice_time_unit": "weeks", + "termination_time_amount": "string", + "termination_time_unit": "weeks", + "renewal_duration_amount": "string", + "renewal_duration_unit": "weeks", + "billing_due_day": "string", + "installment_amount_currency": "EUR", + "installment_amount_decimal": "123.45", + "installment_amount": 0, + "balance_currency": "EUR", + "balance_decimal": "123.45", + "balance": 0, + "meters": {}, + "last_sync_at": "2025-01-01", + "external_id": "string", + "workflows": {}, + "line_items": {}, + "total_details": {}, + "_files": {}, + "_created_at": "1970-01-01T00:00:00.000Z", + "_updated_at": "1970-01-01T00:00:00.000Z" + }, + "billing_account": { + "_schema": "billing_account", + "_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "_org": "123", + "_title": "John Doe", + "_tags": [ + "family:label" + ], + "billing_account_number": "string", + "balance_currency": "EUR", + "balance_decimal": "123.45", + "balance": 0, + "billing_contact": {}, + "billing_address": {}, + "payment_method": [ + { + "type": "payment_sepa", + "data": { + "bank_name": "Deutsche Bank", + "iban": "DE89370400440532013000", + "bic_number": "DEUTDEFF", + "fullname": "John Doe" + } + } + ], + "contracts": {}, + "external_id": "string", + "workflows": {}, + "_files": {}, + "_created_at": "1970-01-01T00:00:00.000Z", + "_updated_at": "1970-01-01T00:00:00.000Z" + }, + "ticket_files": [ + { + "_schema": "file", + "_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "_org": "123", + "_title": "John Doe", + "_tags": [ + "family:label" + ], + "_purpose": {}, + "_thumbnail": {}, + "filename": "string", + "mime_type": "string", + "type": "document", + "readable_size": "string", + "language": "de", + "alt_text": "string", + "access_control": "private", + "public_url": "string", + "shared_with_end_customer": true, + "category": "address-suggestions", + "_files": {}, + "_created_at": "1970-01-01T00:00:00.000Z", + "_updated_at": "1970-01-01T00:00:00.000Z" + } + ] +} \ No newline at end of file diff --git a/static/events/CustomerRequestSubmitted.schema.json b/static/events/CustomerRequestSubmitted.schema.json new file mode 100644 index 00000000..f7ced691 --- /dev/null +++ b/static/events/CustomerRequestSubmitted.schema.json @@ -0,0 +1,2398 @@ +{ + "type": "object", + "properties": { + "_org_id": { + "type": "string", + "description": "epilot tenant/organization ID", + "example": "123" + }, + "_event_time": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when event occurred" + }, + "_event_id": { + "type": "string", + "description": "Unique event identifier (ULID)" + }, + "_event_name": { + "type": "string", + "description": "Event name from catalog", + "example": "CustomerRequestSubmitted" + }, + "_event_version": { + "type": "string", + "description": "Schema version number", + "example": "1.0" + }, + "_event_source": { + "type": "string", + "description": "Source that triggered the event" + }, + "_ack_id": { + "type": "string", + "format": "uuid", + "description": "Acknowledgement ID for tracking event delivery (UUID)" + }, + "_trigger_source_type": { + "type": "string", + "description": "The type of system that triggered the event. Examples: api, automation, operation, portal_user", + "example": "api" + }, + "_trigger_source": { + "type": "string", + "description": "Identifier of the specific trigger source. Examples: user ID, automation execution ID, activity ID, portal user email", + "example": "api_123456" + }, + "_has_pending_changesets": { + "type": "boolean", + "description": "True when the triggering entity (or meter counter) has at least one pending changeset" + }, + "_changeset_edit_modes": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Distinct edit modes across the PENDING changesets — only \"external\" (synced from an external system) or \"approval\" (awaiting approval). A \"direct\" write is applied immediately, is not a pending changeset, and so never appears here." + }, + "_changeset_attributes": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Keys identifying pending changesets. For attribute changesets these are attribute names (e.g. `billing_cycle`); for meter-reading changesets these are the changeset IDs from `meter._meter_readings_changeset`" + }, + "_changeset_edit_modes_by_attribute": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "not": {} + }, + { + "type": "string" + } + ] + }, + "description": "Per-key edit mode map; keys are a subset of _changeset_attributes (attribute names for entity changesets, changeset IDs for meter-reading changesets) — only those whose pending changeset has an \"external\" or \"approval\" mode. A \"direct\"/unrecognised mode is omitted here, though the key still appears in _changeset_attributes. Enables fine-grained webhook conditions such as `_changeset_edit_modes_by_attribute.billing_cycle equals approval`" + }, + "operation": { + "type": "string", + "enum": [ + "createEntity" + ], + "description": "The entity operation type that triggered this event" + }, + "activity_id": { + "type": "string", + "description": "Activity ID associated with this operation (ULID)", + "example": "01F130Q52Q6MWSNS8N2AVXV4JN" + }, + "activity_type": { + "type": "string", + "description": "Activity type (e.g., EntityCreated, EntityUpdated)", + "example": "EntityUpdated" + }, + "trigger_entity": { + "type": "string", + "format": "uuid", + "description": "Entity ID of the entity that triggered this event" + }, + "event_attachments": { + "type": "array", + "items": { + "type": "object", + "properties": { + "entity_id": { + "type": "string", + "format": "uuid", + "description": "Entity ID of the file" + }, + "filename": { + "type": "string", + "description": "Name of the file" + }, + "mime_type": { + "type": "string", + "description": "MIME type of the file (e.g., application/pdf)" + }, + "size_bytes": { + "type": "integer", + "minimum": 0, + "description": "File size in bytes" + }, + "s3ref": { + "type": "object", + "properties": { + "bucket": { + "type": "string", + "description": "S3 bucket name" + }, + "key": { + "type": "string", + "description": "S3 object key" + } + }, + "required": [ + "bucket", + "key" + ], + "additionalProperties": false, + "description": "S3 reference for the file content" + }, + "version_index": { + "type": "integer", + "minimum": 0, + "description": "Latest version index of the file (versions[].length - 1, or 0 if no versions array)" + }, + "readable_size": { + "type": "string", + "description": "Human-readable file size (e.g., \"200 KB\")" + }, + "_tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Tags on the file entity (semantic labels set by uploader/automation)" + }, + "relation_tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Tags on the ticket→file relation (e.g. journey step label like \"_hidden_ Neuer Schritt - Nachweis\" plus the upload block's configured tags)" + }, + "category": { + "type": "string", + "description": "File category select value (e.g., \"address-suggestions\", \"unknown\")" + }, + "file_date": { + "type": "string", + "description": "User-provided file date" + }, + "_created_at": { + "type": "string", + "format": "date-time", + "description": "When the file entity was created — useful for time-window filtering" + } + }, + "required": [ + "entity_id", + "version_index" + ], + "additionalProperties": false + }, + "description": "All files related to the ticket. A single submission can carry many documents across several upload blocks. Consumers identify a given upload via `relation_tags` / `_tags` and scope the use case via the `_purpose` taxonomy IDs on the ticket node (IDs only — resolving them to names is up to the consumer)." + }, + "ticket": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "required": [ + "_schema" + ], + "properties": { + "_schema": { + "readOnly": true, + "type": "string", + "pattern": "^[a-zA-Z0-9_-]+$", + "example": "ticket", + "description": "URL-friendly identifier for the entity schema" + }, + "_id": { + "readOnly": true, + "type": "string", + "format": "uuid", + "example": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "description": "Unique identifier for the entity" + }, + "_org": { + "type": "string", + "description": "Organization Id the entity belongs to", + "readOnly": true, + "example": "123" + }, + "_title": { + "readOnly": true, + "type": "string", + "nullable": true, + "example": "John Doe", + "description": "Generated title for the entity based on schema title_template" + }, + "_tags": { + "type": "array", + "nullable": true, + "items": { + "type": "string" + }, + "example": [ + "family:label" + ], + "description": "Entity Labels" + }, + "_purpose": { + "$ref": "#/definitions/PurposeAttribute", + "description": "Purposes" + }, + "ticket_title": { + "type": "string", + "description": "Ticket Title" + }, + "customer": { + "$ref": "#/definitions/BaseRelation", + "description": "Customer" + }, + "ticket_number": { + "type": "string", + "description": "Ticket Number" + }, + "status": { + "type": "string", + "enum": [ + "Open", + "In Progress", + "Escalated", + "Closed", + "Canceled" + ], + "description": "Status" + }, + "payment": { + "type": "array", + "items": { + "type": "object", + "description": "Payment method details", + "properties": { + "type": { + "type": "string", + "enum": [ + "payment_sepa", + "payment_invoice", + "payment_cash" + ], + "example": "payment_sepa" + }, + "data": { + "type": "object", + "properties": { + "bank_name": { + "type": "string", + "example": "Deutsche Bank" + }, + "iban": { + "type": "string", + "example": "DE89370400440532013000" + }, + "bic_number": { + "type": "string", + "example": "DEUTDEFF" + }, + "fullname": { + "type": "string", + "example": "John Doe" + } + } + } + } + }, + "description": "Payment" + }, + "source": { + "$ref": "#/definitions/LinkAttribute", + "description": "Source" + }, + "workflows": { + "$ref": "#/definitions/WorkflowOverview" + }, + "_files": { + "$ref": "#/definitions/BaseRelation" + }, + "_created_at": { + "readOnly": true, + "type": "string", + "format": "date-time", + "nullable": true, + "description": "Timestamp of entity creation" + }, + "_updated_at": { + "readOnly": true, + "type": "string", + "format": "date-time", + "nullable": true, + "description": "Timestamp of entity last update" + } + }, + "description": "ticket entity (hydrated from entity graph)" + }, + "submission": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "required": [ + "_schema" + ], + "properties": { + "_id": { + "readOnly": true, + "type": "string", + "format": "uuid", + "example": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "description": "Unique identifier for the entity" + }, + "_schema": { + "readOnly": true, + "type": "string", + "pattern": "^[a-zA-Z0-9_-]+$", + "example": "submission", + "description": "URL-friendly identifier for the entity schema" + }, + "_org": { + "type": "string", + "description": "Organization Id the entity belongs to", + "readOnly": true, + "example": "123" + } + }, + "description": "submission entity (hydrated from entity graph)" + }, + "contact": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "required": [ + "_schema" + ], + "properties": { + "_schema": { + "readOnly": true, + "type": "string", + "pattern": "^[a-zA-Z0-9_-]+$", + "example": "contact", + "description": "URL-friendly identifier for the entity schema" + }, + "_id": { + "readOnly": true, + "type": "string", + "format": "uuid", + "example": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "description": "Unique identifier for the entity" + }, + "_org": { + "type": "string", + "description": "Organization Id the entity belongs to", + "readOnly": true, + "example": "123" + }, + "_title": { + "readOnly": true, + "type": "string", + "nullable": true, + "example": "John Doe", + "description": "Generated title for the entity based on schema title_template" + }, + "_tags": { + "type": "array", + "nullable": true, + "items": { + "type": "string" + }, + "example": [ + "family:label" + ], + "description": "Entity Labels" + }, + "_purpose": { + "$ref": "#/definitions/PurposeAttribute", + "description": "Purposes" + }, + "salutation": { + "type": "string", + "enum": [ + "null", + "Mr.", + "Ms. / Mrs.", + "Company", + "Contact Person", + "Company/Contact Person", + "Spouse", + "Family", + "Ownership", + "Assembly", + "Other" + ], + "description": "Salutation" + }, + "title": { + "type": "string", + "enum": [ + "null", + "Dr.", + "Prof.", + "Prof. Dr." + ], + "description": "Title" + }, + "first_name": { + "type": "string", + "description": "First Name" + }, + "last_name": { + "type": "string", + "description": "Last Name" + }, + "customer_number": { + "type": "string", + "description": "Customer Number" + }, + "birthdate": { + "type": "string", + "format": "date", + "example": "2025-01-01", + "description": "Date of Birth" + }, + "email": { + "type": "array", + "items": { + "type": "object", + "properties": { + "_tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "email": { + "type": "string", + "format": "email" + } + }, + "required": [ + "email" + ] + }, + "description": "Email" + }, + "phone": { + "type": "array", + "items": { + "type": "object", + "properties": { + "_tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "phone": { + "type": "string", + "example": "+491234567890" + } + }, + "required": [ + "phone" + ] + }, + "description": "Phone" + }, + "communication_preference": { + "type": "string", + "enum": [ + "postal", + "portal" + ], + "description": "Standard communication method" + }, + "address": { + "type": "array", + "items": { + "type": "object", + "description": "Address attribute", + "additionalProperties": true, + "properties": { + "_tags": { + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "billing" + ] + }, + "street": { + "type": "string", + "description": "The first line of the address. Typically the street address or PO Box number.", + "nullable": true, + "example": "Hauptstraße" + }, + "street_number": { + "type": "string", + "description": "The second line of the address. Typically the number of the apartment, suite, or unit.", + "nullable": true, + "example": "123" + }, + "postal_code": { + "type": "string", + "description": "The postal code for the address.", + "nullable": true, + "example": "50668" + }, + "city": { + "type": "string", + "description": "The name of the city, district, village, or town.", + "nullable": true, + "example": "Cologne" + }, + "country": { + "type": "string", + "description": "The two-letter code for the country of the address.", + "nullable": true, + "example": "DE" + }, + "additional_info": { + "type": "string", + "description": "An additional description for the address", + "nullable": true + }, + "company_name": { + "type": "string", + "description": "the company name, usually used as extra delivery instructions", + "nullable": true + }, + "first_name": { + "type": "string", + "description": "the first name of the recipient, usually used as extra delivery instructions", + "nullable": true + }, + "last_name": { + "type": "string", + "description": "the last name of the recipient, usually used as extra delivery instructions", + "nullable": true + }, + "salutation": { + "type": "string", + "description": "the salutation of the recipient, usually used as extra delivery instructions", + "nullable": true + }, + "title": { + "type": "string", + "description": "the title of the recipient, usually used as extra delivery instructions", + "nullable": true + }, + "coordinates": { + "type": "string", + "description": "Geographical coordinates (latitude, longitude)", + "nullable": true, + "example": "50.948484, 6.943147" + }, + "plot_of_land": { + "type": "string", + "description": "Plot of land information", + "nullable": true + }, + "plot_area": { + "type": "string", + "description": "Area of the plot", + "nullable": true + }, + "start_date": { + "type": "string", + "description": "Start date for the address", + "format": "date", + "nullable": true + }, + "end_date": { + "type": "string", + "description": "End date for the address", + "format": "date", + "nullable": true + } + } + }, + "description": "Address" + }, + "payment": { + "type": "array", + "items": { + "type": "object", + "description": "Payment method details", + "properties": { + "type": { + "type": "string", + "enum": [ + "payment_sepa", + "payment_invoice", + "payment_cash" + ], + "example": "payment_sepa" + }, + "data": { + "type": "object", + "properties": { + "bank_name": { + "type": "string", + "example": "Deutsche Bank" + }, + "iban": { + "type": "string", + "example": "DE89370400440532013000" + }, + "bic_number": { + "type": "string", + "example": "DEUTDEFF" + }, + "fullname": { + "type": "string", + "example": "John Doe" + } + } + } + } + }, + "description": "Payment" + }, + "account": { + "$ref": "#/definitions/BaseRelation", + "description": "Account" + }, + "marketing_permission": { + "type": "boolean", + "description": "Marketing Permission" + }, + "contact_owner": { + "$ref": "#/definitions/User", + "description": "Contact Owner" + }, + "consent_email_marketing": { + "$ref": "#/definitions/ConsentAttribute", + "description": "Email Marketing" + }, + "consent_sms_marketing": { + "$ref": "#/definitions/ConsentAttribute", + "description": "SMS Marketing" + }, + "consent_phone_call": { + "$ref": "#/definitions/ConsentAttribute", + "description": "Direct Phone Marketing" + }, + "consent_print_marketing": { + "$ref": "#/definitions/ConsentAttribute", + "description": "Print Marketing" + }, + "portal_users": { + "$ref": "#/definitions/BaseRelation", + "description": "Portal Users" + }, + "opportunities": { + "$ref": "#/definitions/BaseRelation", + "description": "Opportunities" + }, + "orders": { + "$ref": "#/definitions/BaseRelation", + "description": "Orders" + }, + "contracts": { + "$ref": "#/definitions/BaseRelation", + "description": "Contracts" + }, + "external_id": { + "type": "string", + "description": "External ID" + }, + "workflows": { + "$ref": "#/definitions/WorkflowOverview" + }, + "_files": { + "$ref": "#/definitions/BaseRelation" + }, + "_created_at": { + "readOnly": true, + "type": "string", + "format": "date-time", + "nullable": true, + "description": "Timestamp of entity creation" + }, + "_updated_at": { + "readOnly": true, + "type": "string", + "format": "date-time", + "nullable": true, + "description": "Timestamp of entity last update" + } + }, + "description": "contact entity (hydrated from entity graph)" + }, + "contract": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "required": [ + "_schema", + "type", + "billing_period", + "installment_amount", + "balance" + ], + "properties": { + "_schema": { + "readOnly": true, + "type": "string", + "pattern": "^[a-zA-Z0-9_-]+$", + "example": "contract", + "description": "URL-friendly identifier for the entity schema" + }, + "_id": { + "readOnly": true, + "type": "string", + "format": "uuid", + "example": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "description": "Unique identifier for the entity" + }, + "_org": { + "type": "string", + "description": "Organization Id the entity belongs to", + "readOnly": true, + "example": "123" + }, + "_title": { + "readOnly": true, + "type": "string", + "nullable": true, + "example": "John Doe", + "description": "Generated title for the entity based on schema title_template" + }, + "_tags": { + "type": "array", + "nullable": true, + "items": { + "type": "string" + }, + "example": [ + "family:label" + ], + "description": "Entity Labels" + }, + "_purpose": { + "$ref": "#/definitions/PurposeAttribute", + "description": "Purposes" + }, + "contract_name": { + "type": "string", + "description": "Contract Name" + }, + "contract_number": { + "type": "string", + "description": "Contract Number" + }, + "assignee": { + "$ref": "#/definitions/User", + "description": "Assignee" + }, + "payment": { + "$ref": "#/definitions/BaseRelationRef", + "description": "Payment Details" + }, + "status": { + "type": "string", + "enum": [ + "draft", + "in_approval_process", + "approved", + "active", + "deactivated", + "revoked", + "terminated", + "expired" + ], + "description": "Status" + }, + "description": { + "type": "string", + "description": "Description" + }, + "billing_account": { + "$ref": "#/definitions/BaseRelation", + "description": "Billing Account" + }, + "account_number": { + "type": "string", + "description": "Account Number" + }, + "branch": { + "type": "string", + "enum": [ + "power", + "gas", + "water", + "waste_water", + "district_heating" + ], + "description": "Branch" + }, + "move_in_date": { + "type": "string", + "format": "date", + "example": "2025-01-01", + "description": "Move In Date" + }, + "move_out_date": { + "type": "string", + "format": "date", + "example": "2025-01-01", + "description": "Move Out Date" + }, + "billing_address": { + "$ref": "#/definitions/BaseRelationRef", + "description": "Billing Address" + }, + "delivery_address": { + "type": "array", + "items": { + "type": "object", + "description": "Address attribute", + "additionalProperties": true, + "properties": { + "_tags": { + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "billing" + ] + }, + "street": { + "type": "string", + "description": "The first line of the address. Typically the street address or PO Box number.", + "nullable": true, + "example": "Hauptstraße" + }, + "street_number": { + "type": "string", + "description": "The second line of the address. Typically the number of the apartment, suite, or unit.", + "nullable": true, + "example": "123" + }, + "postal_code": { + "type": "string", + "description": "The postal code for the address.", + "nullable": true, + "example": "50668" + }, + "city": { + "type": "string", + "description": "The name of the city, district, village, or town.", + "nullable": true, + "example": "Cologne" + }, + "country": { + "type": "string", + "description": "The two-letter code for the country of the address.", + "nullable": true, + "example": "DE" + }, + "additional_info": { + "type": "string", + "description": "An additional description for the address", + "nullable": true + }, + "company_name": { + "type": "string", + "description": "the company name, usually used as extra delivery instructions", + "nullable": true + }, + "first_name": { + "type": "string", + "description": "the first name of the recipient, usually used as extra delivery instructions", + "nullable": true + }, + "last_name": { + "type": "string", + "description": "the last name of the recipient, usually used as extra delivery instructions", + "nullable": true + }, + "salutation": { + "type": "string", + "description": "the salutation of the recipient, usually used as extra delivery instructions", + "nullable": true + }, + "title": { + "type": "string", + "description": "the title of the recipient, usually used as extra delivery instructions", + "nullable": true + }, + "coordinates": { + "type": "string", + "description": "Geographical coordinates (latitude, longitude)", + "nullable": true, + "example": "50.948484, 6.943147" + }, + "plot_of_land": { + "type": "string", + "description": "Plot of land information", + "nullable": true + }, + "plot_area": { + "type": "string", + "description": "Area of the plot", + "nullable": true + }, + "start_date": { + "type": "string", + "description": "Start date for the address", + "format": "date", + "nullable": true + }, + "end_date": { + "type": "string", + "description": "End date for the address", + "format": "date", + "nullable": true + } + } + }, + "description": "Delivery Address" + }, + "additional_addresses": { + "$ref": "#/definitions/BaseRelationRef", + "description": "Additional Addresses" + }, + "termination_date": { + "type": "string", + "format": "date", + "example": "2025-01-01", + "description": "Date of Termination" + }, + "termination_reason": { + "type": "string", + "description": "Reason For Termination" + }, + "start_date": { + "type": "string", + "format": "date", + "example": "2025-01-01", + "description": "Start Date" + }, + "end_date": { + "type": "string", + "format": "date", + "example": "2025-01-01", + "description": "End Date" + }, + "customer": { + "$ref": "#/definitions/BaseRelation", + "description": "Customer" + }, + "order": { + "$ref": "#/definitions/BaseRelation", + "description": "Order" + }, + "type": { + "type": "string", + "enum": [ + "one_time", + "recurring" + ], + "description": "Type" + }, + "billing_period": { + "type": "string", + "enum": [ + "weekly", + "monthly", + "every_quarter", + "every_6_months", + "yearly" + ], + "description": "Billing Period" + }, + "billing_duration_amount": { + "type": "string", + "description": "Billing Duration" + }, + "billing_duration_unit": { + "type": "string", + "enum": [ + "weeks", + "months", + "years" + ], + "description": "Billing Duration Unit" + }, + "notice_time_amount": { + "type": "string", + "description": "Notice Time" + }, + "notice_time_unit": { + "type": "string", + "enum": [ + "weeks", + "months", + "years" + ], + "description": "Notice Time Unit" + }, + "termination_time_amount": { + "type": "string", + "description": "Termination Time" + }, + "termination_time_unit": { + "type": "string", + "enum": [ + "weeks", + "months", + "years" + ], + "description": "Termination Time Unit" + }, + "renewal_duration_amount": { + "type": "string", + "description": "Renewal Duration" + }, + "renewal_duration_unit": { + "type": "string", + "enum": [ + "weeks", + "months", + "years" + ], + "description": "Renewal Duration Unit" + }, + "billing_due_day": { + "type": "string", + "description": "Billing Due Day" + }, + "installment_amount_currency": { + "type": "string", + "description": "Three-letter ISO currency code, in lowercase. Must be a supported currency.", + "example": "EUR" + }, + "installment_amount_decimal": { + "type": "string", + "description": "The currency value as a decimal string", + "example": "123.45" + }, + "installment_amount": { + "type": "number", + "description": "Installment Currency" + }, + "balance_currency": { + "type": "string", + "description": "Three-letter ISO currency code, in lowercase. Must be a supported currency.", + "example": "EUR" + }, + "balance_decimal": { + "type": "string", + "description": "The currency value as a decimal string", + "example": "123.45" + }, + "balance": { + "type": "number", + "description": "Currency" + }, + "meters": { + "$ref": "#/definitions/BaseRelation", + "description": "Meters" + }, + "last_sync_at": { + "type": "string", + "format": "date", + "example": "2025-01-01", + "description": "Last Sync At" + }, + "external_id": { + "type": "string", + "description": "External ID" + }, + "workflows": { + "$ref": "#/definitions/WorkflowOverview" + }, + "line_items": { + "$ref": "#/definitions/LineItems" + }, + "total_details": { + "$ref": "#/definitions/TotalDetails" + }, + "_files": { + "$ref": "#/definitions/BaseRelation" + }, + "_created_at": { + "readOnly": true, + "type": "string", + "format": "date-time", + "nullable": true, + "description": "Timestamp of entity creation" + }, + "_updated_at": { + "readOnly": true, + "type": "string", + "format": "date-time", + "nullable": true, + "description": "Timestamp of entity last update" + } + }, + "description": "contract entity (hydrated from entity graph)" + }, + "billing_account": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "required": [ + "_schema" + ], + "properties": { + "_schema": { + "readOnly": true, + "type": "string", + "pattern": "^[a-zA-Z0-9_-]+$", + "example": "billing_account", + "description": "URL-friendly identifier for the entity schema" + }, + "_id": { + "readOnly": true, + "type": "string", + "format": "uuid", + "example": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "description": "Unique identifier for the entity" + }, + "_org": { + "type": "string", + "description": "Organization Id the entity belongs to", + "readOnly": true, + "example": "123" + }, + "_title": { + "readOnly": true, + "type": "string", + "nullable": true, + "example": "John Doe", + "description": "Generated title for the entity based on schema title_template" + }, + "_tags": { + "type": "array", + "nullable": true, + "items": { + "type": "string" + }, + "example": [ + "family:label" + ], + "description": "Entity Labels" + }, + "billing_account_number": { + "type": "string", + "description": "Billing Account Number" + }, + "balance_currency": { + "type": "string", + "description": "Three-letter ISO currency code, in lowercase. Must be a supported currency.", + "example": "EUR" + }, + "balance_decimal": { + "type": "string", + "description": "The currency value as a decimal string", + "example": "123.45" + }, + "balance": { + "type": "number", + "description": "Account Balance" + }, + "billing_contact": { + "$ref": "#/definitions/BaseRelation", + "description": "Billing Contact" + }, + "billing_address": { + "$ref": "#/definitions/AddressAttribute", + "description": "Billing Address" + }, + "payment_method": { + "type": "array", + "items": { + "type": "object", + "description": "Payment method details", + "properties": { + "type": { + "type": "string", + "enum": [ + "payment_sepa", + "payment_invoice", + "payment_cash" + ], + "example": "payment_sepa" + }, + "data": { + "type": "object", + "properties": { + "bank_name": { + "type": "string", + "example": "Deutsche Bank" + }, + "iban": { + "type": "string", + "example": "DE89370400440532013000" + }, + "bic_number": { + "type": "string", + "example": "DEUTDEFF" + }, + "fullname": { + "type": "string", + "example": "John Doe" + } + } + } + } + }, + "description": "Payment Method" + }, + "contracts": { + "$ref": "#/definitions/BaseRelation", + "description": "Contracts" + }, + "external_id": { + "type": "string", + "description": "External ID" + }, + "workflows": { + "$ref": "#/definitions/WorkflowOverview" + }, + "_files": { + "$ref": "#/definitions/BaseRelation" + }, + "_created_at": { + "readOnly": true, + "type": "string", + "format": "date-time", + "nullable": true, + "description": "Timestamp of entity creation" + }, + "_updated_at": { + "readOnly": true, + "type": "string", + "format": "date-time", + "nullable": true, + "description": "Timestamp of entity last update" + } + }, + "description": "billing_account entity (hydrated from entity graph)" + }, + "ticket_files": { + "type": "array", + "description": "Array of file entities (hydrated from entity graph)", + "items": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "required": [ + "_schema", + "filename", + "language" + ], + "properties": { + "_schema": { + "readOnly": true, + "type": "string", + "pattern": "^[a-zA-Z0-9_-]+$", + "example": "file", + "description": "URL-friendly identifier for the entity schema" + }, + "_id": { + "readOnly": true, + "type": "string", + "format": "uuid", + "example": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "description": "Unique identifier for the entity" + }, + "_org": { + "type": "string", + "description": "Organization Id the entity belongs to", + "readOnly": true, + "example": "123" + }, + "_title": { + "readOnly": true, + "type": "string", + "nullable": true, + "example": "John Doe", + "description": "Generated title for the entity based on schema title_template" + }, + "_tags": { + "type": "array", + "nullable": true, + "items": { + "type": "string" + }, + "example": [ + "family:label" + ], + "description": "Entity Labels" + }, + "_purpose": { + "$ref": "#/definitions/PurposeAttribute", + "description": "Purposes" + }, + "_thumbnail": { + "$ref": "#/definitions/BaseRelation", + "description": "Thumbnail" + }, + "filename": { + "type": "string", + "description": "File Name" + }, + "mime_type": { + "type": "string", + "description": "MIME Type" + }, + "type": { + "type": "string", + "enum": [ + "document", + "document_template", + "text", + "image", + "video", + "audio", + "spreadsheet", + "presentation", + "font", + "archive", + "application", + "unknown" + ], + "description": "File Type" + }, + "readable_size": { + "type": "string", + "description": "Size" + }, + "language": { + "type": "string", + "enum": [ + "de", + "en" + ], + "description": "Language" + }, + "alt_text": { + "type": "string", + "description": "Alt text" + }, + "access_control": { + "type": "string", + "enum": [ + "private", + "public-read" + ], + "description": "Access Control" + }, + "public_url": { + "type": "string", + "description": "Public URL" + }, + "shared_with_end_customer": { + "type": "boolean", + "description": "Shared with End Customer" + }, + "category": { + "type": "string", + "enum": [ + "address-suggestions", + "unknown" + ], + "description": "Category" + }, + "_files": { + "$ref": "#/definitions/BaseRelation" + }, + "_created_at": { + "readOnly": true, + "type": "string", + "format": "date-time", + "nullable": true, + "description": "Timestamp of entity creation" + }, + "_updated_at": { + "readOnly": true, + "type": "string", + "format": "date-time", + "nullable": true, + "description": "Timestamp of entity last update" + } + } + } + } + }, + "required": [ + "_org_id", + "_event_time", + "_event_id", + "_event_name", + "_event_version", + "_event_source", + "_ack_id", + "operation", + "trigger_entity" + ], + "additionalProperties": true, + "definitions": { + "PurposeAttribute": { + "type": "array", + "description": "List of purpose ids", + "items": { + "type": "string" + }, + "example": [ + "7675dc56-fd1b-46ee-9c35-defe0667b32e" + ] + }, + "LinkAttribute": { + "type": "object", + "description": "Link with title and href", + "properties": { + "type": { + "type": "string", + "enum": [ + "link" + ] + }, + "title": { + "type": "string", + "example": "Visit Website" + }, + "href": { + "type": "string", + "format": "uri", + "example": "https://example.com" + } + } + }, + "BaseUUID": { + "type": "string", + "format": "uuid", + "example": "123e4567-e89b-12d3-a456-426614174000" + }, + "BaseTags": { + "type": "array", + "nullable": true, + "items": { + "type": "string" + }, + "example": [ + "example" + ] + }, + "BaseRelation": { + "type": "object", + "nullable": true, + "description": "Relation to another entity", + "properties": { + "$relation": { + "type": "array", + "items": { + "type": "object", + "properties": { + "entity_id": { + "$ref": "#/definitions/BaseUUID" + }, + "_tags": { + "$ref": "#/definitions/BaseTags" + } + } + } + } + } + }, + "WorkflowStatus": { + "type": "string", + "enum": [ + "STARTED", + "DONE", + "CLOSED" + ] + }, + "StepType": { + "type": "string", + "enum": [ + "MANUAL", + "AUTOMATION" + ] + }, + "StepStatus": { + "type": "string", + "enum": [ + "UNASSIGNED", + "ASSIGNED", + "COMPLETED", + "SKIPPED", + "IN_PROGRESS" + ] + }, + "PhaseInEntity": { + "type": "object", + "description": "Workflow phase information", + "properties": { + "phase_id": { + "type": "string" + }, + "phase_name": { + "type": "string" + }, + "phase_progress": { + "type": "number" + } + }, + "required": [ + "phase_id", + "phase_name", + "phase_progress" + ] + }, + "WorkflowInEntity": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "definition_id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/WorkflowStatus" + }, + "assignees": { + "type": "array", + "items": { + "type": "string" + } + }, + "progress": { + "type": "number" + }, + "phases_in_progress": { + "type": "array", + "items": { + "$ref": "#/definitions/PhaseInEntity" + } + }, + "last_update_time": { + "type": "string", + "format": "date-time" + }, + "upcoming_tasks_assignees": { + "type": "array", + "items": { + "type": "string" + } + }, + "task_id": { + "type": "string" + }, + "task_name": { + "type": "string" + }, + "task_assignees": { + "type": "array", + "items": { + "type": "string" + } + }, + "task_duedate": { + "type": "string", + "format": "date-time" + }, + "task_execution_type": { + "$ref": "#/definitions/StepType" + }, + "task_status": { + "$ref": "#/definitions/StepStatus" + }, + "phase_id": { + "type": "string" + }, + "phase_name": { + "type": "string" + }, + "phase_assignees": { + "type": "array", + "items": { + "type": "string" + } + }, + "phase_progress": { + "type": "number" + } + }, + "required": [ + "id", + "definition_id", + "name", + "status", + "assignees", + "last_update_time" + ] + }, + "WorkflowOverview": { + "type": "object", + "properties": { + "primary": { + "$ref": "#/definitions/WorkflowInEntity" + } + }, + "additionalProperties": { + "type": "array", + "items": { + "$ref": "#/definitions/WorkflowInEntity" + } + } + }, + "ConsentAttribute": { + "type": "object", + "description": "Consent Management", + "properties": { + "type": { + "type": "string", + "enum": [ + "consent" + ] + }, + "topic": { + "type": "string" + }, + "identifiers": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "type", + "topic" + ] + }, + "UserId": { + "description": "User's unique identifier", + "type": "string", + "readOnly": true + }, + "OrganizationId": { + "description": "Organization unique identifier", + "type": "string", + "readOnly": true, + "example": "123" + }, + "User": { + "type": "object", + "description": "User information", + "properties": { + "id": { + "$ref": "#/definitions/UserId" + }, + "organization_id": { + "$ref": "#/definitions/OrganizationId" + }, + "email": { + "type": "string", + "format": "email", + "example": "user@example.com" + }, + "display_name": { + "type": "string", + "example": "John Doe", + "deprecated": true + }, + "name": { + "type": "string", + "example": "John Doe", + "deprecated": true + }, + "preferred_language": { + "type": "string", + "example": "de", + "description": "ISO 639-1 language code" + }, + "signature": { + "type": "string", + "example": "

Best regards,
John Doe

", + "description": "HTML email signature" + }, + "roles": { + "type": "array", + "items": { + "type": "string", + "example": "Owner" + }, + "deprecated": true, + "description": "Deprecated! Please use Permissions API instead" + }, + "image_uri": { + "type": "object", + "additionalProperties": true, + "properties": { + "original": { + "type": "string", + "format": "uri", + "example": "https://account-profile-images.epilot.cloud/1/avatar.png" + }, + "thumbnail_32": { + "type": "string", + "format": "uri", + "example": "https://account-profile-images.epilot.cloud/1/avatar_32x32.png" + } + }, + "nullable": true + }, + "properties": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "example": "profileImageName" + }, + "value": { + "type": "string", + "example": "avatar.png" + } + }, + "required": [ + "name", + "value" + ] + } + } + }, + "required": [ + "id", + "organization_id", + "email", + "name", + "preferred_language", + "roles", + "properties" + ] + }, + "BaseRelationRef": { + "type": "object", + "nullable": true, + "description": "Reference to a specific property of another entity", + "properties": { + "$relation_ref": { + "type": "array", + "items": { + "type": "object", + "properties": { + "entity_id": { + "$ref": "#/definitions/BaseUUID" + }, + "path": { + "type": "string", + "example": "address", + "description": "The property of the entity (pointed by entity_id) to be used as the reference" + }, + "_tags": { + "$ref": "#/definitions/BaseTags" + }, + "_id": { + "type": "string", + "example": "xHcOoJCa07eysJ1GaQeSb" + } + } + } + } + } + }, + "TaxBreakdownInfo": { + "type": "object", + "properties": { + "rate": { + "type": "number" + }, + "type": { + "type": "string", + "enum": [ + "VAT", + "GST", + "Custom" + ] + }, + "_id": { + "type": "string" + } + } + }, + "TaxAmountBreakdown": { + "type": "object", + "description": "A tax amount associated with a specific tax rate.", + "readOnly": true, + "properties": { + "amount": { + "type": "integer" + }, + "rate": { + "type": "string", + "description": "The tax rate applied. With the release of the tax manager feature this field is being deprecated in favor of the tax field.", + "deprecated": true + }, + "rateValue": { + "type": "number", + "description": "The tax rate value applied. With the release of the tax manager feature this field is being deprecated in favor of the tax field.", + "deprecated": true + }, + "tax": { + "$ref": "#/definitions/TaxBreakdownInfo" + } + } + }, + "Amounts": { + "type": "object", + "properties": { + "amount_subtotal": { + "type": "integer" + }, + "amount_subtotal_decimal": { + "type": "string" + }, + "amount_total": { + "type": "integer" + }, + "amount_total_decimal": { + "type": "string" + } + } + }, + "Currency": { + "type": "string", + "description": "Three-letter ISO currency code, in lowercase. Must be a supported currency.", + "example": "EUR" + }, + "RecurrenceAmount": { + "type": "object", + "description": "An amount associated with a specific recurrence.", + "readOnly": true, + "allOf": [ + { + "$ref": "#/definitions/Amounts" + } + ], + "properties": { + "type": { + "type": "string" + }, + "billing_period": { + "type": "string", + "enum": [ + "weekly", + "monthly", + "every_quarter", + "every_6_months", + "yearly" + ] + }, + "unit_amount_gross": { + "type": "integer" + }, + "unit_amount_net": { + "type": "integer" + }, + "amount_tax": { + "type": "integer" + }, + "currency": { + "$ref": "#/definitions/Currency" + } + }, + "required": [ + "amount_subtotal", + "amount_total", + "amount_subtotal_decimal", + "amount_total_decimal" + ] + }, + "RecurrenceAmountWithTax": { + "type": "object", + "description": "An amount associated with a specific recurrence.", + "properties": { + "type": { + "type": "string" + }, + "billing_period": { + "type": "string", + "enum": [ + "weekly", + "monthly", + "every_quarter", + "every_6_months", + "yearly" + ] + }, + "amount_total": { + "type": "integer" + }, + "amount_subtotal": { + "type": "integer" + }, + "amount_tax": { + "type": "integer" + }, + "tax": { + "$ref": "#/definitions/TaxAmountBreakdown" + } + }, + "required": [ + "amount_subtotal", + "amount_total" + ] + }, + "TotalDetails": { + "type": "object", + "description": "The total details with tax (and discount) aggregated totals.", + "readOnly": true, + "properties": { + "amount_shipping": { + "type": "integer", + "description": "This is the sum of all the price item shipping amounts." + }, + "amount_tax": { + "type": "integer", + "description": "This is the sum of all the price item tax amounts." + }, + "breakdown": { + "type": "object", + "description": "Breakdown of individual tax (and discount) amounts that add up to the totals.", + "properties": { + "taxes": { + "type": "array", + "description": "The aggregated price items tax amount per rate.", + "items": { + "$ref": "#/definitions/TaxAmountBreakdown" + } + }, + "recurrences": { + "type": "array", + "description": "The aggregated price items tax amount per rate.", + "items": { + "$ref": "#/definitions/RecurrenceAmount" + } + }, + "recurrencesByTax": { + "type": "array", + "description": "The aggregated price items recurrences by tax rate", + "items": { + "$ref": "#/definitions/RecurrenceAmountWithTax" + } + } + } + } + } + }, + "LineItems": { + "type": "array", + "items": { + "type": "object", + "properties": { + "price_id": { + "$ref": "#/definitions/BaseUUID" + }, + "product_id": { + "$ref": "#/definitions/BaseUUID" + }, + "quantity": { + "type": "integer" + }, + "description": { + "type": "string" + }, + "unit_amount": { + "type": "integer" + }, + "unit_amount_decimal": { + "type": "string" + }, + "pricing_model": { + "type": "string", + "enum": [ + "per_unit" + ] + }, + "_price": { + "type": "object", + "properties": { + "_id": { + "$ref": "#/definitions/BaseUUID" + }, + "_title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "unit_amount": { + "type": "integer" + }, + "unit_amount_decimal": { + "type": "string" + }, + "unit_amount_currency": { + "type": "string" + }, + "active": { + "type": "boolean" + }, + "sales_tax": { + "type": "string" + }, + "tax_behavior": { + "type": "string" + }, + "price_display_in_journeys": { + "type": "string" + }, + "type": { + "type": "string" + }, + "billing_period": { + "type": "string" + }, + "billing_duration_unit": { + "type": "string" + }, + "notice_time_unit": { + "type": "string" + }, + "termination_time_unit": { + "type": "string" + }, + "renewal_duration_unit": { + "type": "string" + }, + "internal_description": { + "type": "string" + }, + "pricing_model": { + "type": "string" + }, + "is_tax_inclusive": { + "type": "boolean" + }, + "headline": { + "type": "string" + }, + "subHeadline": { + "type": "string" + }, + "group": { + "type": "string" + } + } + }, + "_product": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "product" + ] + }, + "_title": { + "type": "string" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "price_options": { + "type": "array", + "items": { + "type": "object", + "properties": { + "_id": { + "$ref": "#/definitions/BaseUUID" + }, + "_title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "unit_amount": { + "type": "integer" + }, + "unit_amount_decimal": { + "type": "string" + }, + "unit_amount_currency": { + "type": "string" + }, + "active": { + "type": "boolean" + }, + "_schema": { + "type": "string" + }, + "_org": { + "type": "string" + }, + "_created_at": { + "type": "string", + "format": "date-time" + }, + "_updated_at": { + "type": "string", + "format": "date-time" + }, + "sales_tax": { + "type": "string" + }, + "tax_behavior": { + "type": "string" + }, + "price_display_in_journeys": { + "type": "string" + }, + "type": { + "type": "string" + }, + "billing_period": { + "type": "string" + }, + "billing_duration_unit": { + "type": "string" + }, + "notice_time_unit": { + "type": "string" + }, + "termination_time_unit": { + "type": "string" + }, + "renewal_duration_unit": { + "type": "string" + }, + "internal_description": { + "type": "string" + }, + "_owners": { + "type": "array", + "items": { + "type": "object", + "properties": { + "org_id": { + "type": "string" + } + } + } + }, + "pricing_model": { + "type": "string" + }, + "is_tax_inclusive": { + "type": "boolean" + }, + "_acl": { + "type": "object", + "properties": { + "view": { + "type": "array", + "items": { + "type": "string" + } + }, + "edit": { + "type": "array", + "items": { + "type": "string" + } + }, + "delete": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "$relation": { + "type": "object", + "properties": { + "entity_id": { + "$ref": "#/definitions/BaseUUID" + }, + "_schema": { + "type": "string" + }, + "_tags": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + } + }, + "_id": { + "$ref": "#/definitions/BaseUUID" + } + } + }, + "taxes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "rate": { + "type": "string" + }, + "rateValue": { + "type": "integer" + }, + "amount": { + "type": "integer" + } + } + } + }, + "currency": { + "type": "string" + }, + "unit_amount_net": { + "type": "integer" + }, + "unit_amount_net_decimal": { + "type": "string" + }, + "unit_amount_gross": { + "type": "integer" + }, + "unit_amount_gross_decimal": { + "type": "string" + }, + "amount_subtotal": { + "type": "integer" + }, + "amount_total": { + "type": "integer" + }, + "amount_tax": { + "type": "integer" + }, + "is_tax_inclusive": { + "type": "boolean" + }, + "amount_subtotal_decimal": { + "type": "string" + }, + "amount_total_decimal": { + "type": "string" + } + } + } + }, + "AddressAttribute": { + "type": "object", + "description": "Address attribute", + "additionalProperties": true, + "properties": { + "_tags": { + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "billing" + ] + }, + "street": { + "type": "string", + "description": "The first line of the address. Typically the street address or PO Box number.", + "nullable": true, + "example": "Hauptstraße" + }, + "street_number": { + "type": "string", + "description": "The second line of the address. Typically the number of the apartment, suite, or unit.", + "nullable": true, + "example": "123" + }, + "postal_code": { + "type": "string", + "description": "The postal code for the address.", + "nullable": true, + "example": "50668" + }, + "city": { + "type": "string", + "description": "The name of the city, district, village, or town.", + "nullable": true, + "example": "Cologne" + }, + "country": { + "type": "string", + "description": "The two-letter code for the country of the address.", + "nullable": true, + "example": "DE" + }, + "additional_info": { + "type": "string", + "description": "An additional description for the address", + "nullable": true + }, + "company_name": { + "type": "string", + "description": "the company name, usually used as extra delivery instructions", + "nullable": true + }, + "first_name": { + "type": "string", + "description": "the first name of the recipient, usually used as extra delivery instructions", + "nullable": true + }, + "last_name": { + "type": "string", + "description": "the last name of the recipient, usually used as extra delivery instructions", + "nullable": true + }, + "salutation": { + "type": "string", + "description": "the salutation of the recipient, usually used as extra delivery instructions", + "nullable": true + }, + "title": { + "type": "string", + "description": "the title of the recipient, usually used as extra delivery instructions", + "nullable": true + }, + "coordinates": { + "type": "string", + "description": "Geographical coordinates (latitude, longitude)", + "nullable": true, + "example": "50.948484, 6.943147" + }, + "plot_of_land": { + "type": "string", + "description": "Plot of land information", + "nullable": true + }, + "plot_area": { + "type": "string", + "description": "Area of the plot", + "nullable": true + }, + "start_date": { + "type": "string", + "description": "Start date for the address", + "format": "date", + "nullable": true + }, + "end_date": { + "type": "string", + "description": "End date for the address", + "format": "date", + "nullable": true + } + } + } + } +} \ No newline at end of file From 2d3240afcd68f209e9f42dc97c189120b069c0ac Mon Sep 17 00:00:00 2001 From: Karl Rankla Date: Fri, 14 Aug 2026 11:34:16 +0300 Subject: [PATCH 2/6] docs: clarify attachment readiness semantics --- .../integration-toolkit/outbound-file-delivery.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/integrations/integration-toolkit/outbound-file-delivery.md b/docs/integrations/integration-toolkit/outbound-file-delivery.md index 822b8a88..2b74055d 100644 --- a/docs/integrations/integration-toolkit/outbound-file-delivery.md +++ b/docs/integrations/integration-toolkit/outbound-file-delivery.md @@ -11,6 +11,14 @@ Outbound file delivery sends files referenced by an epilot event to an external `CustomerRequestSubmitted` is the main example: one submitted request can contain several `event_attachments`, and each attachment is delivered and monitored independently. +:::caution Attachment readiness + +`CustomerRequestSubmitted` is emitted when the ticket is created. Its attachments are a snapshot of the ticket relations visible when Event Catalog processes that event; it does not wait for a later relation write. In the usual journey flow the files already exist and the relation is normally visible by then, but this timing is not guaranteed. An empty snapshot produces `FAN_OUT_EMPTY` and is not automatically replayed when the relation appears. + +If the integration needs strict ordering after ticket creation, use a `FileUpdated` handoff after the workflow writes the ticket reference onto the file, or trigger a dedicated workflow event after all relations are complete. + +::: + :::info Upload versus download This page covers files moving **out of epilot**. To serve files from an external archive when a user opens them in epilot, use the [download File Proxy](./file-proxy.md). ::: From 516303e95600bf2ffc66008297b54888f8122a83 Mon Sep 17 00:00:00 2001 From: Karl Rankla Date: Fri, 14 Aug 2026 12:15:36 +0300 Subject: [PATCH 3/6] docs: sync outbound file delivery behavior --- .../integration-toolkit/outbound-file-delivery.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/integrations/integration-toolkit/outbound-file-delivery.md b/docs/integrations/integration-toolkit/outbound-file-delivery.md index 2b74055d..87806d1f 100644 --- a/docs/integrations/integration-toolkit/outbound-file-delivery.md +++ b/docs/integrations/integration-toolkit/outbound-file-delivery.md @@ -13,7 +13,7 @@ Outbound file delivery sends files referenced by an epilot event to an external :::caution Attachment readiness -`CustomerRequestSubmitted` is emitted when the ticket is created. Its attachments are a snapshot of the ticket relations visible when Event Catalog processes that event; it does not wait for a later relation write. In the usual journey flow the files already exist and the relation is normally visible by then, but this timing is not guaranteed. An empty snapshot produces `FAN_OUT_EMPTY` and is not automatically replayed when the relation appears. +`CustomerRequestSubmitted` is emitted when the ticket is created. In the standard Journey flow, the file entities already exist, but automation creates the ticket and copies its file relations in a second request. Event Catalog processes the ticket creation asynchronously and reads the current relation graph. The relation request usually finishes first, which is why the attachments are normally present, but this ordering is not guaranteed. An empty snapshot produces `FAN_OUT_EMPTY`; the later relation write does not emit another `CustomerRequestSubmitted` or automatically replay the delivery. If the integration needs strict ordering after ticket creation, use a `FileUpdated` handoff after the workflow writes the ticket reference onto the file, or trigger a dedicated workflow event after all relations are complete. @@ -265,6 +265,9 @@ Environment values are resolved after Handlebars. In the JSON sent to the API, w - `upload.max_file_bytes` defaults to the platform maximum of 100 MiB. Known size is checked before fetch; the limit is always enforced while buffering. - `upload.success_when` can reject a nominally successful response, such as an HTTP 200 response containing an error envelope. - `upload.external_id` records the target document ID without failing an otherwise successful delivery if extraction is unsuccessful. +- External HTTP `408`, `429`, and `5xx` responses are retried. Other `4xx` responses are terminal. Timeouts, OAuth refresh failures, and transient file-fetch failures are retried up to the configured attempt limit. +- Configuration and data errors that will not improve on retry—such as a missing file, invalid mapping result, missing required parameter, or invalid step template—fail terminally. JSONata expressions and Handlebars syntax are also checked when the recipe is saved. +- Terminal and exhausted deliveries are recorded as failed and completed without deliberately filling the dead-letter queue with permanent errors. ## Monitoring @@ -296,6 +299,7 @@ General configuration and mapping codes, including `USE_CASE_NOT_FOUND`, `USE_CA | JSON body breaks for some filenames | Replace plain interpolation with `{{json params.filename}}`. | | Event succeeds without uploading | Inspect `FAN_OUT_EMPTY`, the split expression, and `event_filter`. | | File fetch fails | Verify `entity_id`, `version_index`, and the configured file-size limit. | +| Target `4xx` is not retried | This is expected except for `408` and `429`; correct the request mapping or target configuration. | ## Related documentation From 61276e46ea1bb0e8ef142bb8922ff8827260e5f1 Mon Sep 17 00:00:00 2001 From: Karl Rankla Date: Fri, 14 Aug 2026 12:21:51 +0300 Subject: [PATCH 4/6] docs: clarify empty attachment monitoring --- .../integration-toolkit/outbound-file-delivery.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/integrations/integration-toolkit/outbound-file-delivery.md b/docs/integrations/integration-toolkit/outbound-file-delivery.md index 87806d1f..7c60ad7e 100644 --- a/docs/integrations/integration-toolkit/outbound-file-delivery.md +++ b/docs/integrations/integration-toolkit/outbound-file-delivery.md @@ -13,7 +13,9 @@ Outbound file delivery sends files referenced by an epilot event to an external :::caution Attachment readiness -`CustomerRequestSubmitted` is emitted when the ticket is created. In the standard Journey flow, the file entities already exist, but automation creates the ticket and copies its file relations in a second request. Event Catalog processes the ticket creation asynchronously and reads the current relation graph. The relation request usually finishes first, which is why the attachments are normally present, but this ordering is not guaranteed. An empty snapshot produces `FAN_OUT_EMPTY`; the later relation write does not emit another `CustomerRequestSubmitted` or automatically replay the delivery. +`CustomerRequestSubmitted` is emitted when the ticket is created. In the standard Journey flow, the file entities already exist, but automation creates the ticket and copies its file relations in a second request. Event Catalog processes the ticket creation asynchronously and reads the current relation graph. The relation request usually finishes first, which is why the attachments are normally present, but this ordering is not guaranteed. The later relation write does not emit another `CustomerRequestSubmitted` or automatically replay an empty snapshot. + +Without an attachment-count `event_filter`, an empty fan-out records `FAN_OUT_EMPTY`. With the example `$count(event_attachments) > 0` filter below, the use case is filtered out before fan-out, so no delivery is enqueued and no `FAN_OUT_EMPTY` is emitted for that mapping. If the integration needs strict ordering after ticket creation, use a `FileUpdated` handoff after the workflow writes the ticket reference onto the file, or trigger a dedicated workflow event after all relations are complete. From 5ce49f16e241e8d55295731c877d99eb12682216 Mon Sep 17 00:00:00 2001 From: Karl Rankla Date: Fri, 21 Aug 2026 13:00:05 +0300 Subject: [PATCH 5/6] docs: rewrite outbound file delivery for the v3 upload contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One binding for files: $file_data, always an array — the delivery's single attachment when fanning out, every attachment when not. Replaces $item, $file and $file_base64, and the {{ item.* }} / {{ file.* }} Handlebars equivalents. Removes the fields the API now rejects at save time, each naming its replacement: fan_out.split_expression, file_source, shared, required_keys, params_mapping, required_params, constants, lookups, upload.success_when and upload.external_id. A 2xx from the last step is the delivery, so there is no separate predicate re-judging it and no external id correlation hint to promise. Documents the per-step enabled expression and the skipped outcome it produces, upload.max_total_bytes, the JSONata body (body_jsonata, or empty to send the files unchanged), single-pass Handlebars for url and headers with plain {{env.NAME}}, and the save-time rule that an upload may only be bound to an event declaring event_attachments. Every example is verified against the shipped contract: each JSONata expression evaluates and passes the save-time binding check, and each Handlebars path resolves against the delivery's template context. --- .../integration-toolkit/configuration.md | 2 +- .../outbound-file-delivery.md | 269 +++++++++++------- 2 files changed, 172 insertions(+), 99 deletions(-) diff --git a/docs/integrations/integration-toolkit/configuration.md b/docs/integrations/integration-toolkit/configuration.md index 4d9a3347..6927f961 100644 --- a/docs/integrations/integration-toolkit/configuration.md +++ b/docs/integrations/integration-toolkit/configuration.md @@ -228,7 +228,7 @@ curl -X POST 'https://erp-integration.sls.epilot.io/v1/integrations/{integration Everything beyond the configuration contract — the polling and acknowledgement API, lease and ordering semantics, retention and expiry behavior, the dead-letter queue and operator actions, and poll-mode monitoring — is documented on the dedicated [Pollable Outbound](./pollable-outbound.md) page. -**File proxy delivery (push):** points to an upload-direction `file_proxy` use case in the same integration. The referenced recipe owns fan-out, payload mapping, file selection, authentication, and HTTP steps. `jsonata_expression` is rejected on this mapping type. See [Outbound File Delivery](./outbound-file-delivery.md) for the complete setup and runtime behavior. +**File proxy delivery (push):** points to an upload-direction `file_proxy` use case in the same integration. The referenced recipe owns fan-out, payload mapping, authentication, and HTTP steps. `jsonata_expression` is rejected on this mapping type, and the use case's `event_catalog_event` must declare `event_attachments`. See [Outbound File Delivery](./outbound-file-delivery.md) for the complete setup and runtime behavior. ```json "delivery": { diff --git a/docs/integrations/integration-toolkit/outbound-file-delivery.md b/docs/integrations/integration-toolkit/outbound-file-delivery.md index 7c60ad7e..527465f7 100644 --- a/docs/integrations/integration-toolkit/outbound-file-delivery.md +++ b/docs/integrations/integration-toolkit/outbound-file-delivery.md @@ -7,7 +7,7 @@ slug: /integrations/integration-toolkit/outbound-file-delivery # Outbound File Delivery -Outbound file delivery sends files referenced by an epilot event to an external document API. The Integration Toolkit fetches each file from epilot, maps the event and file into the target API's format, and runs a declarative HTTP workflow. +Outbound file delivery sends files referenced by an epilot event to an external document API. The Integration Toolkit fetches each file from epilot, maps the event and the file into the target API's format, and runs a declarative HTTP workflow. `CustomerRequestSubmitted` is the main example: one submitted request can contain several `event_attachments`, and each attachment is delivered and monitored independently. @@ -15,7 +15,7 @@ Outbound file delivery sends files referenced by an epilot event to an external `CustomerRequestSubmitted` is emitted when the ticket is created. In the standard Journey flow, the file entities already exist, but automation creates the ticket and copies its file relations in a second request. Event Catalog processes the ticket creation asynchronously and reads the current relation graph. The relation request usually finishes first, which is why the attachments are normally present, but this ordering is not guaranteed. The later relation write does not emit another `CustomerRequestSubmitted` or automatically replay an empty snapshot. -Without an attachment-count `event_filter`, an empty fan-out records `FAN_OUT_EMPTY`. With the example `$count(event_attachments) > 0` filter below, the use case is filtered out before fan-out, so no delivery is enqueued and no `FAN_OUT_EMPTY` is emitted for that mapping. +Without an attachment-count `event_filter`, an event with no attachments records `FAN_OUT_EMPTY`. With the example `$count(event_attachments) > 0` filter below, the use case is filtered out before fan-out, so no delivery is enqueued and no `FAN_OUT_EMPTY` is emitted for that mapping. If the integration needs strict ordering after ticket creation, use a `FileUpdated` handoff after the workflow writes the ticket reference onto the file, or trigger a dedicated workflow event after all relations are complete. @@ -32,7 +32,7 @@ The setup uses two use cases in the same integration: | Use case | Decides | Configuration | | --- | --- | --- | | `outbound` | **When** to deliver | Event Catalog event, optional event filter, and a pointer to the upload recipe | -| `file_proxy` with `direction: "upload"` | **What and how** to deliver | Fan-out, file selection, mapping, authentication, HTTP steps, limits, and success criteria | +| `file_proxy` with `direction: "upload"` | **What and how** to deliver | Fan-out, mapping, authentication, HTTP steps, and limits | ```mermaid sequenceDiagram @@ -48,9 +48,9 @@ sequenceDiagram Outbound->>Queue: Enqueue durable delivery Queue->>File: Fetch entity_id + version_index File-->>Queue: File bytes and metadata - Queue->>Queue: Evaluate lookups and params_mapping + Queue->>Queue: Evaluate enabled and body_jsonata per step Queue->>Target: Execute configured HTTP steps - Target-->>Queue: Result / external document ID + Target-->>Queue: Result end ``` @@ -61,7 +61,7 @@ The outbound delivery is a pure pointer to the file-proxy use-case slug. The rec You need: - an Integration Toolkit integration; -- an Event Catalog event containing a file reference, such as [`CustomerRequestSubmitted`](/docs/integrations/core-events#customer); +- an Event Catalog event that declares `event_attachments`, such as [`CustomerRequestSubmitted`](/docs/integrations/core-events#customer); - an external HTTP endpoint that accepts the file; and - environment values for target URLs and credentials. @@ -83,49 +83,29 @@ curl -X POST 'https://integration-toolkit.sls.epilot.io/v1/integrations/{integra "configuration": { "direction": "upload", "fan_out": { - "enabled": true, - "split_expression": "event_attachments" + "enabled": true }, - "lookups": { - "documentType": { - "source": "ticket._purpose[0]", - "entries": { - "termination": "CANCELLATION", - "complaint": "COMPLAINT" - }, - "default": "OTHER", - "on_miss": "warn" - } - }, - "constants": { - "sourceSystem": "epilot" - }, - "params_mapping": "{ \"customerNumber\": contact.customer_number, \"filename\": $item.filename, \"mimeType\": $file.mime_type, \"fileData\": $file_base64, \"documentType\": $lookups.documentType, \"submittedAt\": $germanDate($now()) }", - "required_params": ["customerNumber", "filename", "fileData"], "auth": { "type": "oauth2_client_credentials", - "token_url": "\\{{env.DOCUMENT_TOKEN_URL}}", - "client_id": "\\{{env.DOCUMENT_CLIENT_ID}}", - "client_secret": "\\{{env.DOCUMENT_CLIENT_SECRET}}" + "token_url": "{{env.DOCUMENT_TOKEN_URL}}", + "client_id": "{{env.DOCUMENT_CLIENT_ID}}", + "client_secret": "{{env.DOCUMENT_CLIENT_SECRET}}" }, "steps": [ { "method": "POST", - "url": "\\{{env.DOCUMENT_API_URL}}/documents", + "url": "{{env.DOCUMENT_API_URL}}/documents", "headers": { - "Authorization": "Bearer {{auth_token}}", "Content-Type": "application/json", - "Idempotency-Key": "{{custom_key}}" + "Idempotency-Key": "{{file_data.0.entity_id}}" }, - "body": "{\"customerNumber\":{{json params.customerNumber}},\"filename\":{{json params.filename}},\"mimeType\":{{json params.mimeType}},\"fileData\":{{json params.fileData}},\"documentType\":{{json params.documentType}},\"sourceSystem\":{{json params.sourceSystem}}}", + "body_jsonata": "{ \"customerNumber\": contact.customer_number, \"filename\": $file_data[0].filename, \"mimeType\": $file_data[0].mime_type, \"fileData\": $file_data[0].base64, \"sourceSystem\": \"epilot\", \"submittedAt\": $germanDate($now()) }", "response_type": "json" } ], "upload": { - "max_file_bytes": 104857600, - "max_delivery_attempts": 8, - "success_when": "statusCode >= 200 and statusCode < 300", - "external_id": "steps[-1].body.documentId" + "max_file_bytes": 26214400, + "max_delivery_attempts": 8 } } }' @@ -137,17 +117,25 @@ curl -X POST 'https://integration-toolkit.sls.epilot.io/v1/integrations/{integra | --- | --- | --- | | `direction` | Yes | Must be `upload`. Omitting it means the existing download behavior. | | `steps` | Yes | One or more HTTP requests. Upload steps support `GET`, `POST`, `PUT`, and `PATCH`. | -| `upload` | Yes | File-size limit, retry limit, optional success predicate, and external ID expression. | -| `params_mapping` | Yes | JSONata expression returning the `params` object used by HTTP templates. | -| `fan_out` | No | Splits one event into independent deliveries. Required for one upload per attachment. | -| `file_source` | No | JSONata selecting the attachment-shaped file reference when the fan-out item is not itself an attachment. | -| `lookups` | No | Named code translations evaluated before `params_mapping`. | -| `constants` | No | Static values merged underneath the mapped parameters. | -| `required_params` | No | Parameters that must not be absent, `null`, or empty before any HTTP request is sent. | +| `upload` | Yes | Size ceilings and the retry limit. | +| `fan_out` | No | Whether one event produces one delivery per file or a single delivery carrying all of them. | | `auth` | No | OAuth2 client credentials or password authentication. | | `secure_proxy` | No | Routes the steps through a `secure_proxy` use case in the same integration. | -Download-only fields such as `params`, `response`, `allowed_origins`, and `prevent_indirect_serving` are rejected for upload recipes. +Each step is configured with: + +| Field | Required | Description | +| --- | --- | --- | +| `url` | Yes | Handlebars template for the request URL. | +| `method` | Yes | `GET`, `POST`, `PUT`, or `PATCH`. | +| `response_type` | Yes | `json` or `binary`. | +| `headers` | No | Object whose values are Handlebars templates. | +| `body_jsonata` | No | JSONata producing the request body as data. Leave it out to send the delivery's files unchanged. | +| `enabled` | No | JSONata returning a boolean that decides whether this step runs. Absent means it runs. | + +Download-only fields are rejected for upload recipes: `params`, `response`, `allowed_origins`, `prevent_indirect_serving`, and a Handlebars `body` on a step. + +Fields that earlier versions of this feature carried are rejected as well, each naming its replacement: `params_mapping`, `required_params`, `constants`, `lookups`, `shared`, `file_source`, `fan_out.split_expression`, and a step's `required_keys`. ## 2. Subscribe to the event @@ -180,6 +168,8 @@ curl -X POST 'https://integration-toolkit.sls.epilot.io/v1/integrations/{integra }' ``` +The event must declare `event_attachments`. An outbound use case with a `file_proxy` mapping is rejected with `400` when its `event_catalog_event` carries no attachments — the message names the event and the offending mapping — because such a configuration would fan out to nothing on every event it ever sees and report no error anywhere. If the event catalog cannot be reached to check, the save fails with a retryable `503` rather than being let through. + Do not add `jsonata_expression` to a `file_proxy` outbound mapping. The referenced recipe owns payload mapping, and the API rejects an expression that would otherwise be ignored. Mapping IDs and timestamps are generated when omitted. Use `ack_tracking: "off"` for a file-only outbound use case: durable delivery state is tracked per file. If a webhook mapping in the same outbound use case requires acknowledgements, keep acknowledgement tracking enabled or separate the webhook and file deliveries. @@ -190,117 +180,200 @@ Check target resolution with: GET /v1/integrations/{integrationId}/outbound-status ``` -## Fan-out and file selection +## Fan-out -`fan_out.split_expression` runs once when the event is enqueued and must return an array. Every item becomes an independent delivery with its own status, retries, and idempotency record. - -For `CustomerRequestSubmitted`, use `event_attachments`. Each item already contains `entity_id` and `version_index`, so the worker recognizes it as a file reference without `file_source`. +`fan_out.enabled` is the only decision. The split is always over the event's `event_attachments`, so there is no expression to write — an upload only ever runs on events that declare that field. ```json "fan_out": { - "enabled": true, - "split_expression": "event_attachments[$count(_tags[$ = 'customer-document']) > 0]" + "enabled": true } ``` -Use `event_filter` to decide whether the event should be handled at all. Use `fan_out.split_expression` only to select the items delivered from a handled event. - -- An empty, missing, or `null` result produces no deliveries and records `FAN_OUT_EMPTY`. -- A non-array result fails with `FAN_OUT_INVALID_RESULT`. -- With fan-out disabled, the event produces one delivery and `$item` is not bound. +- **`enabled: true`** — one delivery per attachment. Each is fully independent: its own idempotency record, its own retry schedule, its own monitoring events. A four-file event can therefore end up three-of-four delivered, which is the honest state to report. +- **`enabled: false`** (or omitted) — the event produces exactly one delivery carrying every attachment. -If you split over something other than attachments, set `file_source` to an expression that returns the corresponding attachment: +The split is evaluated once, when the event is enqueued, so item indices — and therefore idempotency keys — stay stable across retries. -```json -"file_source": "event_attachments[entity_id = $item.file_id][0]" -``` +An event carrying no attachments produces no deliveries and records `FAN_OUT_EMPTY` at info level, which is the normal outcome for a catch-all subscription seeing an event with nothing to send. Use `event_filter` to decide whether the event should be handled at all. -If no file resolves, the workflow can still send metadata. Include the mapped file value in `required_params` when bytes are mandatory. +## Mapping the request body -## Mapping values +A step's `body_jsonata` produces the request body as **data**; the result is serialized to JSON and sent. Use it for every JSON body: it cannot emit malformed JSON, and it omits a key whose value is undefined instead of sending it empty, which is what makes optional fields work without a conditional guard. -The JSONata root is the complete event. Event fields such as `contact.customer_number` and `ticket._purpose` are read directly. Per-delivery values are `$`-prefixed bindings: +The evaluation root is the event, so `contact.customer_number`, `ticket._purpose` and `_event_id` are reachable directly, unprefixed. Everything else is a `$`-prefixed binding: | JSONata value | Contains | | --- | --- | -| `$item` | Current fan-out item | -| `$file_base64` | File content encoded as base64 | -| `$file` | `filename`, `mime_type`, and `size_bytes` from the resolved file | -| `$constants` | Configured static values | -| `$lookups` | Resolved lookup results | +| `$file_data` | The files this delivery carries, always an array | +| `$steps` | Results of the steps already executed, each `{statusCode, headers, body}` | | `$ack_id` | Triggering event acknowledgement ID, when present | -| `$now()` | Current ISO timestamp | +| `$env` | Organization environment variables | +| `$now()` | Current ISO timestamp, one value for the whole delivery | | `$germanDate(iso)` | An ISO timestamp formatted as a German date | +`$file_data` is the single binding for files, in both fan-out modes: one entry when fanning out, every attachment when not. `$file_data[0].filename` therefore works either way. Each entry is the event's `event_attachments` object plus `base64`: + +| Field | Contains | +| --- | --- | +| `entity_id` | File entity ID | +| `filename` | File name | +| `mime_type` | MIME type | +| `size_bytes` | Size in bytes | +| `base64` | File content, base64-encoded | +| `s3ref` | `bucket` and `key` of the stored file | +| `version_index` | Which file version the event referenced | +| `readable_size` | Human-readable size, such as `1.2 MB` | +| `_tags` | File entity tags | +| `relation_tags` | Tags on the relation that attached the file | +| `category` | File category | +| `file_date` | Document date | +| `_created_at` | When the file entity was created | + :::warning JSONata bindings -Write `$item.filename` and `$file_base64`, including the `$`. `item.filename` reads a root event property named `item` and silently produces no value. +Write `$file_data[0].filename`, including the `$`. `file_data[0].filename` reads a field named `file_data` on the event, which does not exist, and silently produces no value. Every `$` binding is checked when the recipe is saved, so a mistyped binding is rejected rather than quietly producing a missing key. ::: -Lookups support `on_miss: "default"`, `"warn"`, or `"fail"`. Warning mode continues with the configured default and records `LOOKUP_UNMAPPED`; fail mode stops that delivery. +Only `undefined` omits a key. `null`, `""`, `false` and `0` are values and are all sent. Write the two-arm ternary with no else branch to omit a key when a test is false: + +``` +{ "pin": $exists(contact.customer_pin) ? $string(contact.customer_pin) } +``` -## Building HTTP steps safely +Avoid `x ? $string(x)` as the test: JSONata reads `0` and `""` as false, so a meter number of `"0"` would silently vanish. And `: undefined` is not a literal — JSONata has no `undefined` keyword, so it is a path lookup that happens to find nothing. -HTTP steps use Handlebars after JSONata has produced `params`. The template context provides `params.*`, `item`, `event.id`, `event.name`, `custom_key`, earlier `steps.N.*`, and `auth_token`. +### Sending the files unchanged -Always use the `json` helper for values inserted into JSON bodies: +Leave `body_jsonata` out and the delivery sends its files exactly as they are: the single attachment object when fanning out, the whole array when not. No mapping is needed for the common case. -```handlebars +```json +"steps": [ + { + "method": "POST", + "url": "{{env.DOCUMENT_API_URL}}/documents", + "headers": { "Content-Type": "application/json" }, + "response_type": "json" + } +] +``` + +### Translating codes + +Codes are translated with an inline map in the expression itself. A miss yields `undefined`, so the key is simply left out; bind the map with `:=` when you also want a fallback: + +``` +( + $types := { "termination": "CANCELLATION", "complaint": "COMPLAINT" }; + { + "documentType": [$lookup($types, ticket._purpose[0]), "OTHER"][0], + "filename": $file_data[0].filename, + "fileData": $file_data[0].base64 + } +) +``` + +Names the expression binds itself with `:=` are accepted alongside the built-in bindings. If the delivery must not go out at all when a code is unmapped, test for it in the step's `enabled` expression instead. + +## Skipping a step + +A step's optional `enabled` expression is JSONata returning a boolean that decides whether the step runs. Absent means it runs. + +```json { - "filename": {{json params.filename}}, - "fileData": {{json params.fileData}} - {{#if params.customerNumber}}, - "customerNumber": {{json params.customerNumber}} - {{/if}} + "enabled": "$file_data[0].mime_type = \"application/pdf\"", + "method": "POST", + "url": "{{env.DOCUMENT_API_URL}}/documents", + "body_jsonata": "{ \"fileData\": $file_data[0].base64 }", + "response_type": "json" } ``` -Plain interpolation does not safely serialize quotes or control characters. `#if` is supported in JSON bodies; unsafe `#each` and `#with` blocks are rejected during validation. +A false result is a **break**: this step is skipped and so is every step after it, and the delivery is recorded as `skipped` rather than delivered or failed. It is acknowledged and never retried, and a `STEP_DISABLED` monitoring event is emitted at info level — a disabled step is the configuration working, not a fault. + +`enabled` reads the same bindings a body does, `$steps` included, so it can branch on what an earlier step returned. This is also how a single file is filtered out: with one delivery per attachment, a false result on the first step drops that file and leaves the others untouched. + +``` +$count($file_data[0].relation_tags[$ = "customer-document"]) > 0 +``` + +Return a boolean. An expression that cannot evaluate fails the delivery terminally with `MAPPING_EXPRESSION_FAILED` naming the step — a broken predicate must not read as a deliberate skip. + +## URLs, headers, and environment values + +Two expression languages, split by what they produce. **JSONata produces data**: `body_jsonata` and `enabled`. **Handlebars composes strings**: a step's `url` and `headers`. An upload step rejects a Handlebars `body` outright. -Environment values are resolved after Handlebars. In the JSON sent to the API, write `"\\{{env.NAME}}"`. The leading backslash preserves the placeholder through Handlebars and is removed before environment resolution. +Handlebars templates render exactly once, against a single context: + +| Namespace | Contents | +| --- | --- | +| `env` | Organization environment variables and secrets | +| `file_data` | The same array `$file_data` holds, so `{{file_data.0.filename}}` reads the first file | +| `steps` | Previous step results, such as `{{steps.0.body.documentId}}` | +| `auth_token` | The acquired OAuth2 token, added automatically as a `Bearer` header unless a step sets its own `Authorization` | + +Environment values are part of that one context, so write them plainly: + +```json +"url": "{{env.DOCUMENT_API_URL}}/documents" +``` + +:::warning No backslash escape +Upload templates render in a single pass. Do not write the legacy `\{{env.NAME}}` escape used by the [download direction](./file-proxy.md#environment-variable-resolution) — it is not rewritten here and renders as the literal text `{{env.NAME}}`, which the delivery then rejects rather than shipping. +::: + +Two guards run on every rendered upload template, because single-pass rendering fails quietly by default. Both are terminal, and each names what to fix: + +- **A residual `{{` after rendering** — a configuration still carrying the `\{{` escape. Rewrite it without the backslash. +- **A referenced `env` key absent from the environment** — checked before the URL is parsed, because an empty value in host position turns `https://{{env.host}}/document/import` into `https:///document/import`, whose host then parses as `document`. Provision the variable. A key that exists and is legitimately empty is fine; only absence fails. + +Store credentials and base URLs as organization environment variables using epilot's Environments & Secrets feature, following the [naming recommendations](./file-proxy.md#recommended-environment-variable-naming). ## Delivery behavior +Every delivery ends in one of three terminal outcomes, counted separately: **delivered**, **skipped**, or **failed**. A 2xx from the last step is the delivery — there is no separate predicate re-judging a response the transport already accepted. + - Delivery is **at least once**. Normal queue redelivery is deduplicated by a durable per-file record retained for 30 days. -- A failure after the target accepts a file but before epilot commits success can still repeat the request. Pass `{{custom_key}}` to the target and make it an idempotency key. +- A failure after the target accepts a file but before epilot commits success can still repeat the request. If the target supports an idempotency key, send it a value that is stable across retries of the same delivery — `$file_data[0].entity_id & ":" & _event_id` in a body, or `{{file_data.0.entity_id}}` in a header. - The upload recipe is read again for queued retries, subject to a short cache. Correcting configuration affects later attempts without replaying the source event. -- `upload.max_delivery_attempts` defaults to 8 and supports 1–100 attempts with jittered backoff. -- `upload.max_file_bytes` defaults to the platform maximum of 100 MiB. Known size is checked before fetch; the limit is always enforced while buffering. -- `upload.success_when` can reject a nominally successful response, such as an HTTP 200 response containing an error envelope. -- `upload.external_id` records the target document ID without failing an otherwise successful delivery if extraction is unsuccessful. +- `upload.max_delivery_attempts` defaults to 8 and supports 1–100 attempts with jittered backoff. The default schedules at most 7 delays, totaling roughly 7 hours 40 minutes before jitter, so a normal ERP maintenance window does not immediately exhaust them. +- `upload.max_file_bytes` is the per-file ceiling. It defaults to, and is clamped by, the platform maximum of 100 MiB (`104857600`). Known size is checked before fetch; the limit is always enforced while buffering. +- `upload.max_total_bytes` is the ceiling for all of a delivery's files together, in bytes, and is capped by the same platform maximum — it may lower it but never raise it. It only has an effect with fan-out disabled, where one delivery carries every attachment and base64 inflates each by about a third. - External HTTP `408`, `429`, and `5xx` responses are retried. Other `4xx` responses are terminal. Timeouts, OAuth refresh failures, and transient file-fetch failures are retried up to the configured attempt limit. -- Configuration and data errors that will not improve on retry—such as a missing file, invalid mapping result, missing required parameter, or invalid step template—fail terminally. JSONata expressions and Handlebars syntax are also checked when the recipe is saved. +- Configuration and data errors that will not improve on retry — such as a missing file, an expression that cannot evaluate, a body expression that returns something other than an object or array, or an unresolvable step template — fail terminally. JSONata expressions, Handlebars syntax, and `$` bindings are also checked when the recipe is saved. - Terminal and exhausted deliveries are recorded as failed and completed without deliberately filling the dead-letter queue with permanent errors. ## Monitoring -Every per-file monitoring event uses the source Event Catalog `_event_id` as both `event_id` and `correlation_id`. Details include the use case, mapping, attachment, item index, attempt, and external ID where available. +Every per-file monitoring event uses the source Event Catalog `_event_id` as both `event_id` and `correlation_id`. Details include the use case, mapping, attachment, item index, and attempt, plus the captured request and response of the exchange with the target, with credentials redacted. | Level | Code | Meaning | | --- | --- | --- | | Success | `FILE_PROXY_UPLOADED` | The target accepted the file workflow. | -| Info | `FILE_PROXY_UPLOAD_ENQUEUED` | A per-file delivery was queued. | -| Info | `FAN_OUT_EMPTY` | The event had no matching items. | +| Info | `FILE_PROXY_UPLOAD_ENQUEUED` | Per-file deliveries were queued for an event. | +| Info | `FAN_OUT_EMPTY` | The event carried no attachments to send. | +| Info | `STEP_DISABLED` | A step's `enabled` expression returned false, so the delivery was skipped. Includes `step_index`. | | Warning | `FILE_PROXY_UPLOAD_RETRYING` | The delivery failed and will be attempted again. | -| Warning / error | `LOOKUP_UNMAPPED` | A lookup missed; `warn` continues with a fallback, while `fail` stops the delivery. | | Error | `FILE_PROXY_UPLOAD_FAILED` | Delivery failed terminally or exhausted attempts. | | Error | `FILE_FETCH_FAILED` | File API did not return usable content. | | Error | `FILE_TOO_LARGE` | The file exceeded the recipe or platform limit. | | Error | `ATTACHMENT_NOT_FOUND` | The referenced file entity or version was not found. | -| Error | `REQUIRED_PARAM_MISSING` | A required mapped parameter was absent. | -| Error | `FAN_OUT_INVALID_RESULT` | The split expression returned a non-array value. | +| Error | `MAPPING_EXPRESSION_FAILED` | A `body_jsonata` or `enabled` expression could not be evaluated, or produced an unusable result. | +| Error | `FAN_OUT_INVALID_RESULT` | The event's `event_attachments` was not an array. | -General configuration and mapping codes, including `USE_CASE_NOT_FOUND`, `USE_CASE_DISABLED`, and `MAPPING_EXPRESSION_FAILED`, can also apply. These are epilot-produced monitoring codes; they are separate from the `EXTERNAL_*` codes described in [External Monitoring Events](./external-monitoring-events.md). +General configuration codes, including `USE_CASE_NOT_FOUND`, `USE_CASE_DISABLED`, `USE_CASE_INVALID_TYPE`, and `USE_CASE_MISSING_CONFIG`, can also apply. These are epilot-produced monitoring codes; they are separate from the `EXTERNAL_*` codes described in [External Monitoring Events](./external-monitoring-events.md). ## Troubleshooting | Symptom | Check | | --- | --- | +| The use case cannot be saved | A `file_proxy` mapping requires an event that declares `event_attachments`. The `400` message names the event. | | Target is unresolved | The delivery's `use_case_slug` must match an enabled upload-direction `file_proxy` use case in the same integration. | -| Mapped filename or bytes are empty | Per-item bindings require `$item` and `$file_base64`, including the `$`. | -| Target URL or credentials are empty | Persist environment placeholders with a leading backslash: `\\{{env.NAME}}` in JSON. | -| JSON body breaks for some filenames | Replace plain interpolation with `{{json params.filename}}`. | -| Event succeeds without uploading | Inspect `FAN_OUT_EMPTY`, the split expression, and `event_filter`. | -| File fetch fails | Verify `entity_id`, `version_index`, and the configured file-size limit. | +| Mapped filename or bytes are empty | File bindings require `$file_data`, including the `$`, and an index: `$file_data[0].base64`. | +| Target URL or credentials are empty | Write `{{env.NAME}}` without a leading backslash, and confirm the variable exists in the organization's environment. | +| An optional field is missing from the body | Only `undefined` omits a key. Check the test in the two-arm ternary — `0` and `""` are false in JSONata. | +| Everything reports skipped | An `enabled` expression is returning false. `STEP_DISABLED` names the `step_index`. | +| Event succeeds without uploading | Inspect `FAN_OUT_EMPTY` and `event_filter`; the event may carry no attachments. | +| File fetch fails | Verify `entity_id`, `version_index`, and the configured file-size limits. | | Target `4xx` is not retried | This is expected except for `408` and `429`; correct the request mapping or target configuration. | ## Related documentation From c79eae1eef47c0bc7ebd02506883d9a01e2fb9a9 Mon Sep 17 00:00:00 2001 From: Karl Rankla Date: Fri, 21 Aug 2026 18:26:29 +0300 Subject: [PATCH 6/6] docs: align file delivery guide with current runtime --- .../outbound-file-delivery.md | 79 ++++++++++--------- .../CustomerRequestSubmitted.config.json | 52 ++---------- .../CustomerRequestSubmitted.sample.json | 10 +-- .../CustomerRequestSubmitted.schema.json | 44 ++++------- 4 files changed, 67 insertions(+), 118 deletions(-) diff --git a/docs/integrations/integration-toolkit/outbound-file-delivery.md b/docs/integrations/integration-toolkit/outbound-file-delivery.md index 527465f7..424224a9 100644 --- a/docs/integrations/integration-toolkit/outbound-file-delivery.md +++ b/docs/integrations/integration-toolkit/outbound-file-delivery.md @@ -9,15 +9,17 @@ slug: /integrations/integration-toolkit/outbound-file-delivery Outbound file delivery sends files referenced by an epilot event to an external document API. The Integration Toolkit fetches each file from epilot, maps the event and the file into the target API's format, and runs a declarative HTTP workflow. -`CustomerRequestSubmitted` is the main example: one submitted request can contain several `event_attachments`, and each attachment is delivered and monitored independently. +`CustomerRequestSubmitted` is the main example: one submitted request can contain several `event_attachments`, and with fan-out enabled each attachment is delivered and monitored independently. -:::caution Attachment readiness +:::caution Trigger after attachments are ready -`CustomerRequestSubmitted` is emitted when the ticket is created. In the standard Journey flow, the file entities already exist, but automation creates the ticket and copies its file relations in a second request. Event Catalog processes the ticket creation asynchronously and reads the current relation graph. The relation request usually finishes first, which is why the attachments are normally present, but this ordering is not guaranteed. The later relation write does not emit another `CustomerRequestSubmitted` or automatically replay an empty snapshot. +`CustomerRequestSubmitted` is triggered explicitly by Automation; ticket creation no longer emits it automatically. Place the trigger action after the workflow has created the ticket and written its file relations. Event Catalog then hydrates the ticket's current graph and builds `event_attachments` from every related file. + +An empty attachment array is valid because a customer request may contain no uploads. If Automation triggers the event before the file relations are written, that empty snapshot is published and a later relation update does not replay it automatically. Without an attachment-count `event_filter`, an event with no attachments records `FAN_OUT_EMPTY`. With the example `$count(event_attachments) > 0` filter below, the use case is filtered out before fan-out, so no delivery is enqueued and no `FAN_OUT_EMPTY` is emitted for that mapping. -If the integration needs strict ordering after ticket creation, use a `FileUpdated` handoff after the workflow writes the ticket reference onto the file, or trigger a dedicated workflow event after all relations are complete. +For strict upload ordering, make the `CustomerRequestSubmitted` trigger action depend on the relation-writing step. If that workflow cannot provide the ordering, use a `FileUpdated` handoff or another dedicated event after the relations are complete. ::: @@ -54,7 +56,7 @@ sequenceDiagram end ``` -The outbound delivery is a pure pointer to the file-proxy use-case slug. The recipe is resolved at runtime, so you can create the two use cases in either order. A missing or disabled target is visible through monitoring and the outbound status endpoint. +The outbound delivery is a pure pointer to the file-proxy use-case slug. The recipe is resolved at runtime, so you can create the two use cases in either order. A missing, disabled, or wrong-direction target is reported through monitoring when the event is handled. ## Before you start @@ -96,10 +98,9 @@ curl -X POST 'https://integration-toolkit.sls.epilot.io/v1/integrations/{integra "method": "POST", "url": "{{env.DOCUMENT_API_URL}}/documents", "headers": { - "Content-Type": "application/json", - "Idempotency-Key": "{{file_data.0.entity_id}}" + "Content-Type": "application/json" }, - "body_jsonata": "{ \"customerNumber\": contact.customer_number, \"filename\": $file_data[0].filename, \"mimeType\": $file_data[0].mime_type, \"fileData\": $file_data[0].base64, \"sourceSystem\": \"epilot\", \"submittedAt\": $germanDate($now()) }", + "body_jsonata": "{ \"customerNumber\": contact.customer_number, \"filename\": $file_data[0].filename, \"mimeType\": $file_data[0].mime_type, \"fileData\": $file_data[0].base64, \"sourceSystem\": \"epilot\", \"idempotencyKey\": $file_data[0].entity_id & \":\" & _event_id, \"submittedAt\": $germanDate($now()) }", "response_type": "json" } ], @@ -135,7 +136,9 @@ Each step is configured with: Download-only fields are rejected for upload recipes: `params`, `response`, `allowed_origins`, `prevent_indirect_serving`, and a Handlebars `body` on a step. -Fields that earlier versions of this feature carried are rejected as well, each naming its replacement: `params_mapping`, `required_params`, `constants`, `lookups`, `shared`, `file_source`, `fan_out.split_expression`, and a step's `required_keys`. +Fields that earlier versions of this feature carried are rejected as well, each naming its replacement: `params_mapping`, `required_params`, `constants`, `lookups`, `shared`, `file_source`, `fan_out.split_expression`, and a step's `required_keys` or `body_source`. + +`upload.success_when` and `upload.external_id` have also been removed. For compatibility they are not rejected, but they have no effect: a final response below `400` completes the workflow, and no external ID is stored or added to monitoring. Remove both fields from existing recipes. ## 2. Subscribe to the event @@ -168,17 +171,13 @@ curl -X POST 'https://integration-toolkit.sls.epilot.io/v1/integrations/{integra }' ``` -The event must declare `event_attachments`. An outbound use case with a `file_proxy` mapping is rejected with `400` when its `event_catalog_event` carries no attachments — the message names the event and the offending mapping — because such a configuration would fan out to nothing on every event it ever sees and report no error anywhere. If the event catalog cannot be reached to check, the save fails with a retryable `503` rather than being let through. +The event's catalog schema must declare `event_attachments`. An outbound use case with a `file_proxy` mapping is rejected with `400` when that field is absent from the schema — the message names the event and the offending mapping — because such a configuration would fan out to nothing on every event it ever sees and report no error anywhere. An individual event may still carry an empty array. If the event catalog cannot be reached to check the schema, the save fails with a retryable `503` rather than being let through. Do not add `jsonata_expression` to a `file_proxy` outbound mapping. The referenced recipe owns payload mapping, and the API rejects an expression that would otherwise be ignored. Mapping IDs and timestamps are generated when omitted. -Use `ack_tracking: "off"` for a file-only outbound use case: durable delivery state is tracked per file. If a webhook mapping in the same outbound use case requires acknowledgements, keep acknowledgement tracking enabled or separate the webhook and file deliveries. +The delivery object is now only `{ "type": "file_proxy", "use_case_slug": "..." }`. Legacy delivery fields are rejected: `attachment_selector` has no replacement because the split is fixed to `event_attachments`; `constants` and `lookups` belong inline in a step's `body_jsonata`; and `max_delivery_attempts` belongs at `upload.max_delivery_attempts` on the referenced recipe. -Check target resolution with: - -```http -GET /v1/integrations/{integrationId}/outbound-status -``` +Use `ack_tracking: "off"` for a file-only outbound use case: durable delivery state is tracked per file. If a webhook mapping in the same outbound use case requires acknowledgements, keep acknowledgement tracking enabled or separate the webhook and file deliveries. ## Fan-out @@ -212,14 +211,14 @@ The evaluation root is the event, so `contact.customer_number`, `ticket._purpose | `$now()` | Current ISO timestamp, one value for the whole delivery | | `$germanDate(iso)` | An ISO timestamp formatted as a German date | -`$file_data` is the single binding for files, in both fan-out modes: one entry when fanning out, every attachment when not. `$file_data[0].filename` therefore works either way. Each entry is the event's `event_attachments` object plus `base64`: +`$file_data` is the single binding for files, in both fan-out modes: one entry when fanning out, every attachment when not. `$file_data[0].filename` therefore works either way. Each entry starts with the event's `event_attachments` object. The worker resolves the file by `entity_id` and `version_index`, adds `base64`, and replaces the filename, MIME type, and size with the values of the file version it actually loaded. An event `s3ref` is passed through as metadata; it is not used as the download source. | Field | Contains | | --- | --- | | `entity_id` | File entity ID | -| `filename` | File name | -| `mime_type` | MIME type | -| `size_bytes` | Size in bytes | +| `filename` | File name returned by File API | +| `mime_type` | MIME type returned by File API | +| `size_bytes` | Loaded size in bytes | | `base64` | File content, base64-encoded | | `s3ref` | `bucket` and `key` of the stored file | | `version_index` | Which file version the event referenced | @@ -288,7 +287,7 @@ A step's optional `enabled` expression is JSONata returning a boolean that decid } ``` -A false result is a **break**: this step is skipped and so is every step after it, and the delivery is recorded as `skipped` rather than delivered or failed. It is acknowledged and never retried, and a `STEP_DISABLED` monitoring event is emitted at info level — a disabled step is the configuration working, not a fault. +A false result is a **break**: this step is skipped and so is every step after it, and the delivery is recorded as `skipped` rather than delivered or failed. Earlier steps are not undone. The delivery is acknowledged and never retried, and a `STEP_DISABLED` monitoring event is emitted at info level — a disabled step is the configuration working, not a fault. `enabled` reads the same bindings a body does, `$steps` included, so it can branch on what an earlier step returned. This is also how a single file is filtered out: with one delivery per attachment, a false result on the first step drops that file and leaves the others untouched. @@ -296,7 +295,7 @@ A false result is a **break**: this step is skipped and so is every step after i $count($file_data[0].relation_tags[$ = "customer-document"]) > 0 ``` -Return a boolean. An expression that cannot evaluate fails the delivery terminally with `MAPPING_EXPRESSION_FAILED` naming the step — a broken predicate must not read as a deliberate skip. +Return exactly `true` or `false`. An expression that throws or returns any other value fails the delivery terminally with `MAPPING_EXPRESSION_FAILED` naming the step — a missing path must not be mistaken for a deliberate skip. ## URLs, headers, and environment values @@ -330,49 +329,55 @@ Store credentials and base URLs as organization environment variables using epil ## Delivery behavior -Every delivery ends in one of three terminal outcomes, counted separately: **delivered**, **skipped**, or **failed**. A 2xx from the last step is the delivery — there is no separate predicate re-judging a response the transport already accepted. +The worker distinguishes three terminal outcomes: **delivered**, **skipped**, or **failed**. A skipped delivery is completed for idempotency purposes, acknowledged, and never retried; its monitoring code keeps it distinct from a workflow that reached its final step. When the final step runs, any response below `400` completes the workflow — there is no separate predicate re-judging a response the transport already accepted. -- Delivery is **at least once**. Normal queue redelivery is deduplicated by a durable per-file record retained for 30 days. -- A failure after the target accepts a file but before epilot commits success can still repeat the request. If the target supports an idempotency key, send it a value that is stable across retries of the same delivery — `$file_data[0].entity_id & ":" & _event_id` in a body, or `{{file_data.0.entity_id}}` in a header. +- Delivery is **at least once**. Normal queue redelivery is deduplicated by a durable per-delivery record retained for 30 days. +- A failure after the target accepts a file but before epilot commits success can still repeat the request. If the target supports an idempotency key, send it a value that is stable across retries of the same delivery. `$file_data[0].entity_id & ":" & _event_id` in `body_jsonata` distinguishes separate events for the same file; the Handlebars header context does not expose the root event ID. - The upload recipe is read again for queued retries, subject to a short cache. Correcting configuration affects later attempts without replaying the source event. - `upload.max_delivery_attempts` defaults to 8 and supports 1–100 attempts with jittered backoff. The default schedules at most 7 delays, totaling roughly 7 hours 40 minutes before jitter, so a normal ERP maintenance window does not immediately exhaust them. -- `upload.max_file_bytes` is the per-file ceiling. It defaults to, and is clamped by, the platform maximum of 100 MiB (`104857600`). Known size is checked before fetch; the limit is always enforced while buffering. -- `upload.max_total_bytes` is the ceiling for all of a delivery's files together, in bytes, and is capped by the same platform maximum — it may lower it but never raise it. It only has an effect with fan-out disabled, where one delivery carries every attachment and base64 inflates each by about a third. -- External HTTP `408`, `429`, and `5xx` responses are retried. Other `4xx` responses are terminal. Timeouts, OAuth refresh failures, and transient file-fetch failures are retried up to the configured attempt limit. +- `upload.max_file_bytes` is the per-file ceiling. It defaults to the platform maximum of 100 MiB (`104857600`) and cannot exceed it. Known size is checked before fetch; the limit is always enforced while buffering. +- `upload.max_total_bytes` is the ceiling for all of a delivery's files together, in bytes, and cannot exceed the same platform maximum. It only has an effect with fan-out disabled, where one delivery carries every attachment and base64 inflates each by about a third. +- External HTTP responses below `400` are accepted. `408`, `429`, and `5xx` responses are retried; other `4xx` responses are terminal. Timeouts, OAuth refresh failures, and transient file-fetch failures are retried up to the configured attempt limit. - Configuration and data errors that will not improve on retry — such as a missing file, an expression that cannot evaluate, a body expression that returns something other than an object or array, or an unresolvable step template — fail terminally. JSONata expressions, Handlebars syntax, and `$` bindings are also checked when the recipe is saved. - Terminal and exhausted deliveries are recorded as failed and completed without deliberately filling the dead-letter queue with permanent errors. ## Monitoring -Every per-file monitoring event uses the source Event Catalog `_event_id` as both `event_id` and `correlation_id`. Details include the use case, mapping, attachment, item index, and attempt, plus the captured request and response of the exchange with the target, with credentials redacted. +File-delivery monitoring uses the source Event Catalog `_event_id` as both `event_id` and `correlation_id`, so all attachments from one event stay in one trace. Base details identify the event, mapping, upload recipe, attachment, item index, and item count. + +Worker terminal outcomes add the attempt number. When the file proxy calls the target directly, a terminal record also includes the final request and response when available; credentials are redacted and long values such as base64 file content are elided. Retry records do not duplicate that exchange. When a recipe uses `secure_proxy`, the secure proxy records the partner exchange under its own use case instead. + +Worker terminal outcomes are emitted twice: once for the outbound use case and once for the upload `file_proxy` use case. Enqueue, empty-fan-out, and retry records are attributed only to the outbound use case. | Level | Code | Meaning | | --- | --- | --- | -| Success | `FILE_PROXY_UPLOADED` | The target accepted the file workflow. | -| Info | `FILE_PROXY_UPLOAD_ENQUEUED` | Per-file deliveries were queued for an event. | +| Success | `FILE_PROXY_UPLOADED` | The workflow reached its final step with a response below `400`. | +| Info | `FILE_PROXY_UPLOAD_ENQUEUED` | The mapping queued its deliveries; `item_count` records how many. | | Info | `FAN_OUT_EMPTY` | The event carried no attachments to send. | -| Info | `STEP_DISABLED` | A step's `enabled` expression returned false, so the delivery was skipped. Includes `step_index`. | +| Info | `STEP_DISABLED` | A step's `enabled` expression returned false, so the remaining workflow was skipped. Includes the zero-based `step_index`. A legacy or malformed queued item with no usable attachment also completes through this code, without `step_index`. | | Warning | `FILE_PROXY_UPLOAD_RETRYING` | The delivery failed and will be attempted again. | | Error | `FILE_PROXY_UPLOAD_FAILED` | Delivery failed terminally or exhausted attempts. | | Error | `FILE_FETCH_FAILED` | File API did not return usable content. | -| Error | `FILE_TOO_LARGE` | The file exceeded the recipe or platform limit. | +| Error | `FILE_TOO_LARGE` | A file, or all files in one non-fan-out delivery together, exceeded the recipe or platform limit. | | Error | `ATTACHMENT_NOT_FOUND` | The referenced file entity or version was not found. | | Error | `MAPPING_EXPRESSION_FAILED` | A `body_jsonata` or `enabled` expression could not be evaluated, or produced an unusable result. | -| Error | `FAN_OUT_INVALID_RESULT` | The event's `event_attachments` was not an array. | General configuration codes, including `USE_CASE_NOT_FOUND`, `USE_CASE_DISABLED`, `USE_CASE_INVALID_TYPE`, and `USE_CASE_MISSING_CONFIG`, can also apply. These are epilot-produced monitoring codes; they are separate from the `EXTERNAL_*` codes described in [External Monitoring Events](./external-monitoring-events.md). +`REQUIRED_PARAM_MISSING`, `LOOKUP_UNMAPPED`, and `FAN_OUT_INVALID_RESULT` remain in the shared code enum but are no longer produced by this upload path; their configuration mechanisms were removed. + ## Troubleshooting | Symptom | Check | | --- | --- | -| The use case cannot be saved | A `file_proxy` mapping requires an event that declares `event_attachments`. The `400` message names the event. | -| Target is unresolved | The delivery's `use_case_slug` must match an enabled upload-direction `file_proxy` use case in the same integration. | +| The use case cannot be saved | A `file_proxy` mapping requires an event whose catalog schema declares `event_attachments`. The `400` message names the event. | +| Target is unresolved | The delivery's `use_case_slug` must match an enabled upload-direction `file_proxy` use case in the same integration. Check `USE_CASE_NOT_FOUND`, `USE_CASE_DISABLED`, or `USE_CASE_INVALID_TYPE` monitoring. | | Mapped filename or bytes are empty | File bindings require `$file_data`, including the `$`, and an index: `$file_data[0].base64`. | | Target URL or credentials are empty | Write `{{env.NAME}}` without a leading backslash, and confirm the variable exists in the organization's environment. | | An optional field is missing from the body | Only `undefined` omits a key. Check the test in the two-arm ternary — `0` and `""` are false in JSONata. | -| Everything reports skipped | An `enabled` expression is returning false. `STEP_DISABLED` names the `step_index`. | +| Everything reports skipped | An `enabled` expression is returning false. `STEP_DISABLED` names the `step_index`; if it is absent, inspect the queued attachment's `entity_id`. | | Event succeeds without uploading | Inspect `FAN_OUT_EMPTY` and `event_filter`; the event may carry no attachments. | +| `CustomerRequestSubmitted` has no files | Place its Automation trigger action after the workflow step that writes the ticket's file relations. An empty attachment array is valid and is not replayed after a later relation update. | | File fetch fails | Verify `entity_id`, `version_index`, and the configured file-size limits. | | Target `4xx` is not retried | This is expected except for `408` and `429`; correct the request mapping or target configuration. | diff --git a/static/events/CustomerRequestSubmitted.config.json b/static/events/CustomerRequestSubmitted.config.json index 2608f816..47b39976 100644 --- a/static/events/CustomerRequestSubmitted.config.json +++ b/static/events/CustomerRequestSubmitted.config.json @@ -1,7 +1,7 @@ { "event_name": "CustomerRequestSubmitted", "event_title": "Customer Request Submitted", - "event_description": "Triggered when a customer request ticket is created, including all files attached to the ticket", + "event_description": "Explicitly triggered by Automation after a customer request ticket and its file attachments are ready", "event_version": "1.0", "event_tags": [ "builtin", @@ -12,40 +12,6 @@ "customer-request" ], "schema_fields": { - "operation": { - "json_schema": { - "type": "string", - "enum": [ - "createEntity" - ], - "description": "The entity operation type that triggered this event" - }, - "required": true - }, - "activity_id": { - "json_schema": { - "type": "string", - "description": "Activity ID associated with this operation (ULID)", - "example": "01F130Q52Q6MWSNS8N2AVXV4JN" - }, - "required": false - }, - "activity_type": { - "json_schema": { - "type": "string", - "description": "Activity type (e.g., EntityCreated, EntityUpdated)", - "example": "EntityUpdated" - }, - "required": false - }, - "trigger_entity": { - "json_schema": { - "type": "string", - "format": "uuid", - "description": "Entity ID of the entity that triggered this event" - }, - "required": true - }, "event_attachments": { "json_schema": { "type": "array", @@ -132,9 +98,9 @@ ], "additionalProperties": false }, - "description": "All files related to the ticket. A single submission can carry many documents across several upload blocks. Consumers identify a given upload via `relation_tags` / `_tags` and scope the use case via the `_purpose` taxonomy IDs on the ticket node (IDs only — resolving them to names is up to the consumer)." + "description": "All files related to the ticket, empty when the request carries none. A single submission can carry many documents across several upload blocks. Consumers identify a given upload via `relation_tags` / `_tags` and scope the use case via the `_purpose` taxonomy IDs on the ticket node (IDs only — resolving them to names is up to the consumer)." }, - "required": false, + "required": true, "graph_source": "[ticket_files.{\"entity_id\": _id, \"filename\": filename, \"mime_type\": mime_type, \"size_bytes\": size_bytes, \"s3ref\": s3ref, \"version_index\": ($count(versions) > 0 ? $count(versions) - 1 : 0), \"readable_size\": readable_size, \"_tags\": _tags, \"relation_tags\": relation_tags, \"category\": category, \"file_date\": file_date, \"_created_at\": _created_at}]" } }, @@ -197,13 +163,7 @@ } ] }, - "entity_operation": { - "operation": [ - "createEntity" - ], - "schema": [ - "ticket" - ] - }, - "automation_trigger": true + "automation_trigger": true, + "automation_trigger_only": true, + "automation_trigger_seed_node": "ticket" } \ No newline at end of file diff --git a/static/events/CustomerRequestSubmitted.sample.json b/static/events/CustomerRequestSubmitted.sample.json index 887d0d0d..adbfefaf 100644 --- a/static/events/CustomerRequestSubmitted.sample.json +++ b/static/events/CustomerRequestSubmitted.sample.json @@ -4,10 +4,10 @@ "_event_id": "string", "_event_name": "CustomerRequestSubmitted", "_event_version": "1.0", - "_event_source": "string", + "_event_source": "automation", "_ack_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", - "_trigger_source_type": "api", - "_trigger_source": "api_123456", + "_trigger_source_type": "automation", + "_trigger_source": "execution-id/action-id", "_has_pending_changesets": true, "_changeset_edit_modes": [ "string" @@ -16,10 +16,6 @@ "string" ], "_changeset_edit_modes_by_attribute": {}, - "operation": "createEntity", - "activity_id": "01F130Q52Q6MWSNS8N2AVXV4JN", - "activity_type": "EntityUpdated", - "trigger_entity": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "event_attachments": [ { "entity_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", diff --git a/static/events/CustomerRequestSubmitted.schema.json b/static/events/CustomerRequestSubmitted.schema.json index f7ced691..f17ddca8 100644 --- a/static/events/CustomerRequestSubmitted.schema.json +++ b/static/events/CustomerRequestSubmitted.schema.json @@ -27,7 +27,11 @@ }, "_event_source": { "type": "string", - "description": "Source that triggered the event" + "description": "Source that triggered the event", + "enum": [ + "automation" + ], + "example": "automation" }, "_ack_id": { "type": "string", @@ -37,12 +41,16 @@ "_trigger_source_type": { "type": "string", "description": "The type of system that triggered the event. Examples: api, automation, operation, portal_user", - "example": "api" + "enum": [ + "automation" + ], + "example": "automation" }, "_trigger_source": { "type": "string", "description": "Identifier of the specific trigger source. Examples: user ID, automation execution ID, activity ID, portal user email", - "example": "api_123456" + "minLength": 1, + "example": "execution-id/action-id" }, "_has_pending_changesets": { "type": "boolean", @@ -76,28 +84,6 @@ }, "description": "Per-key edit mode map; keys are a subset of _changeset_attributes (attribute names for entity changesets, changeset IDs for meter-reading changesets) — only those whose pending changeset has an \"external\" or \"approval\" mode. A \"direct\"/unrecognised mode is omitted here, though the key still appears in _changeset_attributes. Enables fine-grained webhook conditions such as `_changeset_edit_modes_by_attribute.billing_cycle equals approval`" }, - "operation": { - "type": "string", - "enum": [ - "createEntity" - ], - "description": "The entity operation type that triggered this event" - }, - "activity_id": { - "type": "string", - "description": "Activity ID associated with this operation (ULID)", - "example": "01F130Q52Q6MWSNS8N2AVXV4JN" - }, - "activity_type": { - "type": "string", - "description": "Activity type (e.g., EntityCreated, EntityUpdated)", - "example": "EntityUpdated" - }, - "trigger_entity": { - "type": "string", - "format": "uuid", - "description": "Entity ID of the entity that triggered this event" - }, "event_attachments": { "type": "array", "items": { @@ -183,7 +169,7 @@ ], "additionalProperties": false }, - "description": "All files related to the ticket. A single submission can carry many documents across several upload blocks. Consumers identify a given upload via `relation_tags` / `_tags` and scope the use case via the `_purpose` taxonomy IDs on the ticket node (IDs only — resolving them to names is up to the consumer)." + "description": "All files related to the ticket, empty when the request carries none. A single submission can carry many documents across several upload blocks. Consumers identify a given upload via `relation_tags` / `_tags` and scope the use case via the `_purpose` taxonomy IDs on the ticket node (IDs only — resolving them to names is up to the consumer)." }, "ticket": { "$schema": "https://json-schema.org/draft/2020-12/schema", @@ -1424,8 +1410,10 @@ "_event_version", "_event_source", "_ack_id", - "operation", - "trigger_entity" + "event_attachments", + "ticket", + "_trigger_source_type", + "_trigger_source" ], "additionalProperties": true, "definitions": {