--- url: /learn/fundamentals.md --- # Fundamentals ## Everything is a Resource Hantera is an extendable platform, and there's one core idea that ties it all together: **everything is a resource**. Every "thing" in Hantera — whether it's an actor, a registry entry, a user, or an app — is addressable by a URI. No matter what you're working with, it shares this aspect. These resources form a hierarchical tree, similar to a file system on your computer. You'll see this tree reflected in the URIs that appear in API endpoints, manifest files, and permission paths: ``` actors/order/b3cf0d63-6ad7-4923-8060-90fb6935954d registry/settings/shipping iam/users/jane@example.com components/my-component ``` If you're familiar with Unix, you'll recognize the philosophy. Unix was built on the idea that *everything is a file*. Your printer, your network socket, your process — they all show up as files. That's also why your USB-connected printer shows up as a file in Linux, when it would probably be more natural to think of it as a device on a serial bus. Hantera takes a similar but more intentional approach. Instead of squeezing everything into a single generic abstraction, different types of resources exist for very specific purposes. Each resource type understands what it represents and what you can do with it. It's a more powerful abstraction — which makes sense, given that it's been over 50 years since Unix was created. ## Resource Classes Resources are categorized into **resource classes**. Each resource class is its own subsystem with its own API, built to solve a specific category of problems. You can think of them as the top-level folders in the resource tree. Authorization can be tailored per resource, giving you fine-grained control over who can access what. ## Resources and Apps This is where Hantera differs from many other platforms. On most platforms, an app is the *only* way to extend the system. Think of it like an iPhone — installing an app is the only way to add an icon to the home screen or make the phone do anything beyond what's built into iOS. Hantera is more like a Linux or Windows operating system. You can create and manage resources directly, without needing a packaged installer. There's nothing an app can do that you can't already do by managing resources individually. And in some cases, that's the better approach. That said, when you want to distribute multiple resources as a single atomic unit — for reproducibility, reuse, and convenience — [apps](/resources/apps/) are very useful. An app is itself a resource, and it packages other resources together into a distributable unit that can be installed into any Hantera system. But apps are just one way to work with Hantera, not the only way. ## Explore Resource Classes * [**/resources/actors**](/resources/actors/) — Actors provide a way to model business entities in a powerful and scalable way. * [**/resources/apps**](/resources/apps/) — Apps are packages that can extend Hantera by providing custom resources. * [**/resources/components**](/resources/components/) — Components are Filtrera scripts that can be used by other resources to provide custom functionality. * [**/resources/files**](/resources/files/) — Naturally manage large binary files, accessible from everywhere inside and outside Hantera. * [**/resources/graph**](/resources/graph/) — The graph provides a graph query interface for querying data. The graph is read-only and updated by other resources, such as actors. * [**/resources/iam**](/resources/iam/) — Identity and Access Management provides management of users and clients and their access. * [**/resources/me**](/resources/me) — The "Me" resource provides access to the current authenticated user's profile and security features. * [**/resources/ingresses**](/resources/ingresses/) — Ingresses enables receiving and process data from external systems. * [**/resources/job-definitions**](/resources/job-definitions) — Job definitions define units of work that can be scheduled now or in the future. * [**/resources/jobs**](/resources/jobs/) — Jobs are executions of job-definitions. They can be scheduled in the future. * [**/resources/registry**](/resources/registry/) — The registry is a key/value dynamic model that is used for global configuration. * [**/resources/rules**](/resources/rules/) — Rules provide a framework for implementing custom business rules. * [**/resources/sendings**](/resources/sendings) — Centralized system for managing communication such as e-mails. --- --- url: /learn/authentication.md --- # Authentication OAuth authentication is a robust method for securing API requests and managing access tokens. Hantera supports multiple OAuth flows, including the code authorization flow, client credentials, and implicit code grant with PKCE. ## Base Path for OAuth Requests All OAuth-related requests should be directed to the following base path: ``` /oauth ``` ## OAuth Flows ### Code Authorization Flow The code authorization flow is the most commonly used OAuth flow, suitable for web applications where the client can securely store secrets. 1. **Redirect to Authorization Endpoint** Redirect the user to the authorization endpoint to obtain an authorization code: ``` GET /oauth/authorize Host: your-hantera-domain.com ``` **Parameters:** * `response_type`: Must be set to `code` * `client_id`: Your application's client ID * `redirect_uri`: The URL to redirect the user after authorization * `scope`: The requested permissions (see [Scopes](#scopes)) * `state`: A unique string to maintain state between the request and callback Example: ``` GET /oauth/authorize?response_type=code&client_id=your_client_id&redirect_uri=https://yourapp.com/callback&scope=actors/order:* graph:* me:*&state=unique_state_string ``` 2. **Handle Redirect** Upon successful authorization, the user will be redirected to your specified `redirect_uri` with an authorization code and the state parameter: ``` https://yourapp.com/callback?code=authorization_code&state=unique_state_string ``` 3. **Exchange Authorization Code for Tokens** Make a POST request to the token endpoint to exchange the authorization code for an access token and refresh token: ``` POST /oauth/token Host: your-hantera-domain.com Content-Type: application/x-www-form-urlencoded ``` **Parameters:** * `grant_type`: Must be set to `authorization_code` * `code`: The authorization code received in the callback * `redirect_uri`: The same redirect URI used in the authorization request * `client_id`: Your application's client ID * `client_secret`: Your application's client secret Example: ``` POST /oauth/token Host: your-hantera-domain.com Content-Type: application/x-www-form-urlencoded grant_type=authorization_code&code=authorization_code&redirect_uri=https://yourapp.com/callback&client_id=your_client_id&client_secret=your_client_secret ``` ### Client Credentials Flow The client credentials flow is used for machine-to-machine authentication, where no user interaction is required. 1. **Request Tokens** Make a POST request to the token endpoint: ``` POST /oauth/token Host: your-hantera-domain.com Content-Type: application/x-www-form-urlencoded ``` **Parameters:** * `grant_type`: Must be set to `client_credentials` * `client_id`: Your application's client ID * `client_secret`: Your application's client secret * `scope`: The requested permissions (see [Scopes](#scopes)) Example: ``` POST /oauth/token Host: your-hantera-domain.com Content-Type: application/x-www-form-urlencoded grant_type=client_credentials&client_id=your_client_id&client_secret=your_client_secret&scope=actors/order:* graph:* me:* ``` ### Implicit Code Grant with PKCE The implicit code grant is available, but mandates the use of PKCE (Proof Key for Code Exchange). This option is useful for clients that can not be trusted with a client secret, such as a PWA or phone app. 1. **Generate a Code Verifier and Challenge** Generate a secure random string as the code verifier and then create a code challenge using a SHA-256 hash of the code verifier. 2. **Redirect to Authorization Endpoint** Redirect the user to the authorization endpoint: ``` GET /oauth/authorize Host: your-hantera-domain.com ``` **Parameters:** * `response_type`: Must be set to `code` * `client_id`: Your application's client ID * `redirect_uri`: The URL to redirect the user after authorization * `scope`: The requested permissions (see [Scopes](#scopes)) * `state`: A unique string to maintain state between the request and callback * `code_challenge`: The code challenge generated from the code verifier * `code_challenge_method`: Must be set to `S256` Example: ``` GET /oauth/authorize?response_type=code&client_id=your_client_id&redirect_uri=https://yourapp.com/callback&scope=actors/order:* graph:* me:*&state=unique_state_string&code_challenge=code_challenge_string&code_challenge_method=S256 ``` 3. **Handle Redirect** Upon successful authorization, the user will be redirected to your specified `redirect_uri` with an authorization code and the state parameter: ``` https://yourapp.com/callback?code=authorization_code&state=unique_state_string ``` 4. **Exchange Authorization Code for Tokens** Make a POST request to the token endpoint to exchange the authorization code for an access token, including the code verifier: ``` POST /oauth/token Host: your-hantera-domain.com Content-Type: application/x-www-form-urlencoded ``` **Parameters:** * `grant_type`: Must be set to `authorization_code` * `code`: The authorization code received in the callback * `redirect_uri`: The same redirect URI used in the authorization request * `client_id`: Your application's client ID * `code_verifier`: The original code verifier used to generate the code challenge Example: ``` POST /oauth/token Host: your-hantera-domain.com Content-Type: application/x-www-form-urlencoded grant_type=authorization_code&code=authorization_code&redirect_uri=https://yourapp.com/callback&client_id=your_client_id&code_verifier=code_verifier_string ``` ## Token Lifecycle * **Access Token:** By default, an access token is valid for 60 minutes. * **Refresh Token:** By default, a refresh token is valid for 30 days. ### Session Management A session is created upon a completed authorization and contains both the access and refresh tokens. The first 16 bytes of a token are a UUID that can be used to identify the session. ### Revoking Sessions To revoke a session and disable potentially leaked tokens, use the session revocation endpoint. ``` POST /oauth/revoke Host: your-hantera-domain.com Content-Type: application/x-www-form-urlencoded ``` **Parameters:** * `token`: The token to be revoked (either access or refresh token) * `token_type_hint`: A hint about the type of the token being revoked (e.g., `access_token` or `refresh_token`) Example: ``` POST /oauth/revoke Host: your-hantera-domain.com Content-Type: application/x-www-form-urlencoded token=token_to_be_revoked&token_type_hint=access_token ``` ## Personal Access Tokens (PAT) Personal Access Tokens (PAT) provide a way for users to generate long-living access tokens, with a maximum lifespan of one year. These tokens do not include a refresh token but can be renewed through the `/me` API. ### Creating a Personal Access Token To create a PAT, use the Portal or the `/me` API endpoint: ``` POST /me/pat Host: your-hantera-domain.com Content-Type: application/json Authorization: Bearer your_existing_token X-Client-Id: `` X-Client-Secret: `` ``` **Request Body:** ``` { "description": "Your Token Description", "scope": "me:* actors:*" "expiresAt": "2024-07-01T00:00:00Z" // Max 1 year in the future } ``` **Response Body:** ``` { "keyId": "ed755c9a-05fc-4594-844f-846ef7795336" "description": "Your Token Description", "issuedAt": "2024-01-01T15:32:34Z", "expiresAt": "2024-07-01T00:00:00Z", "scope": ["me:*", "actors:*"], "active": true, "accessToken": "..." } ``` ### Revoking a Personal Access Token To revoke a PAT, use the `/me` API endpoint: ``` DELETE /me/pat/`` Host: your-hantera-domain.com Content-Type: application/json Authorization: Bearer your_pat ``` ## Scopes Scopes in Hantera are made up of [Access Control Entries (ACEs)](/learn/access-control). These define the specific permissions granted to the access token. The format for scopes is typically: ``` resource/permission ``` For example: * `actors/order:*`: Grants all permissions on `order` actors. * `graph:*`: Grants all permissions on the `graph`. * `me:*`: Grants all permissions on the `me` resource. Multiple scopes can be requested by separating them with spaces: ``` actors/order:* graph:* me:* ``` ## Client Identity Management Client identities are created through the Identity and Access Management (IAM) API. This allows administrators to manage which applications can access the Hantera API and the specific permissions they are granted. ### Creating a Client Identity To create a client identity, use the IAM API to send a POST request with the necessary client details: ``` POST /iam/clients Host: your-hantera-domain.com Content-Type: application/json Authorization: Bearer your_admin_token ``` **Request Body:** ``` { "name": "Your Client Name", "redirect_uris": [ "https://yourapp.com/callback" ], "grant_types": [ "authorization_code", "client_credentials", "implicit" ] } ``` ### Restricting Allowed Redirect URLs Clients can restrict the allowed redirect URLs to enhance security. This is configured during client identity creation by specifying the `redirect_uris` parameter. Only the URLs listed in `redirect_uris` will be accepted during the authorization process. ## Conclusion Using OAuth authentication with Hantera ensures secure and controlled access to your resources. By following the steps outlined in this guide, you can implement OAuth flows, manage tokens, handle session revocation, create and manage client identities, and generate Personal Access Tokens effectively. For further details, refer to the Hantera API documentation. --- --- url: /learn/access-control.md --- # Access Control Once [authenticated](/learn/authentication), your session will have access to resources. The resources available to the session are determined by the user or client's permissions, combined with the scopes granted during sign-in (see [Authentication](/learn/authentication)). Access is structured in a cascading tree where each resource may have multiple permissions. ## Access Control Entry (ACE) Throughout this documentation, you will encounter the term *Access Control Entry* (ACE). An ACE consists of a resource path and optional permission. Here are some examples: ``` actors:* ``` * Authorizes full access to all actor types and actors. All permissions are granted. ``` actors/order:* ``` * Authorizes full access to all messages for all order actors. ``` actors/order:applyCommands ``` * Authorizes the `applyCommands` message for order actors. ``` actors/order/b3cf0d63-6ad7-4923-8060-90fb6935954d ``` * Authorizes all messages for the order actor with ID "b3cf0d63-6ad7-4923-8060-90fb6935954d". ``` actors/order/b3cf0d63-6ad7-4923-8060-90fb6935954d:applyCommands ``` * Authorizes the message type `applyCommands` for the order actor with ID "b3cf0d63-6ad7-4923-8060-90fb6935954d". ## Cascading Access Access is cascading, meaning any permission applied at a higher level is inherited by child resources. A resource access with no specific permissions implicitly grants all available permissions on that resource. ## Nested Permissions Some permissions can be nested. For example, `actors/order:applyCommands` authorizes the `applyCommands` message, but this message also supports nested permissions for each command. So `actors/order:applyCommands:setNotes` only authorizes the `setNotes` command, restricting all other commands. `actors/order:applyCommands` gives full access to the message including all commands. ## Roles and Access Control Lists (ACL) To simplify management among many users and clients, roles can be created and assigned. Each role has an Access Control List (ACL) containing multiple ACEs that will be granted to any identity with that role. Users and clients (identities) may also have directly applied ACEs. This is particularly useful for clients used for integration purposes where there is no overlap between multiple clients. ### Example Configuration An identity or role will generally have many ACEs to configure access. Here's an example configuration that allows an identity to create new orders and query for order `orderId`s and `orderNumber` in the graph: ``` actors/order:create graph/order:orderId graph/order:orderNumber ``` ## Attribute-Based Access Control (ABAC) For advanced scenarios, Attribute-Based Access Control (ABAC) can be used to grant access to data. ABAC is useful, for example, when you have users who should only see data for a specific market or country, or a customer account that should only access its own data. Attributes can be tied to ACEs, making the resource access only viable if the identity or role has a matching value of those attributes with the actor or graph node. The available attributes are per actor type, so refer to [Actor Types](/resources/actors/) for detailed information on available access attributes. The graph also supports attribute access, and generally, all fields in the graph are available. ### Practical Example Let's say you have a user who should only see orders in channel "EU". 1. Add the access attribute `channelKey="EU"` to the user. 2. Add the following ACL to the user: ``` channelKey@actors/order:* channelKey@graph/order:* channelKey@graph/delivery:* channelKey@graph/orderLine:* channelKey@graph/invoice:* channelKey@graph/payment:* channelKey@graph/orderJournalEntry:* ``` --- --- url: /learn/dimensions.md --- # Dimensions Dimensions refer to the two dimensions Locale and Channel. These two dimensions are used throughout the system for various globalization purposes, business rules and processes. While they have quite different purposes, they work are identical in the way they behave. ## Channels Channels are used for separating flows into different sales channels. For example a retail process can be quite different from a wholesale process. Every order have a `channelKey` that can be be used to indicate which channel the ## Locales Locales are used for internationalization. A locale generally defines a language and a region used for formatting. `Orders` specify a `localeKey` which is used to identify which language and formatting the customer expects. ## Dimensions in the Graph Aside from the examples above, fields in the [Graph](/resources/graph/) can use dimensions to provide different values for each. This is great when modeling multi-language entities such as products or other assets. ## Configuring Dimensions In the examples above, it's fine to start using a `localeKey` or `channelKey` before it's configured in the system. But in order to make them useful (and to make it possible to filter by a dimension in the graph), you should configure them. This can be done through the Registry using a manifest. Here's an example: ```yaml uri: /registry/channels/b2c spec: value: {} --- uri: /registry/locale/sweden spec: value: language: sv formatting: sv-SE ``` It's fine to add any additional properties you may find useful. This examples shows the bare minimum to make dimensions work with the Graph. --- --- url: /learn/currencies.md --- # Currencies In Hantera, currency is usually a property of a **bounded context** — not a dimension of individual values. An order is a single-currency context. Every value attached to it (delivery shipping prices, order line unit prices, returns, calculated discounts, the invoice it eventually produces, the payment that captures it) is in the same currency. This is a different shape from channel and locale (see [Dimensions](/learn/dimensions)). Channels and locales are dimensions: a single product carries many translated names, one per locale. Currencies do not work that way. A monetary value has exactly one currency — the currency of the context it lives in. There is no per-locale or per-channel variant of a price stored alongside the value. ## Why bounded contexts and not dimensions? The bounded-context model is what keeps the math trivial: * **No mixed-currency arithmetic anywhere in the platform.** When `orderLineTotal + shippingTotal` runs, both sides are guaranteed to be in the order's currency. There is no possibility of accidentally summing a SEK total with a EUR total. * **No need to pin a rate to long-lived entities.** An order can sit pending for months. If we recorded an exchange rate at the moment the order was created, a refund issued months later at a drifted rate would leave a net order total ≠ 0 from rate movement alone. Hantera sidesteps that entirely by not pinning a rate to orders at all. * **No implicit conversions.** The platform never auto-converts a value from one currency to another. If you want a value in a different currency, you do the conversion explicitly, where the choice of rate is part of the question you're asking. Cross-currency aggregation isn't a problem the platform tries to solve. It's a problem **reporting** solves — see [Reporting across currencies](#reporting-across-currencies) below. ## Configuring currencies Currencies are configured in the registry under the `currencies/` namespace. Each entry is keyed by a currency code, typically (but not necessarily) an ISO 4217 code. ```yaml uri: /registry/currencies/SEK spec: value: label: 'Swedish Krona' decimals: 2 exchangeRate: 1.0 --- uri: /registry/currencies/EUR spec: value: label: 'Euro' decimals: 2 exchangeRate: 11.5 --- uri: /registry/currencies/USD spec: value: label: 'United States Dollar' decimals: 2 exchangeRate: 10.6 ``` ### Custom currencies Codes are arbitrary identifiers (`^[A-Za-z0-9_]{1,16}$`). You're not limited to ISO 4217 — define whatever your business needs: ```yaml uri: /registry/currencies/BTC spec: value: label: 'Bitcoin' decimals: 8 exchangeRate: 800000.0 --- uri: /registry/currencies/LOYALTY_POINTS spec: value: label: 'Loyalty Points' decimals: 0 exchangeRate: 0.01 ``` ### Fields | Field | Type | Required | Description | |---|---|---|---| | `label` | `string` | yes | Display name | | `decimals` | `number` | no | Display precision; defaults to `Intl` behavior or `2` | | `exchangeRate` | `number` | no | Current conversion factor; defaults to `1` | There is no `symbol` field. Symbol placement and formatting are presentation concerns and are derived from the browser's `Intl.NumberFormat` for ISO codes. ## Exchange rates The `exchangeRate` is a **normalized scalar**, not a pair. It expresses the value of one unit of the currency in the implicit base — so to convert *to base* you **multiply**: > `amountInBase = amount * rate(currency)` > > The rate from currency A to currency B is `rate(A) / rate(B)`. There is no system-defined base currency. Whatever currency happens to be at rate `1` is the implicit base — but this is incidental. What matters is that the rates on currency entries are mutually consistent. In the sample above, `EUR = 11.5` and `SEK = 1.0` means *one euro is worth 11.5 kronor*; SEK is the implicit base purely because it's the one sitting at `1`. Hantera uses `exchangeRate` for exactly two purposes: 1. **Snapshotting onto invoices** at invoice creation time. This is the only place the platform itself reads the registry rate. 2. As a value that **reports may consume** when they need to normalize across currencies. The platform does not consult the rate during order processing, promotion calculation, payment processing, or any other in-flow operation. Those flows stay inside their bounded context's currency. ### Updating rates Rates can be updated: * Manually in the **Settings → Currencies** panel in the portal. * Via the registry API, `PUT /registry/currencies/`. * By a custom job that periodically pulls from a feed (ECB, Open Exchange Rates, etc.). Hantera stores only the current rate. It does not maintain a time-series history of rates. ## Why orders don't store a rate This deserves explicit mention because it's where most ERPs go wrong. An order is a long-lived entity. It can be created today, modified next week, partially fulfilled next month, and refunded six months later. If we recorded an exchange rate when the order was created, that rate would represent "the conversion factor at order time" — a value that becomes increasingly stale and increasingly meaningless as the order ages. Worse, if a refund were issued months later and converted at the *drifted* rate, the order's net total in the normalized currency wouldn't sum to zero — even though, in the order's actual currency, sales and refunds cancel out cleanly. The "net non-zero" would be entirely a rate-drift artifact, not a real business event. Hantera avoids this by not pinning a rate to orders at all. Inside the order's currency, math is exact. Cross-currency analysis is something a report decides to do, with a rate the report explicitly chooses. ## Why invoices snapshot the rate Invoices are the boundary where Hantera commits to an accounting-relevant value. They feed into financial systems, ledgers, and tax reporting — all of which need a pinned, point-in-time rate to work correctly. When an invoice is created, the order's currency rate is read from the registry once and **snapshotted** onto the invoice as a system field, `exchangeRate`. Once written, it never changes. Even if the registry rate is updated tomorrow, the invoice's `exchangeRate` continues to reflect the rate that was in effect at invoicing time. ```graphql invoice { invoiceId invoiceTotal # in the order's currency exchangeRate # snapshot of the registry rate at invoice creation } ``` If the source currency wasn't registered, or had no `exchangeRate`, the snapshot defaults to `1`. ## Reporting across currencies When a report needs to compare or aggregate amounts across currencies, the conversion happens **in the report**, not in the platform. The report decides: * **Which rate** to use (current registry rate, period-end rate from an external feed, the snapshot on each invoice, an average, etc.). * **How to apply it** (per row, per period, per aggregation bucket). * **What "normalized" means** for that report. The platform deliberately does not pre-calculate normalized values, because there is no single right answer. A daily sales dashboard might use the current registry rate. A year-end financial report should use the invoice snapshots. A finance-month-end revaluation might use a date-specific feed. Different reports, different choices. A simple Filtrera example of a report computing a normalized sales total against the current registry rates: ```filtrera import 'iterators' // Pattern-match the registry value so missing entries (or entries without // an exchangeRate) safely fall back to 1. let rateOf (currency: text) |> (registry->$'currencies/{currency}') match ({ exchangeRate: number }) |> exchangeRate |> 1 // Implicit base is whatever rate = 1 in the registry; multiplying converts to it. let normalize (amount: number, currency: text) |> amount * rateOf(currency) from orders select o => (o, normalize(o.orderTotal, o.currencyCode)) ``` For invoiced amounts, prefer the invoice's own `exchangeRate` over the current registry rate. The invoice snapshot is what was committed to accounting. ## Portal: managing currencies The **Settings → Currencies** panel in the portal lets users: * Add ISO 4217 currencies from a built-in list (with prefilled label/decimals). * Add custom codes for non-standard currencies. * Edit labels, decimals, and exchange rates inline. * Delete currencies they no longer need. Permissions follow the registry pattern: `registry/currencies:read` and `registry/currencies:write`. ## Portal: capturing values in multiple currencies Some configuration is currency-scoped — for example, a price list that needs to express the same conceptual price in several currencies. For these cases, use the global `` component: ```vue ``` The component loads the registered currencies from the registry and presents one row per currency in the value, with an "Add currency" picker for additional ones. Each row's value is independent — there is no automatic conversion between rows. Note: this component is for configuration data that genuinely has a value per currency. It is **not** a way of storing a converted version of a single value. Monetary values on entities (orders, invoices, payments) carry their currency as part of the entity's bounded context, not as a key in a per-currency map. --- --- url: /learn/websocket.md description: >- Connect to Hantera's real-time WebSocket endpoint for event streaming and live queries --- # WebSocket Hantera exposes a unified WebSocket endpoint for real-time communication. It supports two capabilities: * **Event Streaming** — push notifications when things happen in your system (jobs, actor state changes) * **Live Queries** *(experimental)* — maintain a reactive, server-side query result that automatically updates as data changes ::: warning **Preview API**: The WebSocket API is currently in preview and subject to change before final release. ::: ## Endpoint ``` wss://{hostname}/ws ``` Replace `{hostname}` with your tenant hostname (e.g. `tenant.core.ams.hantera.cloud`). ## Connection Lifecycle 1. **Open WebSocket connection** ```typescript const ws = new WebSocket('wss://{hostname}/ws') ``` 2. **Authenticate** The first message must be an `auth` message containing your access token: ```json { "type": "auth", "token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..." } ``` Send the token without the `Bearer ` prefix. Authentication must complete within 10 seconds or the server closes the connection. 3. **Receive confirmation** On success: ```json { "type": "authenticated" } ``` 4. **Subscribe to events or create live queries** Once authenticated you can send `subscribeEvents` and `createLiveQuery` messages (see below). 5. **Respond to keep-alive pings** The server sends `ping` every 30 seconds. Respond with `pong` within 30 seconds: ```json { "type": "pong" } ``` ## Event Streaming Subscribe to server-side events using the `subscribeEvents` message. ### Subscribe ```json { "type": "subscribeEvents", "requestId": "req-1", "subscriptions": [ { "id": "my-jobs", "path": "jobs", "events": ["jobScheduled", "jobStarted", "jobCompleted", "jobFailed"] } ] } ``` The server confirms with `subscribedEvents`: ```json { "type": "subscribedEvents", "requestId": "req-1", "subscriptions": [ { "id": "my-jobs", "path": "jobs", "events": ["jobScheduled", "jobStarted", "jobCompleted", "jobFailed"] } ] } ``` ### Receiving Events Events arrive as `event` messages: ```json { "type": "event", "subscriptionIds": ["my-jobs"], "eventType": "jobCompleted", "path": "jobs", "data": { "jobId": "550e8400-e29b-41d4-a716-446655440000", "jobDefinitionId": "sync-inventory", "finishedAt": "2025-12-07T21:45:05.000Z", "elapsedMs": 4000.5 }, "timestamp": "2025-12-07T21:45:05.000Z" } ``` ### Unsubscribe ```json { "type": "unsubscribeEvents", "ids": ["my-jobs"] } ``` ### Supported Paths and Events #### Jobs | Path | Description | |-----------------|---------------------------| | `jobs` | All job lifecycle events | | `jobs/{jobId}` | Events for a specific job | | Event | Description | |----------------|------------------------------| | `jobScheduled` | Job created in pending state | | `jobStarted` | Job execution began | | `jobCompleted` | Job finished successfully | | `jobFailed` | Job execution failed | #### Job Statistics | Path | Description | |-------------------------------------|------------------------------------| | `job-definitions` | Statistics for all job definitions | | `job-definitions/{jobDefinitionId}` | Statistics for a specific job type | | Event | Description | |-----------------|----------------------------------------------| | `jobStatistics` | Live bucket update with aggregated counters | #### Actors | Path | Description | |---------------------------------|-------------------------------------------| | `actors` | All actor checkpoint events | | `actors/{actorType}` | Checkpoints for a specific actor type | | `actors/{actorType}/{actorId}` | Checkpoints for a specific actor instance | Actor types: `orders`, `payments`, `skus`, and [custom actors](/resources/actors/custom/). | Event | Description | |--------------------|-----------------------------------| | `actorCheckpoint` | Checkpoint created in actor | ::: info The checkpoint event does not include mutation details. Query the actor's state via the [Graph API](/resources/graph/) to see what changed. ::: ### Backpressure Event streaming uses best-effort delivery. When your client consumes events slower than they are produced, the server queues events and drops old ones when the queue fills. A `warning` message with code `QUEUE_OVERFLOW` is sent to notify you: ```json { "type": "warning", "code": "QUEUE_OVERFLOW", "message": "5 events dropped for subscription 'my-jobs' due to slow consumption", "subscriptionId": "my-jobs" } ``` When you receive an overflow warning, re-sync your state from the [Graph API](/resources/graph/) to recover any missed changes. ::: warning Dropped events are permanently lost. For workflows requiring guaranteed delivery, use [Rules](/resources/rules/) with webhooks instead. ::: ## Live Queries *(Experimental)* ::: warning **Experimental**: Live Queries are an experimental feature. Message shapes and behaviour may change. ::: A live query runs a [Graph API](/resources/graph/) query server-side and sends you the initial results plus incremental updates as data changes. ### Create a Live Query Send a `createLiveQuery` message with your graph query nested in a `query` field: ```json { "type": "createLiveQuery", "id": "lq-orders", "query": { "edge": "orders", "filter": "status == 'processing'", "orderBy": "createdAt desc" } } ``` The server responds with `liveQueryCreated`: ```json { "type": "liveQueryCreated", "id": "lq-orders", "totalCount": 42, "capped": false } ``` `capped` is `true` when the result set exceeds the maximum record limit (default 1 000). The query will only track the capped set. ### Receiving Initial Data After creation the server streams the initial result set as one or more `liveQueryData` messages: ```json { "type": "liveQueryData", "id": "lq-orders", "data": [ { "id": "...", "status": "processing", ... }, ... ], "hasMore": true, "capped": false } ``` ```json { "type": "liveQueryData", "id": "lq-orders", "data": [ ... ], "hasMore": false, "capped": false } ``` When `hasMore` is `false`, the initial load is complete. ### Incremental Updates As underlying data changes, the server sends targeted update messages: ```json { "type": "liveQueryAddedNode", "id": "lq-orders", "nodeId": "660e8400-...", "data": { ... } } { "type": "liveQueryUpdatedNode", "id": "lq-orders", "nodeId": "550e8400-...", "data": { ... } } { "type": "liveQueryRemovedNode", "id": "lq-orders", "nodeId": "550e8400-..." } ``` ### Destroy a Live Query When you no longer need a live query, send `destroyLiveQuery` to release server-side resources: ```json { "type": "destroyLiveQuery", "id": "lq-orders" } ``` The server responds with `liveQueryDestroyed`: ```json { "type": "liveQueryDestroyed", "id": "lq-orders" } ``` ## Error Handling Errors are returned as `error` messages: ```json { "type": "error", "code": "INVALID_PATH", "message": "Unknown resource path: invalid/path", "requestId": "req-5" } ``` See the [WebSocket API Reference](/api/websocket) for the full list of error codes. ## Complete Example ```typescript const ws = new WebSocket('wss://core.your-tenant.hantera.cloud/ws') ws.onopen = () => { ws.send(JSON.stringify({ type: 'auth', token: 'your-access-token' })) } ws.onmessage = (event) => { const msg = JSON.parse(event.data) switch (msg.type) { case 'authenticated': // Subscribe to job events ws.send(JSON.stringify({ type: 'subscribeEvents', subscriptions: [{ id: 'jobs', path: 'jobs', events: ['jobScheduled', 'jobStarted', 'jobCompleted', 'jobFailed'] }] })) break case 'event': console.log(`[${msg.eventType}]`, msg.data) break case 'ping': ws.send(JSON.stringify({ type: 'pong' })) break case 'error': console.error(msg.code, msg.message) break } } ``` ## Best Practices * **Reconnect on disconnect** — implement automatic reconnection with exponential backoff. Re-authenticate and re-subscribe after reconnecting. * **Subscribe selectively** — only subscribe to paths and events you need to reduce message volume. * **Handle overflow warnings** — when you receive `QUEUE_OVERFLOW`, re-sync from the Graph API to recover missed changes. * **Destroy live queries** — call `destroyLiveQuery` when a query is no longer needed to free server memory. * **Use request correlation** — include `requestId` in messages to correlate responses. --- --- url: /learn/hantera-cli.md --- # Hantera CLI Hantera CLI is provided for developers and Hantera system maintainers to simplify working with Hantera from the command line as well as authoring apps. You can install the app using npm: ```bash > npm install -g @hantera/cli ``` Once installed, you can use it's alias `h_`: ```bash > h_ --help ``` --- --- url: /learn/hantera-development-studio.md --- # Hantera Development Studio Hantera Development Studio is an extension that adds Hantera superpowers to VSCode. While in early technical preview, you can find more information and support on our Discord server Downloads are available on GitHub: https://github.com/hantera-io/vscode/releases --- --- url: /learn/guides.md --- # Guides --- --- url: /resources/actors.md --- # Actors > Looking for a flat reference of every message and command across all actors? See **[Messages](/api/messages)** and **[Commands](/api/commands)** in the API Reference. Business entities in Hantera are modeled as actors. Actors can be explained as individual processes that perform work based on received messages. There are different types of actors responding to different types of messages. For a complete reference of each actor's messages and functionality, refer to the [Actor Types](/resources/actors/) section. The actor API provides a unified way to interact with any type of actor using a standardized API. ## Identifiers Each actor may provide multiple identifiers. Every actor has a UUID identifier, but may provide additional IDs as well. An example of this is the Order actor, which along with its `orderId`, can also be accessed using its natural `orderNumber` key: These paths may point to the same actor instance: ``` /resources/resources/actors/order/6e558257-f67c-45e5-b013-1f36fbb4c944 /resources/resources/actors/order/ORD12345 ``` There are also some actors that provide virtual identifiers. For example, the Order actor provides the virtual actor `new` to create a new order that will be assigned unique identifiers automatically. The newly generated identifiers will be included in the response. ## Sending Messages A single transaction may include multiple messages sent to the same actor, although each message is processed individually in the order that they appear. See the full [Send message(s) to actor](/api/http/post-resources-actors-%7Btype%7D-%7BexternalId%7D.html) API reference for details, examples, and response schemas. ## Previewing A powerful feature of actors is the *preview mode*. Messages sent as preview will all be performed in a transient transaction and will not be stored. This allows for testing messages and seeing the outcome. A common use case is to preview what an order will look like after a certain change is made. For more detailed examples, refer to the [Actor Types](/resources/actors/) section. For the sake of the API, using preview is as simple as adding the `?preview` querystring to the actor's message endpoint: ```http POST /resources/actors/order/new?preview Authorization: Bearer Content-Type: application/json [{ "type": "create", "body": { "currencyCode": "EUR", "taxIncluded": true, "commands": [{ "type": "createDelivery", "deliveryId": "65812410-bd14-4d6d-ab79-374789a976c1", "shippingPrice": 100 },{ "type": "createOrderLine", "deliveryId": "65812410-bd14-4d6d-ab79-374789a976c1", "productNumber": "P1", "quantity": 1, "unitPrice": 90, "taxFactor": 0.25 } ] } }] ``` ## Commands Many actors utilizes commands to execute operations in a single transaction. Commonly the message `applyCommands` is used, but other message types may support commands as well. Additionally, rules within Hantera can emit commands from some hooks to automate mutations based on specific events. Not every mutation is available as a command. Some processes must be done using a single message. ### Batch Processing of Commands Commands are always processed in a single batch. This means that all commands submitted together are executed as one atomic operation. If any command within the batch fails, the entire batch is rejected, and no changes are applied to the system. This all-or-nothing approach ensures data integrity and consistency across the platform. This is different from messages that are processed in sequence. If a single message fails, the others will still be processed even if they are sent in the same request. Commands are used for simple mutations within the actor and will never have side-effects outside the current actor. Messages on the other hand, may cause side-effects in other actors. ## Lifecycle Hooks Rules can react to actor lifecycle events through [Actor Lifecycle Hooks](/resources/actors/lifecycle-hooks). Hooks fire at specific points during an actor's lifecycle — before/after creation or deletion, when commands are applied, and when journal entries are written — allowing you to validate operations or trigger automations. [See all actor lifecycle hooks →](/resources/actors/lifecycle-hooks) ## Access Control The ACE for actors looks like this: ``` actors/[/]: ``` Note that the ID used for authorization must be the global UUID for the actor. \* For [custom actors](/resources/actors/custom/), `typeKey` may be specified to only access a specific type. --- --- url: /resources/actors/order.md --- # Order Actor The Order Actor is the most central actor in Hantera, being an Order Management System. It provides access to managing orders, deliveries, order lines, invoices and order journals. It supports a dynamic data model with fields that can be added at will. The Order Actor is a core component of Hantera. As the central actor within the platform, it provides the functionality to manage various aspects of an order's lifecycle, including orders, deliveries, order lines, invoices, and order journals. Designed with flexibility in mind, the Order Actor leverages a dynamic data model that allows for easy customization. This means fields and attributes can be added or modified according to the specific needs of your business processes, ensuring a highly adaptable and scalable solution for order management. By utilizing the Order Actor, developers can seamlessly interact with Hantera to handle the entire order flow, from creation to fulfillment and beyond. ## Commands The Order Actor utilizes commands to execute operations on orders, deliveries, order lines, invoices etc. Commands can be sent using the [`applyCommands`](/resources/actors/order/messages/apply-commands), and may also be included in the initial [`create`](/resources/actors/order/messages/create) message to the Order Actor. Rules within Hantera can emit order commands from order hooks to automate processes based on specific events. For a full reference of all available commands, go to [Order Commands](/resources/actors/order/commands/). ## Rule Hooks Related Rule Hooks are: ## Examples --- --- url: /resources/actors/order/discounts.md --- # Order Discounts Discounts are static monetary adjustments on an order. They represent fixed deductions — either a flat amount or a percentage — applied to the order total, individual order lines, or shipping fees. ::: tip For dynamic, component-based pricing logic that re-evaluates as orders change, see [Promotions](/resources/actors/order/promotions). ::: ## Discount Types ### Absolute A fixed monetary amount deducted from the target. The value is treated as including or excluding tax based on the order's `taxIncluded` setting. ```json { "type": "createStaticOrderDiscount", "value": 50, "description": "Loyalty reward" } ``` ### Percentage A proportional deduction expressed as a factor. For example, `0.1` means 10% off. ```json { "type": "createStaticOrderDiscount", "isPercentage": true, "value": 0.1, "description": "10% staff discount" } ``` ## Discount Scopes Static discounts can target different parts of an order: ### Order Discount Distributed proportionally across all order lines and shipping fees. Created with [`createStaticOrderDiscount`](/resources/actors/order/commands/create-static-order-discount). ### Order Line Discount Applied to a specific order line. Created with [`createStaticOrderLineDiscount`](/resources/actors/order/commands/create-static-order-line-discount). ### Shipping Discount Applied to a delivery's shipping fee. Created with [`createStaticShippingDiscount`](/resources/actors/order/commands/create-static-shipping-discount). ## Managing Discounts ### Creating Use the appropriate command for the scope you need: ```json [{ "type": "applyCommands", "body": { "commands": [{ "type": "createStaticOrderDiscount", "value": 50, "description": "Coupon ABC123" }] } }] ``` An optional `discountId` can be provided. If omitted, one is generated automatically. ### Updating Change the value of an existing static discount with [`setStaticDiscountValue`](/resources/actors/order/commands/set-static-discount-value): ```json { "type": "setStaticDiscountValue", "discountId": "65812410-bd14-4d6d-ab79-374789a976c1", "value": 75 } ``` ### Metadata * [`setDiscountDescription`](/resources/actors/order/commands/set-discount-description) — update the customer-facing description * [`setDiscountDynamicFields`](/resources/actors/order/commands/set-discount-dynamic-fields) — store custom metadata ### Deleting Remove a discount with [`deleteDiscount`](/resources/actors/order/commands/delete-discount): ```json { "type": "deleteDiscount", "discountId": "65812410-bd14-4d6d-ab79-374789a976c1" } ``` ## Calculated Discounts When a discount is applied, the system calculates the actual monetary impact and distributes it across order lines and shipping as **CalculatedDiscounts**. These represent the resolved per-line or per-shipping amounts in the order's currency. You can query calculated discounts through the [Graph](/resources/graph/): * [`discount`](/resources/graph/nodes/discount) — the discount entity itself * [`calculatedDiscount`](/resources/graph/nodes/calculated-discount) — the resolved amounts per order line / shipping ## See Also * [Promotions](/resources/actors/order/promotions) — dynamic, component-based discount logic * [Order Commands](/resources/actors/order/commands/) — full command reference * [Order Actor](/resources/actors/order/) — order actor overview --- --- url: /resources/actors/order/promotions.md --- # Order Promotions Promotions are dynamic, component-based calculations attached to orders. Each promotion runs a [Filtrera](/resources/components/) script in a pure runtime and can produce **discount effects** (percentage or absolute adjustments) and **promotional messages** (customer-facing text). Promotions re-evaluate automatically whenever the order changes. ::: tip For simple fixed-value deductions that don't need calculation logic, see [Discounts](/resources/actors/order/discounts). ::: ## How Promotions Work When an order changes — items added, quantities updated, delivery methods changed — every promotion on that order is re-evaluated: 1. The promotion component executes with the current order state 2. The component returns discount effects and/or message effects 3. Discount effects are applied as calculated discounts on order lines and shipping 4. Message effects are stored on the promotion for display to the customer This happens automatically. No manual triggering is required. ```mermaid flowchart LR A[Order changes] --> B[Re-evaluate all promotions] B --> C{Component output} C -->|Discount effects| D[Apply to order lines / shipping] C -->|Message effects| E[Store on promotion] C -->|nothing| F[No effect] ``` ## Promotion Components Promotion logic is written in Filtrera using the **promotion runtime** — a pure, side-effect-free environment. Components use the `.hpromo` or `.hpr` file extension. ### The [`order`](/resources/components/runtimes/keywords/order) Symbol Every promotion component has access to the [`order`](/resources/components/runtimes/keywords/order) symbol, which represents the current state of the entire order including deliveries, order lines, and shipping. ### Discount Effects A promotion can return discount effects using three built-in helpers: #### [`percentage`](/resources/components/runtimes/keywords/percentage)`(target, rate)` Apply a percentage discount: ```filtrera // 10% off entire order from percentage(order, 10%) // 100% off shipping (free shipping) from percentage(target(e => e is Delivery), 100%) // 20% off specific product category from percentage(target(e => e is OrderLine and e.dynamic->'category' == 'electronics'), 20%) ``` #### [`absolute`](/resources/components/runtimes/keywords/absolute)`(target, amount)` Apply a fixed amount discount: ```filtrera // 50 off entire order from absolute(order, 50) // 10 off each qualifying line from absolute(target(e => e is OrderLine and e.quantity > 5), 10) ``` #### [`target`](/resources/components/runtimes/keywords/target)`(filter)` Select which parts of the order to discount: ```filtrera // Target deliveries target(e => e is Delivery) // Target specific order lines target(e => e is OrderLine and e.productNumber == 'SKU-123') ``` ### Returning Nothing If a promotion should have no effect for a given order state, return `nothing`: ```filtrera from order.total < 100 match true |> nothing false |> percentage(order, 5%) ``` ## Message Effects Beyond discounts, promotions can return **messages** — customer-facing text that communicates promotional status. Messages are useful for showing progress toward a threshold, confirming that a promotion is active, or upselling related offers. ### Basic Message A message effect is a record with `type = 'message'` and a `message` field: ```filtrera from { type = 'message' message = 'Free shipping applied!' } ``` ### Message Placeholders and Fields Messages support a **placeholder syntax** using `{key}` in the message text, paired with a `fields` record that provides the actual values: ```filtrera from { type = 'message' message = 'Add {remaining} more for free shipping!' fields = { remaining = threshold - order.total } } ``` This produces three stored values on the promotion: | Field | Value | Purpose | |-------|-------|---------| | `messageTemplate` | `"Add {remaining} more for free shipping!"` | The raw template with placeholders intact | | `messageFields` | `{ remaining: 150 }` | The structured field values | | `messageRendered` | `"Add 150 more for free shipping!"` | Server-rendered version with placeholders replaced | The placeholder approach gives consumers flexibility: * **Simple clients** can display `messageRendered` directly * **Rich clients** (e.g., storefronts) can use `messageTemplate` + `messageFields` to render the message with custom formatting — for example, styling the `{remaining}` value in bold or a different color ### Message Type Each message has a `messageType` that identifies the kind of message. This is useful for consumers to decide how to display or filter messages without comparing raw strings. You can set it explicitly: ```filtrera from { type = 'message' messageType = 'free-shipping-progress' message = 'Add {remaining} more for free shipping!' fields = { remaining = threshold - order.total } } ``` If `messageType` is omitted, one is **automatically generated** by hashing the message template. This means messages with the same template text will share the same type, while different templates get different types. ### Combining Discount and Message Effects A promotion component can return both discount effects and message effects as a tuple: ```filtrera // free-shipping-with-message.hpromo param threshold: number from order.total >= threshold match true |> from percentage(target(e => e is Delivery), 100%) from { type = 'message', message = 'You qualify for free shipping!' } false |> from { type = 'message' message = 'Add {remaining} more for free shipping!' fields = { remaining = threshold - order.total } } ``` When the order total meets the threshold, the customer gets free shipping **and** a confirmation message. Below the threshold, they see a progress message with the remaining amount — no discount applied. ## Promotion Groups and Combination Rules Every promotion belongs to a **promotion group** — a string that categorizes the promotion (e.g., `'shipping'`, `'loyalty'`, `'campaign'`). Groups control how promotions interact when multiple are active on the same order. ### `canOnlyCombineWith` Restrict a promotion to only work alongside specific groups. If any other group is active, this promotion is excluded: ```json { "type": "createPromotion", "componentId": "exclusive-deal", "promotionGroup": "exclusive", "canOnlyCombineWith": ["loyalty"], "description": "Exclusive Deal" } ``` This promotion only stays active if the only other active promotions belong to the `loyalty` group. ### `canNotCombineWith` Exclude a promotion when specific groups are active: ```json { "type": "createPromotion", "componentId": "free-shipping", "promotionGroup": "shipping", "canNotCombineWith": ["exclusive"], "description": "Free Shipping" } ``` This promotion is excluded if any promotion in the `exclusive` group is active. ### How Combination Rules Are Applied After all promotions evaluate, the system checks combination rules: 1. Each active promotion's rules are tested against other active promotions' groups 2. Promotions that violate their rules are marked as **inactive** (`isActive = false`) 3. Inactive promotions contribute no discount effects or messages 4. The order totals are calculated using only the surviving active promotions ## Creating Promotions Promotions are added to orders via commands, typically emitted from [rules](/resources/rules/). ### Via Component Reference The most common approach. The component is a deployed `.hpromo` resource: ```json { "type": "createPromotion", "componentId": "free-shipping", "promotionGroup": "shipping", "description": "Free Shipping on Orders Over 500", "parameters": { "threshold": "500" } } ``` See [`createPromotion`](/resources/actors/order/commands/create-promotion) command reference. ### Via Inline Source For dynamic or one-off promotions, embed the Filtrera source directly: ```json { "type": "createPromotionBySource", "source": "from percentage(order, 10%)", "promotionGroup": "campaign", "description": "10% Welcome Discount" } ``` See [`createPromotionBySource`](/resources/actors/order/commands/create-promotion-by-source) command reference. ## Common Pattern: Rule-Based Promotions The typical approach is to use a [rule](/resources/rules/) to add promotions to orders when certain conditions are met, then let the promotion component handle the ongoing calculation. ### Example: Time-Limited Free Shipping **1. Create a reusable promotion component:** ```filtrera // free-shipping.hpromo param threshold: number from order.total >= threshold match true |> from percentage(target(e => e is Delivery), 100%) from { type = 'message', message = 'Free shipping applied!' } false |> from { type = 'message' message = 'Spend {remaining} more for free shipping' fields = { remaining = threshold - order.total } } ``` **2. Create a rule to add the promotion:** ```filtrera // black-friday.hrule param input: OnOrderCreated from [{ effect = 'orderCommand' type = 'createPromotion' componentId = 'free-shipping' promotionGroup = 'shipping' description = 'Black Friday Free Shipping' parameters = { threshold = '500' } }] ``` **3. Deploy via manifest with activation dates:** ```yaml uri: /resources/components/free-shipping.hpromo spec: codeFile: free-shipping.hpromo --- uri: /resources/rules/black-friday spec: codeFile: black-friday.hrule activeFrom: 2025-11-29T00:00:00Z activeTo: 2025-11-29T23:59:59Z ``` The `activeFrom` and `activeTo` fields on the rule entity control when the rule fires. The promotion component itself is stateless and reusable — timing is configured at the rule level. ### How It Plays Out **During the campaign (Nov 29):** * `OnOrderCreated` rule fires for each new order (within the active window) * Promotion is added with the `free-shipping` component and `threshold = 500` * As the customer adds items, the promotion re-evaluates * Below threshold: message says "Spend X more for free shipping" * Above threshold: free shipping is applied with a confirmation message **After the campaign ends (Nov 30+):** * The rule no longer fires for new orders * **Existing orders keep their promotion** — it continues to work as the order is modified * If the customer removes items and drops below threshold, the discount disappears but the message updates ## Managing Promotions ### Updating Parameters Change the runtime parameters of an existing promotion: ```json { "type": "setPromotionParameters", "promotionId": "65812410-bd14-4d6d-ab79-374789a976c1", "parameters": { "threshold": "750" } } ``` See [`setPromotionParameters`](/resources/actors/order/commands/set-promotion-parameters). ### Updating Description ```json { "type": "setPromotionDescription", "promotionId": "65812410-bd14-4d6d-ab79-374789a976c1", "description": "Updated Campaign Name" } ``` See [`setPromotionDescription`](/resources/actors/order/commands/set-promotion-description). ### Dynamic Fields Store custom metadata on a promotion: ```json { "type": "setPromotionDynamicFields", "promotionId": "65812410-bd14-4d6d-ab79-374789a976c1", "fields": { "campaignId": "BF2025", "source": "email" } } ``` See [`setPromotionDynamicFields`](/resources/actors/order/commands/set-promotion-dynamic-fields). ### Deleting ```json { "type": "deletePromotion", "promotionId": "65812410-bd14-4d6d-ab79-374789a976c1" } ``` See [`deletePromotion`](/resources/actors/order/commands/delete-promotion). ## Pure Runtime Constraints Promotion components run in a pure runtime with strict restrictions. This ensures promotions evaluate quickly and safely without unintended side effects. ### What You CAN Do * ✅ Access the current order via the [`order`](/resources/components/runtimes/keywords/order) symbol * ✅ Access custom parameters passed to the component * ✅ Perform calculations and logic * ✅ Use pattern matching and filtering * ✅ Return discount effects ([`percentage`](/resources/components/runtimes/keywords/percentage), [`absolute`](/resources/components/runtimes/keywords/absolute), [`target`](/resources/components/runtimes/keywords/target)) * ✅ Return message effects ### What You CANNOT Do * ❌ Send messages to actors * ❌ Send emails * ❌ Query the graph * ❌ Access the registry * ❌ Schedule jobs * ❌ Modify external state ## Error Handling If a promotion component fails to evaluate — due to a parse error, invalid parameters, or runtime exception — the error is captured on the promotion entity: * `error.code` — error classification (e.g., `PARSE_ERROR`, `INVALID_PARAMETER`, `INTERNAL_ERROR`) * `error.message` — human-readable description Errored promotions are marked as inactive and contribute no effects. The rest of the order's promotions continue to function normally. ## Promotion Entity Fields | Field | Description | |-------|-------------| | `promotionId` | Unique identifier | | `component` | Reference to the component (`componentId`, `version`) | | `parameters` | Key-value pairs passed to the component | | `description` | Customer-facing text, copied to invoices | | `promotionGroup` | Group for combination rules | | `canOnlyCombineWith` | Groups this promotion can coexist with | | `canNotCombineWith` | Groups that exclude this promotion | | `isActive` | Whether the promotion currently produces effects | | `messageType` | Type identifier for the current message (explicit or auto-hashed) | | `messageTemplate` | The raw message text with `{placeholder}` syntax | | `messageRendered` | Server-rendered message with placeholders replaced | | `messageFields` | Structured field values for client-side rendering | | `calculatedTotal` | Total discount amount this promotion contributes | | `error` | Error details if evaluation failed | | `dynamic` | Custom metadata | | `createdAt` | Creation timestamp | ## Querying Promotions Promotions are available via the [Graph](/resources/graph/): * [`promotion`](/resources/graph/nodes/promotion) — the promotion entity * [`calculatedDiscount`](/resources/graph/nodes/calculated-discount) — resolved discount amounts (linked via `promotionId`) ## Advanced Example: Tiered Volume Discount with Message ```filtrera // volume-discount.hpromo let rate = order.total match when order.total >= 10000 |> 15% when order.total >= 5000 |> 10% when order.total >= 1000 |> 5% |> 0% let nextTier = order.total match when order.total >= 10000 |> nothing when order.total >= 5000 |> { target = 10000, rate = '15%' } when order.total >= 1000 |> { target = 5000, rate = '10%' } |> { target = 1000, rate = '5%' } from rate > 0% match true |> percentage(order, rate) from nextTier match nothing |> { type = 'message', message = "You're at our best tier — 15% off!" } |> { type = 'message' message = 'Spend {remaining} more to unlock {nextRate} off' fields = { remaining = nextTier.target - order.total nextRate = nextTier.rate } } ``` This promotion: * Applies 5%/10%/15% off based on order value * Shows a message about the next available tier using placeholders * Re-calculates automatically as the order changes ## Best Practices ### Use Generic Components with Parameters Create reusable promotion components and configure them via parameters: ```filtrera // free-shipping.hpromo — reusable across campaigns param threshold: number from order.total >= threshold match false |> nothing true |> percentage(target(e => e is Delivery), 100%) ``` Rules configure the specifics: ```filtrera param input: OnOrderCreated from [{ effect = 'orderCommand' type = 'createPromotion' componentId = 'free-shipping' promotionGroup = 'shipping' description = 'Summer Free Shipping' parameters = { threshold = '300' } }] ``` ### Use Promotion Groups Intentionally Group promotions by business category so combination rules are meaningful: * `'shipping'` — free shipping promotions * `'campaign'` — seasonal campaigns * `'loyalty'` — member benefits * `'coupon'` — one-time coupon codes ### Use Messages for Customer Communication Messages are rendered and stored on the promotion, making them available through the Graph for display in storefronts, portals, and emails. Use placeholders with `fields` when you want rich clients to render values with custom formatting: ```filtrera // Placeholder approach — allows rich rendering from { type = 'message' message = 'You saved {amount} on this order!' fields = { amount = order.total * rate } } ``` ### Use Clear Descriptions The `description` field appears on invoices and customer communications: * ✅ `"Free Shipping on Orders Over 500"`, `"10% Volume Discount"`, `"Loyalty Member Benefit"` * ❌ `"Discount"`, `"Promo"`, `"Test"` ## See Also * [Discounts](/resources/actors/order/discounts) — static, fixed-value discounts * [Components](/resources/components/) — learn about components and runtimes * [Rules](/resources/rules/) — react to system events * [Rule Effects](/resources/rules/effects) — how to emit order commands from rules * [Order Actor](/resources/actors/order/) — order actor overview * [Order Commands](/resources/actors/order/commands/) — full command reference --- --- url: /resources/actors/order/messages.md --- # Order Actor Messages --- --- url: /resources/actors/order/commands.md --- # Order Actor Commands --- --- url: /resources/actors/payment.md --- # Payment Actor The Payment Actor in Hantera is responsible for managing and tracking the full payment lifecycle, including authorizations, captures, and journals. It provides a flexible solution for handling various payment methods, whether integrating with external online payment providers or supporting more traditional invoicing processes involving bank transfers. Beyond standard payments, the Payment Actor can also be utilized to manage customer "accounts," functioning like gift cards or credit accounts. This versatility allows for a wide range of payment scenarios, giving developers the ability to build solutions that cater to different business needs and customer preferences. ## Rule Hooks Related Rule Hooks are: --- --- url: /resources/actors/payment/messages.md --- # Payment Actor Messages --- --- url: /resources/actors/payment/commands.md --- # Payment Actor Commands --- --- url: /resources/actors/sku.md --- # Sku Actor ## OrderLine Reserved Quantity When stock changes, the `reservedQuantity` of related order lines are automatically kept up to date. There can be a slight delay before order lines are updated if many order lines are currently reserving the same SKU. ::: warning When an order reserves stock using a Rule, the `reservedQuantity` is updated within the same message life cycle. Note that this means that a [query](/resources/actors/order/messages/query) message response will only see updated reflect `reservedQuantity` of the order that triggered the reservation. Other orders that may be affected by the stock changes will not be available to the [query](/resources/actors/order/messages/query) message. ::: ## Rule Hooks Related Rule Hooks are: --- --- url: /resources/actors/sku/messages.md --- # Sku Actor Messages --- --- url: /resources/actors/sku/commands.md --- # Sku Actor Commands --- --- url: /resources/actors/custom.md --- # Custom Actors Custom Actors are [Actor](/resources/actors/) classes that support type definitions. Therefore, developers can extend and customize them to model any kind of business object. Although Hantera has several Actor classes, the only custom actors are [Asset](/resources/actors/custom/asset/) and [Ticket](/resources/actors/custom/ticket/). The [Order](/resources/actors/order/), [Payment](/resources/actors/payment/), and [Sku](/resources/actors/sku/) actors are designed for specific use cases. For instance, Order can only model data such as orders, invoices, and deliveries. So, it lacks the flexibility to represent data outside of its scope. In contrast, the Asset and Ticket actors generalize well to custom data. Through Type definitions, developers can define custom Actor types, subtypes, and relations in the [Registry](/resources/registry/). For example, Ticket can have Support, RMA, Complaint, and Shipment Ticket types. ::: tip * Use Ticket for data that goes through state transitions and is valid for a set period * Use Asset to hold long-term business data ::: Custom Actors are important because they: * Support custom types which can model any business data * Accurately model custom business objects that don’t fit special-use actors * Extend the Graph by creating more nodes and edges to map data relationships ## Key Features of a Custom Actor Custom Actors model general business objects because of the following features: #### 1. Type Key The `type key` is a unique global name that points to a type's node in the Graph. It is used to create and query instances of a type. #### 2. Items An Item is an arbitrary entity that acts like a subtype of a type. In the Graph, an Item is a node that maps to a specified type. A custom Actor may have many types of Items. It can also have many instances of each Item. Just like the main Actor, each Item must have a type key. In addition, it may have Relations with other nodes too. #### 3. Relations A custom type can define Relations to other nodes in the Graph. Each Relation will manifest as an edge to the related nodes. This means that a custom Actor’s related Graph node may contain Edges not normally supported by the Actor. Items can also define Relations, allowing for complex and accurate modeling of business data. While the Actor’s main Graph node automatically generates Edges for the Actor’s typed Items, Relations could be added between types of Items as well. See the example below. ::: tip What's New? As of version 2025.2, **Type Keys are required** to index Custom Actors, Items, and Relations in the Graph. If absent, you will get an [`UNDEFINED__TYPE`](/resources/actors/errors/) error when trying to create or query a custom actor. ::: #### How to create Items and Relations Create Items and Relations by sending a Message to a custom Actor. The `applyCommands` message mutates the state of Asset and Ticket Actor instances by applying the specified methods to them. Valid commands used to create and modify Items and Relations are the following (these links point to the Ticket version of these commands, but they are identical with other Actors): * [`createItem`](/resources/actors/custom/ticket/commands/create-item) * [`createItemRelation`](/resources/actors/custom/ticket/commands/create-item-relation) * [`createRelation`](/resources/actors/custom/ticket/commands/create-relation) * [`deleteItem`](/resources/actors/custom/ticket/commands/delete-item) * [`deleteItemRelation`](/resources/actors/custom/ticket/commands/delete-item-relation) * [`deleteRelation`](/resources/actors/custom/ticket/commands/delete-relation) ::: info When creating items and relations in an actor, the `typeKey`, `itemTypeKey` and `relation` must be defined, otherwise you will get an [INVALID\_COMMAND](/resources/actors/errors/#:~:text=INVALID_COMMAND) error.\ ::: ## How Custom Actors Extend the Graph? The [Graph](/resources/graph/) is a separate read-only representation of data in Hantera. It mirrors Actor types and relationships with other entities, and automatically generates nodes and edges based on type definitions in the Registry. A custom Actor is represented as a node in the Graph. Once a new Asset/Ticket Type is defined in the Registry, the Graph generates a new node. For example, a Ticket type defined as `shipment` will get a node `shipment` with node name as `ticket.shipment`. Therefore, `shipment` acts as a unique and global name in the Graph and Portal, while `ticket.shipment` is used for querying the node and defining [custom Graph fields](/resources/graph/custom-fields). As seen above, each type can have Items and Relations attached to it. If an Item, package, is added to `shipment`, the Graph generates a new node `package` with node name `ticket.shipment.package`. A new edge named `package` is attached to the main node. Relations are likely named. For example, if shipment type has an order relation named orders, the `ticket.shipment` node will have an edge called `orders`. In short: | Entity | Convention | |-------------------------|----------------------------------------| | Actor nodes | `.` | | Item nodes | `..` | | Relation edges | `.` | | Inverse relation edges | `.[.]` | ::: info Inverse relation edge naming convention can be extended in the type definitions. This is required when there are more than one relation to the same node type. ::: Since all the nodes and edges are generated from type definitions, the Graph is a read-only mirror representation of data models. Therefore, you can [query the Graph](/resources/graph/) but cannot modify it. That way, you always get an accurate representation of your data. ::: warning Be careful when defining new types or modifying relations. All changes automatically affect the Graph and instances of Asset and Ticket types. ::: #### Access Control Access control of custom nodes are based on the base node and refined using [ABAC](/resources/graph/#attribute-based-access). The attribute is `typeKey` and an identity needs a matching access attribute. For example, an identity with attribute-limited ACE `typeKey@graph/ticket:query` can only query tickets where it has a matching value for the `typeKey` access attribute. ## How to Define a Custom Actor Type? Actor Type definitions are sent through YAML files to the [Registry](/resources/registry/). The paths for the definitions also depend on the Actor. The convention looks like this: `actors//types/` #### Schema Set the following properties in the YAML file when creating a new Type: ::: tip What's New? `graphSetName` is required in Type definitions for the Graph to index new Types. If Items is present, `itemEdgeName` and `edgeName` are also required. ::: #### Common Type Definition Errors The Registry doesn't enforce schema upon writing. This means that if you incorrectly type a Custom Actor, the Registry may pick it up regardless. Fortunately, you can use the a [CLI](/learn/hantera-cli) command, `h_ manage signals` to check if there were any errors. Still, common pitfalls to avoid include: * not setting `graphSetName` for every Actor type and Item * not setting `itemEdgeName` and `edgeName`, where required * Including numbers in the `defaultNumberPrefix` value. The prefix must only be alphabets. ::: tip Always run `h_ manage signals` after applying a Type definition. ::: ## Example: Define a Shipment Ticket Type Let's say you want to model a shipment containing a delivery being shipped to a customer. A shipment has a limited lifespan, so this makes [Ticket Actor](/resources/actors/custom/ticket/) the best option. In this example, `shipment` will contain items `package` and `event`, as well as have `order` and `delivery` relations. We will store events for each package as well as Shipment-specific events (common for all packages). To model this, we will apply the following manifest: ```yaml uri: /registry/actors/custom/ticket/types/shipment spec: value: graphSetName: shipment itemEdgeName: head defaultNumberPrefix: "SHIP" items: event: graphSetName: event edgeName: edgeEvent relations: package: node: 'ticket.shipment.package' cardinality: single package: graphSetName: package edgeName: edgePackage relations: delivery: node: delivery cardinality: single ``` It's important to note that while Relations are bi-directional in the Graph, the Relation is controlled by the entity that defines it. So we can't add an event to a package in the above case. Instead we must add the package to the event. --- --- url: /resources/actors/custom/asset.md --- # Asset Actor An Asset Actor models general business data. It serves as a template for representing data that don’t fit special actors like [Order](/resources/actors/order/) and [Payment](/resources/actors/payment/). Asset is also different from [Ticket](/resources/actors/custom/ticket/) because it holds data for the long-term. In contrast, a Ticket is valid until it is `completed`. You can define an Asset to model customers, vendors, suppliers, products, buyer personas, and other general data. Asset actors are also dynamic because they support [custom Types](/resources/actors/custom/), [Items](/resources/actors/custom/#:~:text=Items), and [Relations](/resources/actors/custom/#:~:text=3.-,Relations). These allow you to model data more closely and accurately to represent sub-types and existing links with other resources. For instance, a Customer Asset can include items such as one-off and repeat customers, and relations with Order and Payment. ## Core Concepts of Asset Actors Assets stand out because they: * **Support custom types**: Create custom [Asset Types](/resources/actors/custom/asset/types/) with Items and Relations to closely model general business data. * **Hold general data**: Model non-specialized actor classes not found on Hantera, such as, Customer and Vendor data. ## Quick Start for Asset Actors Follow these steps to create and work with new Assets. 1. [Define an Asset type](/resources/actors/custom/asset/types/), for instance Customer, Vendor 2. [Create an instance of an Asset](/resources/actors/custom/asset/messages/create) 3. [Modify instances of Asset types](/resources/actors/custom/asset/commands/) ## Asset Rule Hooks Rule Hooks for Assets are: --- --- url: /resources/actors/custom/asset/types.md --- # Asset Types An Asset Type is a root node in the [Asset](/resources/actors/custom/asset/) graph space. It is a [custom actor type](/resources/actors/custom/) that defines the kind of business data a type holds, what sub-types may be present, and any relations with other resources. For instance, a Vendor Asset type models vendor business data. Asset Types are unavoidable because every Asset instance must be tied to a custom type. Start by defining a new Asset type key in a YAML file with the uri path as `/registry/actors/custom/asset/types/`. The [Graph](/resources/actors/actor-extensions#:~:text=The%20Graph%20mirrors%20Actor%20type) will automatically index the new type. Keep in mind that Asset type definitions have a [schema](/resources/actors/custom/#:~:text=actor%3E/types/%3CtypeKey%3E-,Schema). ## Example: Create a New Vendor Asset Type Choose unique names for asset type definitions, If now, existing types may be overwritten. ::: tip Always run `h_ manage signals` after applying a Type definition. It lists changes including any [type definition errors](/resources/actors/errors/#:~:text=Common%20Pitfalls,-Not%20running%20h_). ::: ## Example: Query the Vendor Asset You can also [customize graph queries](/resources/graph/) to make them more specific. ::: tip Run `GET /resources/graph` before a query. It returns all existing graph nodes and edges so you can correctly query the graph. ::: --- --- url: /resources/actors/custom/asset/messages.md --- # Asset Actor Messages An Asset message is a method applied to an existing instance of an Asset. These are the available Asset messages: Send Asset message requests using `/resources/actors/custom/asset/`\\`\`, where `` is `new` when creating a new instance. Aside from the delete message, each time you send a message, the Asset returns all of its access URIs. They include: * Default GUID * Asset Number * External Reference, if specified. Therefore, you can also message an Asset with `assetNumber` or `externalReference` by including the `assetType` in the URL like so: `/resources/actors/custom/asset//`. Additionally, you can send [multiple messages](/resources/actors/#:~:text=Sending%20Messages,-A%20single%20transaction) in one request or test them in [preview mode](/resources/actors/#:~:text=Previewing,-A%20powerful%20feature). A general template for Asset messages is: ```json [{ "type": "", "body": { } }] ``` ## Example: Create a new `Vendor` and add a `localVendor` item --- --- url: /resources/actors/custom/asset/commands.md --- # Asset Actor Commands Asset commands are methods applied to Assets with the [`applyCommands`](/resources/actors/custom/asset/messages/apply-commands) message. Valid Asset commands and their actions are: All commands are [batch processed](/resources/actors/#:~:text=Batch%20Processing%20of%20Commands,-Commands%20are). Therefore, if one command in a batch fails, all commands fail. To apply a command to an asset, send a request with the following format. See [`applyCommands`](/resources/actors/custom/asset/messages/apply-commands) for a specific example. ```json [{ "type": "applyCommands", "body": { "commands": [ ] } }] ``` --- --- url: /resources/actors/custom/ticket.md --- # Ticket Actor The Ticket Actor in Hantera models and tracks long-running business transactions such as shipments, requests, inquiries, and support cases. It is a [Custom Actor](/resources/actors/custom/) which allows you to customize tickets to fit a wide range of applications, from customer support issues to internal workflow processes. By combining the Ticket Actor with the `sendMessage` effect in rules, you can automate actions such as generating returns and processing refunds upon ticket completion. Ticket is different from [Asset](/resources/actors/custom/asset/) because Ticket actors exist in states and can be marked as `completed`. ## Key Features * **Dynamic Data Model:** Customize ticket fields to capture the specific information required for different scenarios. * **Versatile Applications:** Utilize tickets for customer inquiries, support cases, returns, refunds, and more. * **Integration with Rules:** Automate processes by emitting messages and triggering effects based on ticket events. * **State Transitions:** Tickets exist in different states including `open`, `rejected`, and `completed`. ## Quick Start for Ticket Actors Follow these steps to create and work with new Tickets. 1. [Define a Ticket type](/resources/actors/custom/ticket/types/) 2. [Create an instance of an Ticket](/resources/actors/custom/ticket/messages/create) 3. [Modify instances of Ticket types](/resources/actors/custom/ticket/commands/) ## Ticket Rule Hooks Related Rule Hooks for Tickets are: ## Example: Process a Ticket Completion --- --- url: /resources/actors/custom/ticket/types.md --- # Ticket Types A Ticket Type is a node in the [Graph](/resources/actors/custom/#:~:text=an%20INVALID_COMMAND%20error.-,How%20Custom%20Actors%20Extend%20the%20Graph) that defines a custom type key, plus items and relationships with other nodes. For instance, custom types include Support, Complaints, and Warranty Tickets. To create a new Ticket type, define the type key in a YAML file using the path `/registry/actors/custom/ticket/types/` Ticket types are [Custom Actors](/resources/actors/custom/) with specific [type definition schema](/resources/actors/custom/#:~:text=Schema,-Set%20the%20following). ## Example: Create a Return Ticket Type ```filtrera // newTicketType.yaml uri: /registry/actors/custom/ticket/types/return spec: value: graphSetName: return itemEdgeName: head defaultNumberPrefix: "RE" items: refund: graphSetName: refundTicket edgeName: refundEdge relations: orders: node: 'order' cardinality: single exchange: graphSetName: exchangeTicket edgeName: exchangeEdge relations: orders: node: 'order' cardinality: single ``` 2. Apply it to the Registry via the CLI using `h_ manage apply` ``` h_ manage apply .\newTicketType.yaml -s demo-ecom ``` **Response** ``` Updated registry keys: - actors/ticket/types/return - Old: undefined - New: {"graphSetName":"return","itemEdgeName":"head","defaultNumberPrefix":"RE","items":{"refund":{"graphSetName":"refundTicket","edgeName":"refundEdge","relations":{"orders":{"node":"order","cardinality":"single"}}},"exchange":{"graphSetName":"exchangeTicket","edgeName":"exchangeEdge","relations":{"orders":{"node":"order","cardinality":"single"}}}}} ``` Choose unique names for asset type definitions because existing types are overwritten, which wipes out any previously existing relations. Once you’ve gotten a response, use `h_ manage signals` to ensure there were no asset type creation errors. ::: tip Always run `h_ manage signals` after applying a Type definition. It lists changes including any [type definition errors](/resources/actors/errors/#:~:text=Common%20Pitfalls,-Not%20running%20h_). ::: ## Example: Query the Return Ticket **Response** ``` HTTP/1.1 200 OK ... { "return": { "nodes": [ { "ticketId": "0199a9b6-8608-7360-b98b-d2f9adeb2848", "ticketNumber": "RE100011", "ticketState": "open", "exchangeEdge": { "nodes": [ { "ticketItemId": "0199a9b6-860d-703a-bd17-9ef5451e577f", "dynamic": { "message": "pls exchange this item" }, "cursor": "WyIwMTk5YTliNi04NjBkLTcwM2EtYmQxNy05ZWY1NDUxZTU3N2YiXQ==" } ], "firstCursor": "WyIwMTk5YTliNi04NjBkLTcwM2EtYmQxNy05ZWY1NDUxZTU3N2YiXQ==", "lastCursor": "WyIwMTk5YTliNi04NjBkLTcwM2EtYmQxNy05ZWY1NDUxZTU3N2YiXQ==" }, "cursor": "WyIwMTk5YTliNi04NjA4LTczNjAtYjk4Yi1kMmY5YWRlYjI4NDgiXQ==" }, { "ticketId": "0199aa3f-31d0-7dd8-bf32-3a5fd3fd2627", "ticketNumber": "RE100015", "ticketState": "open", "exchangeEdge": { "nodes": [ { "ticketItemId": "0199aa3f-31ea-7cec-931f-9fb9f6bd5381", "dynamic": { "message": "exchange this product" }, "cursor": "WyIwMTk5YWEzZi0zMWVhLTdjZWMtOTMxZi05ZmI5ZjZiZDUzODEiXQ==" } ], "firstCursor": "WyIwMTk5YWEzZi0zMWVhLTdjZWMtOTMxZi05ZmI5ZjZiZDUzODEiXQ==", "lastCursor": "WyIwMTk5YWEzZi0zMWVhLTdjZWMtOTMxZi05ZmI5ZjZiZDUzODEiXQ==" }, "cursor": "WyIwMTk5YWEzZi0zMWQwLTdkZDgtYmYzMi0zYTVmZDNmZDI2MjciXQ==" } ], "firstCursor": "WyIwMTk5YTliNi04NjA4LTczNjAtYjk4Yi1kMmY5YWRlYjI4NDgiXQ==", "lastCursor": "WyIwMTk5YWEzZi0zMWQwLTdkZDgtYmYzMi0zYTVmZDNmZDI2MjciXQ==" } } ``` Check out how to [customize graph queries](/resources/graph/) to make them more specific. ::: tip Run `GET /resources/graph` before a query. It returns all existing graph nodes and edges so you can correctly query the graph for specific resources. ::: --- --- url: /resources/actors/custom/ticket/messages.md --- # Ticket Actor Messages A Ticket message is a method that modifies instances of a Ticket type. To message a Ticket, send a **POST** request with a valid JSON body to the path `/resources/actors/custom/ticket/`. {ticketId} is new when creating a new instance. Valid messages for Ticket actors include: Every message response contains a Ticket's access URIs, which include: * Default GUID * Ticket Number * External Reference, if available Therefore, you can also message a Ticket with its `ticketNumber` and `externalReference` using `/resources/actors/custom/ticket/{ticketType}/` In addition, Ticket actors support [previewing messages](/resources/actors/#:~:text=Previewing,-A%20powerful%20feature) and sending [multiple messages](/resources/actors/#:~:text=Sending%20Messages,-A%20single%20transaction) at once. A general template for sending a Ticket message is: ```json [{ "type": "", "body": { } }] ``` Ensure you send a valid JSON body and use the correct access URI to avoid [common errors](/resources/actors/errors/). ## Example: Create an Item and Tag on a new Ticket --- --- url: /resources/actors/custom/ticket/commands.md --- # Ticket Actor Commands Ticket commands are methods applied to Tickets via the [`applyCommands`](/resources/actors/custom/ticket/messages/apply-commands) message. They are sent in as an array of commands to be [batch-processed](/resources/actors/#:~:text=Batch%20Processing%20of%20Commands,-Commands%20are%20always) on a Ticket ID. If one command fails, all commands fail. Valid Ticket Actor commands are: To apply commands to a Ticket, use the below request format. See [`applyCommands`](/resources/actors/custom/ticket/messages/apply-commands) for a specific example. ```json [{ "type": "applyCommands", "body": { "commands": [ ] } }] ``` The most common pitfall here is forgetting to specify `type` or improperly forming the `command` array. These may raise the [INVALID\_MESSAGE\_BODY](/resources/actors/errors/#:~:text=INVALID_MESSAGE_BODY-,INVALID_MESSAGE_BODY) error. See [`applyCommands`](/resources/actors/custom/ticket/messages/apply-commands) for a specific example. --- --- url: /resources/actors/common/messages.md --- # Common Actor Messages --- --- url: /resources/actors/checkpoints.md --- # Checkpoints & Rewind All actors in Hantera automatically maintain checkpoints of their state. These checkpoints can be used to view historical states or restore an actor to a previous point in time. This functionality is available on all actor types including Order, Payment, Ticket, Asset, and SKU. ## What are Checkpoints? Every time commands are applied to an actor, a **mutation set** is created and persisted. Each mutation set represents a checkpoint containing: * **Checkpoint ID**: A unique UUID identifier * **Timestamp**: When the mutation set was created * **Metadata**: Optional information about the changes (e.g., which commands were applied) These mutation sets form a complete history of all changes made to the actor over time. By replaying mutations up to a specific checkpoint, the system can reconstruct the exact state of the actor at that point in time. ## Viewing Available Checkpoints Use the [`getCheckpoints`](/resources/actors/common/messages/get-checkpoints) message to retrieve all available checkpoints for an actor: ```json [{ "type": "getCheckpoints", "body": {} }] ``` **Response:** ```json [ { "checkpointId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "timestamp": "2025-01-15T10:30:00Z", "metadata": {} }, { "checkpointId": "b2c3d4e5-f6a7-8901-bcde-f12345678901", "timestamp": "2025-01-15T14:45:00Z", "metadata": { "commands": [{"type": "addTag", "key": "priority"}] } } ] ``` Checkpoints are returned in chronological order, from earliest to most recent. ## Rewinding to a Checkpoint The [`rewind`](/resources/actors/common/messages/rewind) message restores an actor to the state it had at a specific checkpoint. ```json [{ "type": "rewind", "body": { "checkpointId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890" } }] ``` **Response:** ```json { "success": true, "checkpointId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "timestamp": "2025-01-15T10:30:00Z", "checkpointsAffected": 1 } ``` ### How Rewind Works Rewind is a **non-destructive** operation: 1. The system finds the target checkpoint in the mutation history 2. It rebuilds the state by replaying all mutations up to that checkpoint 3. It calculates the difference between the current state and the checkpoint state 4. A **new** mutation set is appended that transforms the current state to match the checkpoint state 5. An activity log entry is added recording the rewind operation The original mutation history is preserved, and the rewind itself becomes a new checkpoint in the history. This means you can even rewind a rewind if needed. ### Rules and Rewind A rewind is not a replay of the actor's history, so the rules that ran for the original messages do **not** run again. The command, calculate and validate hooks are skipped entirely — a rewind can therefore restore a state that current rules would no longer produce. The one exception is the order actor's invoice lifecycle, because invoices are never removed from an order — only cancelled: | Rewind effect on an invoice | Hook fired | | --- | --- | | Rewinding to a checkpoint that predates the invoice cancels it | [`OnOrderInvoiceCancelled`](/resources/components/runtimes/rule-hooks/onOrderInvoiceCancelled) | | Rewinding forward past an earlier rewind re-activates it (`isCancelled` back to `false`) | *None* | This lets apps that own external invoice documents (accounting, tax reporting) stay consistent with the order without knowing anything about checkpoints. Re-activation fires nothing — in particular not [`OnOrderInvoiceCreated`](/resources/components/runtimes/rule-hooks/onOrderInvoiceCreated), since the invoice keeps its original `invoiceId` and `invoiceNumber` and was never re-created. Apps that need to detect re-activation should diff `isCancelled` in [`OnOrderCommands`](/resources/components/runtimes/rule-hooks/onOrderCommands). ### Activity Log Preservation When rewinding, activity logs are preserved. A new entry is automatically added to document the rewind: ``` "Rewound order to checkpoint at {timestamp}" ``` This ensures full traceability of state changes. ## Previewing Old States You can preview what an actor's state looked like at a previous checkpoint without actually performing a rewind. This is done by combining the rewind message with [preview mode](/resources/actors/#previewing): ```json [{ "type": "rewind", "body": { "checkpointId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890" } },{ "type": "query", "body": { "query": { "fields": ["orderId", "orderNumber", "orderState", "total"], "navigate": [{ "edge": "deliveries", "node": { "fields": ["deliveryId", "deliveryState"] } }] } } }] ``` In preview mode: * The rewind is performed in an isolated transaction * Subsequent messages (like `query`) see the rewound state * All changes are automatically rolled back at the end * No data is persisted This is useful for: * Debugging issues by inspecting historical state * Auditing changes over time * Comparing current state with previous states * Customer support scenarios where you need to understand what happened ## Access Control Rewind operations require specific permissions: | Permission | Description | |-----------|-------------| | `actors/:rewind` | Full rewind permission. Allows actual rewind operations that persist state changes. | | `actors/:rewind:preview` | Preview-only permission. Allows rewinding in preview mode to view historical states, but changes are not persisted. | For example: * `actors/order:rewind` - Can rewind orders * `actors/ticket:rewind:preview` - Can only preview historical ticket states The preview permission is useful for support personnel or auditors who need to investigate historical states without the ability to modify data. ## Use Cases ### Reverting Mistakes If incorrect commands were applied to an actor, you can rewind to a checkpoint before those changes: 1. Call `getCheckpoints` to find the appropriate checkpoint 2. Call `rewind` with the checkpoint ID 3. The actor returns to its previous state ### Auditing and Debugging Use preview mode to investigate what an actor looked like at any point in time: 1. Call `getCheckpoints` to see the history 2. Use `rewind` with `?preview` to view the historical state 3. Query the state to inspect specific fields ### Comparing States You can compare an actor's current state with a historical state: 1. Query the current state 2. In preview mode, rewind and query the historical state 3. Compare the two results ## Related Messages * [`getCheckpoints`](/resources/actors/common/messages/get-checkpoints) - Returns available checkpoints for an actor * [`rewind`](/resources/actors/common/messages/rewind) - Rewinds an actor to a specific checkpoint --- --- url: /resources/actors/errors.md --- # Common Errors These errors typically show up when sending Actor messages or applying commands. ##### UNDEFINED\_`<ACTOR>`\_TYPE `UNDEFINED_`\`_TYPE` occurs when you try to create an Actor using a type that is not defined in the [Registry](/resources/registry/). ``` HTTP/1.1 500 Internal Server Error --- { "errors": [ { "code": "UNDEFINED_TICKET_TYPE", "message": "Ticket type \u0027return8\u0027 is not defined", "details": {} } ] } ``` **FIX:** Use a valid type key in your `create` message. ##### ALREADY\_EXISTS `ALREADY_EXISTS` occurs when you try to create an Actor with an ID that already exists in Hantera. All actor instance IDs must be unique. ``` HTTP/1.1 500 Internal Server Error --- { "errors": [ { "code": "ALREADY_EXISTS", "message": "", "details": {} } ] } ``` **FIX:** Use `new` when creating an Actor to automatically generate a GUID. ##### NOT\_FOUND `NOT_FOUND` occurs when you query an Actor using an ID that does not exist. It also shows up if you try to create an Actor with an ID that is not a valid GUID. ``` HTTP/1.1 404 Not Found --- { "errors": [ { "code": "NOT_FOUND", "message": "", "details": {} } ] } ``` **FIX:** Use `new` when creating an actor instance to automatically generate a unique and valid GUID. ##### INVALID\_MESSAGE\_BODY `INVALID_MESSAGE_BODY` occurs when you do not specify the type key for an Asset or a Ticket. ``` HTTP/1.1 400 Bad Request --- { "errors": [ { "code": "INVALID_MESSAGE_BODY", "message": "The TypeKey field is required.", "details": {} } ] } ``` ##### INVALID\_COMMAND `INVALID_COMMAND` occurs when you send an `applyCommands` request which is missing some required information. ``` HTTP/1.1 500 Internal Server Error --- { "errors": [ { "code": "INVALID_COMMAND", "message": "The ItemTypeKey field is required.", "details": {} } ] } ``` **FIX:** Carefully read the error message to discover and include the required keys. ##### DUPLICATE\_ID `DUPLICATE_ID` occurs when you try to use an already-existing external reference when creating a new Actor. ``` HTTP/1.1 500 Internal Server Error --- { "errors": [ { "code": "DUPLICATE_ID", "message": "A given ID is already in use", "details": {} } ] } ``` **FIX:** Use unique external references across Actors. ### Common Pitfalls ##### Not running `h_ manage signals` `h_ manage signals` is a [CLI](/learn/hantera-cli) command that returns latest Graph changes. After applying a Type definition, always run this command to see the changes. [Signals](/resources/registry/reference/signals) also return errors that may stop an Actor from being indexed by the Graph. For instance, `h_manage signals` catches when `graphSetName` is not set or when a node does not have a required `edgeName`. This way, you can troubleshoot and resolve type definition bugs fast. --- --- url: /resources/actors/lifecycle-hooks.md --- # Actor Lifecycle Hooks Actor lifecycle hooks are rule events that fire at specific points in an actor's lifecycle. They allow you to validate operations, enforce business logic, and trigger automations tied to actor state changes. ## Hook Timing Hooks are organized by **when** they execute relative to the operation. ### Before Hooks Execute before the operation completes — can prevent it with a validation error: * [`OnOrderBeforeCreated`](/resources/components/runtimes/rule-hooks/onOrderBeforeCreated) — Before order is created * [`OnOrderBeforeDeleted`](/resources/components/runtimes/rule-hooks/onOrderBeforeDeleted) — Before order is deleted * [`OnPaymentBeforeCreated`](/resources/components/runtimes/rule-hooks/onPaymentBeforeCreated) — Before payment is created * [`OnPaymentBeforeDeleted`](/resources/components/runtimes/rule-hooks/onPaymentBeforeDeleted) — Before payment is deleted * [`OnTicketBeforeCreated`](/resources/components/runtimes/rule-hooks/onTicketBeforeCreated) — Before ticket is created * [`OnTicketBeforeDeleted`](/resources/components/runtimes/rule-hooks/onTicketBeforeDeleted) — Before ticket is deleted * [`OnSkuBeforeCreated`](/resources/components/runtimes/rule-hooks/onSkuBeforeCreated) — Before SKU is created * [`OnSkuBeforeDeleted`](/resources/components/runtimes/rule-hooks/onSkuBeforeDeleted) — Before SKU is deleted * [`OnAssetBeforeCreated`](/resources/components/runtimes/rule-hooks/onAssetBeforeCreated) — Before asset is created * [`OnAssetBeforeDeleted`](/resources/components/runtimes/rule-hooks/onAssetBeforeDeleted) — Before asset is deleted **Use for**: Validation, preventing invalid state ### After Hooks Execute after the operation completes — changes are persisted: * [`OnOrderCreated`](/resources/components/runtimes/rule-hooks/onOrderCreated) — After order is created * [`OnOrderDeleted`](/resources/components/runtimes/rule-hooks/onOrderDeleted) — After order is deleted * [`OnOrderInvoiceCreated`](/resources/components/runtimes/rule-hooks/onOrderInvoiceCreated) — After one or more invoices are created on an order * [`OnOrderInvoiceCancelled`](/resources/components/runtimes/rule-hooks/onOrderInvoiceCancelled) — After one or more invoices are cancelled on an order (incl. by rewind) * [`OnPaymentCreated`](/resources/components/runtimes/rule-hooks/onPaymentCreated) — After payment is created * [`OnPaymentDeleted`](/resources/components/runtimes/rule-hooks/onPaymentDeleted) — After payment is deleted * [`OnPaymentCapture`](/resources/components/runtimes/rule-hooks/onPaymentCapture) — After payment is captured * [`OnTicketCreated`](/resources/components/runtimes/rule-hooks/onTicketCreated) — After ticket is created * [`OnTicketDeleted`](/resources/components/runtimes/rule-hooks/onTicketDeleted) — After ticket is deleted * [`OnTicketComplete`](/resources/components/runtimes/rule-hooks/onTicketComplete) — After ticket is completed * [`OnTicketRejected`](/resources/components/runtimes/rule-hooks/onTicketRejected) — After ticket is rejected * [`OnSkuCreated`](/resources/components/runtimes/rule-hooks/onSkuCreated) — After SKU is created * [`OnSkuDeleted`](/resources/components/runtimes/rule-hooks/onSkuDeleted) — After SKU is deleted * [`OnAssetCreated`](/resources/components/runtimes/rule-hooks/onAssetCreated) — After asset is created * [`OnAssetDeleted`](/resources/components/runtimes/rule-hooks/onAssetDeleted) — After asset is deleted **Use for**: Automation, notifications, integrations ### Command Hooks Execute when commands are applied to an actor: * [`OnOrderCommands`](/resources/components/runtimes/rule-hooks/onOrderCommands) — When commands applied to order * [`OnPaymentCommands`](/resources/components/runtimes/rule-hooks/onPaymentCommands) — When commands applied to payment * [`OnTicketCommands`](/resources/components/runtimes/rule-hooks/onTicketCommands) — When commands applied to ticket * [`OnSkuCommands`](/resources/components/runtimes/rule-hooks/onSkuCommands) — When commands applied to SKU * [`OnAssetCommands`](/resources/components/runtimes/rule-hooks/onAssetCommands) — When commands applied to asset **Use for**: Cascading logic, deriving additional changes ### Calculate Hooks Execute after the command hooks and their emitted commands have been applied, before the validate hooks: * [`OnOrderCalculate`](/resources/components/runtimes/rule-hooks/onOrderCalculate) — Derived calculations on the enriched order (e.g. tax after price enrichment) **Use for**: Calculations that consume the output of `OnOrderCommands` rules ### Validate Hooks Execute after the command and calculate hooks have settled — can reject the operation or auto-fix state: * [`OnOrderValidate`](/resources/components/runtimes/rule-hooks/onOrderValidate) — Enforce order invariants once all reactions have settled **Use for**: Invariants on the post-reaction state, auto-fixing derived state ### Journal Hooks Execute when journal entries are created: * [`OnOrderJournal`](/resources/components/runtimes/rule-hooks/onOrderJournal) — When order journal entry created * [`OnPaymentJournal`](/resources/components/runtimes/rule-hooks/onPaymentJournal) — When payment journal entry created **Use for**: Audit trail reactions, logging ## Hooks by Actor Type #### Order (10 hooks) * [`OnOrderBeforeCreated`](/resources/components/runtimes/rule-hooks/onOrderBeforeCreated) * [`OnOrderCreated`](/resources/components/runtimes/rule-hooks/onOrderCreated) * [`OnOrderBeforeDeleted`](/resources/components/runtimes/rule-hooks/onOrderBeforeDeleted) * [`OnOrderDeleted`](/resources/components/runtimes/rule-hooks/onOrderDeleted) * [`OnOrderCommands`](/resources/components/runtimes/rule-hooks/onOrderCommands) * [`OnOrderCalculate`](/resources/components/runtimes/rule-hooks/onOrderCalculate) * [`OnOrderValidate`](/resources/components/runtimes/rule-hooks/onOrderValidate) * [`OnOrderInvoiceCreated`](/resources/components/runtimes/rule-hooks/onOrderInvoiceCreated) * [`OnOrderInvoiceCancelled`](/resources/components/runtimes/rule-hooks/onOrderInvoiceCancelled) * [`OnOrderJournal`](/resources/components/runtimes/rule-hooks/onOrderJournal) #### Payment (7 hooks) * [`OnPaymentBeforeCreated`](/resources/components/runtimes/rule-hooks/onPaymentBeforeCreated) * [`OnPaymentCreated`](/resources/components/runtimes/rule-hooks/onPaymentCreated) * [`OnPaymentBeforeDeleted`](/resources/components/runtimes/rule-hooks/onPaymentBeforeDeleted) * [`OnPaymentDeleted`](/resources/components/runtimes/rule-hooks/onPaymentDeleted) * [`OnPaymentCommands`](/resources/components/runtimes/rule-hooks/onPaymentCommands) * [`OnPaymentCapture`](/resources/components/runtimes/rule-hooks/onPaymentCapture) * [`OnPaymentJournal`](/resources/components/runtimes/rule-hooks/onPaymentJournal) #### Ticket (8 hooks) * [`OnTicketBeforeCreated`](/resources/components/runtimes/rule-hooks/onTicketBeforeCreated) * [`OnTicketCreated`](/resources/components/runtimes/rule-hooks/onTicketCreated) * [`OnTicketBeforeDeleted`](/resources/components/runtimes/rule-hooks/onTicketBeforeDeleted) * [`OnTicketDeleted`](/resources/components/runtimes/rule-hooks/onTicketDeleted) * [`OnTicketCommands`](/resources/components/runtimes/rule-hooks/onTicketCommands) * [`OnTicketComplete`](/resources/components/runtimes/rule-hooks/onTicketComplete) * [`OnTicketRejected`](/resources/components/runtimes/rule-hooks/onTicketRejected) #### SKU (5 hooks) * [`OnSkuBeforeCreated`](/resources/components/runtimes/rule-hooks/onSkuBeforeCreated) * [`OnSkuCreated`](/resources/components/runtimes/rule-hooks/onSkuCreated) * [`OnSkuBeforeDeleted`](/resources/components/runtimes/rule-hooks/onSkuBeforeDeleted) * [`OnSkuDeleted`](/resources/components/runtimes/rule-hooks/onSkuDeleted) * [`OnSkuCommands`](/resources/components/runtimes/rule-hooks/onSkuCommands) #### Asset (5 hooks) * [`OnAssetBeforeCreated`](/resources/components/runtimes/rule-hooks/onAssetBeforeCreated) * [`OnAssetCreated`](/resources/components/runtimes/rule-hooks/onAssetCreated) * [`OnAssetBeforeDeleted`](/resources/components/runtimes/rule-hooks/onAssetBeforeDeleted) * [`OnAssetDeleted`](/resources/components/runtimes/rule-hooks/onAssetDeleted) * [`OnAssetCommands`](/resources/components/runtimes/rule-hooks/onAssetCommands) ## Execution Order For a single actor operation, hooks execute in this sequence: 1. **Before** hooks — can prevent the operation with a validation error 2. Operation executes — entity created, commands applied 3. **Commands** hooks — can add further commands 4. **Calculate** hooks (order actor) — derived calculations on the enriched state 5. **Validate** hooks (order actor) — enforce invariants or auto-fix once reactions settled 6. **Created/Deleted** hooks — automation and notifications 7. **State-change** hooks — for specific transitions, e.g. `OnOrderInvoiceCreated` when invoices appeared on the order, `OnOrderInvoiceCancelled` when invoices were cancelled, `OnPaymentCapture` when a payment was captured 8. **Journal** hooks — if a journal entry was created Example for creating an order: 1. `OnOrderBeforeCreated` — Validate the order 2. Order created in database 3. `OnOrderCreated` — Send confirmation email 4. `OnOrderInvoiceCreated` — If invoices were created in the same message, react to them (e.g. capture payments) 5. `OnOrderJournal` — Log journal entry (if created) ## Choosing the Right Hook **Use Before hooks when:** * Validating input before operations complete * Preventing invalid state changes * Checking business rule constraints **Use After hooks when:** * Sending notifications * Triggering external integrations * Creating related entities * Scheduling follow-up work **Use Command hooks when:** * Deriving additional changes from commands * Applying cascading updates * Enforcing command combinations ## Examples ### Validation with a Before Hook ```filtrera param input: OnOrderBeforeCreated let hasValidDelivery = input.order.deliveries count > 0 from hasValidDelivery match false |> { effect = 'validationError' code = 'NO_DELIVERY' message = 'Order must have at least one delivery' } ``` ### Automation with an After Hook ```filtrera param input: OnTicketComplete from { effect = 'messageActor' actorType = 'order' actorId = input.ticket.orderId messages = [{ type = 'applyCommands' body = { commands = [{ type = 'addTag' value = 'support-resolved' }] } }] } ``` ### Cascading with a Command Hook ```filtrera param input: OnOrderCommands let hasCancellation = input.order.commands any c => c.type = 'cancel' from hasCancellation match true |> { effect = 'orderCommand' type = 'addTag' value = 'cancelled' } ``` ## See Also * [Rule Hooks](/resources/rules/hooks) — Overview of all hook families * [Rule Effects](/resources/rules/effects) — Available effects * [Common Patterns](/resources/rules/patterns) — Recipe-style examples * [Runtime Reference](/resources/components/runtimes/) — Detailed hook documentation --- --- url: /resources/actors/number-series.md --- # Number Series Several entities in Hantera carry a human-readable, auto-incrementing number — orders, invoices, deliveries, payments, assets and tickets. These numbers are produced by **number series**, and both the prefix and the start number of each series are configurable through the registry. ## What a number series is A number series is the pair `(numbering entity, prefix)`. The growing counter belongs to the **series**, not to the channel or the actor type. This has an important consequence: > Two ticket types that resolve to the same prefix **share a counter**. The counter is global per series. There is no per-channel or per-type counter — only a per-prefix one. This keeps numbering predictable and matches how the underlying counter is seeded (from the maximum existing number for that prefix). ## Entities that get numbers | Entity | Default prefix | Notes | | ---------- | -------------- | --------------------------------------- | | `order` | `O` | | | `invoice` | `I` | Sub-entity of the order actor | | `delivery` | `D` | Sub-entity of the order actor | | `payment` | `P` | | | `asset` | *none* | Typed — defined by asset type | | `ticket` | *none* | Typed — defined by ticket type | ## Prefix and seed Each series has two configurable aspects: * **Prefix** — the text in front of the number (e.g. `O` in `O1042`). * **Seed** — the start number for the series. The counter never produces a number below the seed. If existing data already has higher numbers, the counter continues from there. The default seed is `1000`. Both live under the owning actor in the registry, so an administrator can override the read-only defaults that an app's actor/type specs provide. ## Configuring numbering Base configuration is stored under the actor: ``` actors/{actor}/numbering/{...scope} ``` The value (`NumberingConfig`) carries a default prefix and a per-prefix seed map: ```yaml uri: /registry/actors/order/numbering/order spec: value: defaultPrefix: O seeds: O: 1000 ``` * `defaultPrefix` — default prefix for the scope. Only honored for **typeless** entities (order, invoice, delivery, payment). For typed entities (asset, ticket) the type spec supplies the prefix, so a `defaultPrefix` here is ignored and raises a warning signal. * `seeds` — start numbers keyed by prefix. Seeds are **global per series** and are never channel-overridable. See the [`actors/{actor}/numbering`](/resources/registry/reference/actors_numbering) reference for the full value format. ### Scope The `{...scope}` segment identifies the series within the actor: | Actor | Scope | Example leaf | | ------------------- | ------------------------- | ----------------------------------------- | | order | `order` | `actors/order/numbering/order` | | order → invoice | `invoice` | `actors/order/numbering/invoice` | | order → delivery | `delivery` | `actors/order/numbering/delivery` | | payment | `payment` | `actors/payment/numbering/payment` | | ticket (typed) | `{typeKey}` | `actors/ticket/numbering/{typeKey}` | | asset (typed) | `{typeKey}` | `actors/asset/numbering/{typeKey}` | For typed actors the scope is the type key. ## Channel overrides Channels can override the **prefix** of a series (but never the seed, which is global): ``` actors/{actor}/channels/{channelKey}/numbering/{...scope} ``` ```yaml uri: /registry/actors/order/channels/b2b/numbering/order spec: value: defaultPrefix: B2B ``` Only `defaultPrefix` is honored here; a `seeds` map on a channel override raises a warning signal. Channel overrides are only effective for actors that carry a `channelKey` — today that is **order** and **ticket**. See the [`actors/{actor}/channels/{channelKey}/numbering`](/resources/registry/reference/actors_channels_numbering) reference. ## Resolution When a number is generated, the prefix is resolved with this precedence: ``` explicit prefix on the command ?? channel override actors/{actor}/channels/{ch}/numbering/{...scope}.defaultPrefix ?? base config actors/{actor}/numbering/{...scope}.defaultPrefix ?? type spec actors/{actor}/types/{typeKey}.DefaultNumberPrefix (typed actors) ?? built-in fallback (O / I / D / P / AS / T) ``` The seed is then read from `actors/{actor}/numbering/{...scope}.seeds[prefix]` (must be a positive number), falling back to `1000`. ## Generating numbers Numbers are generated in two ways: * **Automatically on create.** When an entity is created, the actor appends an internal command that resolves the prefix and seed and assigns the next number. If a number prefix is already set, this **silently skips** — re-running create is safe. * **Explicitly, by prefix.** The public `Generate{Entity}NumberByPrefix` commands take a required `prefix` and generate a number with it. Unlike auto-generation, these **fail** if a number prefix is already set on the entity. They are intended for entities that were created without a number, or for deliberate, controlled assignment. ## Live view The current next number for each active series is exposed read-only under the `system` namespace: ``` system/actors/{entity}/numbering/{entity}/{prefix} ``` This reflects the in-memory counter and is useful for monitoring. It cannot be written. --- --- url: /resources/apps.md --- # Overview Apps in Hantera are resources meant for packaging multiple Hantera resources into a single distributable unit. They provide a structured way to group resources so they can interact together within a reusable, installable module. This is achieved by defining all required resources and related files in an `h_app.yaml` file. This manifest acts as the central configuration that links those resources together inside an app. Through this packaging model, developers can structure reusable units of backend logic, exposed APIs, and automated workflows that mirror their internal systems while mapping to Hantera's resource model. #### Example Implementations Apps can be used to: * Create a custom returns and claims system tailored to your organization’s workflows * Use webhooks to source order and delivery data from a storefront into Hantera * Enable product lookup from an external system directly inside the Hantera portal * Implement automated discount logic, validation rules, or background jobs * Integrate external APIs and services ## What an App Can Declare An app can declare Hantera resources such as: * Components ([Reactor](/resources/apps/reactor-component-structure), [Rules](/resources/rules/), [Discount](/resources/actors/order/discounts)) * [Ingresses](/resources/ingresses/) * [Registry entries](/resources/registry/) * [Job definitions](/resources/job-definitions) The three component types that can be declared by an app are registered in the [Components resource class](/resources/components/). They execute within their respective runtime (Reactor, Rule, or Discount), and are mapped by other resources in Hantera to enable these use cases. In addition, apps can include static files. The [portal extension](/resources/apps/portal-extensions) is a CLI-powered mechanism that bundles these files into the app package. When the app is installed, those files are loaded into Hantera’s portal runtime. Through a portal extension, an app can: * Extend views * Inject components into UI slots * Register services * Create navigation sections * Fetch and present data --- --- url: /resources/apps/app-configuration-model.md --- # App Configuration Model The `h_app.yaml` file defines the contents of a Hantera app. It specifies which resources are included in the package and how they are linked together. An app manifest typically declares: * A unique app identifier * Portal extensions * Components * Ingress definitions * App settings * Registry entries * Job definitions * Declared dependencies (`requires`) on other apps' graph shape and Filtrera modules — see [Declaring App Dependencies](/resources/apps/declaring-dependencies) and the [Requires](#requires) section below ```yaml id: hantera-test-app name: Hantera Test App description: Minimal example app showing HTTP ingress → reactor component → actor command, plus settings, registry entries, and a job definition. authors: - Hantera extensions: portal: ./portal components: # Reactor component used by the HTTP ingress - id: components/orders.hrc # Reactor component used as a scheduled job target - id: components/hantera-test-app-order-processing.hrc # Example rule component # - id: rules/cart-to-order.hrl # Example discount component # - id: discounts/order-discount.hrd ingresses: - id: orders componentId: components/orders.hrc type: http acl: - actors/order:create # If the component reads app settings from registry, grant it explicitly - registry/apps/hantera-test-app/settings/webhookSecret:read properties: route: hantera-test-app/orders httpMethod: POST body: mode: structured isPublic: true registryEntries: # Example: extend the graph model with a field definition - path: /graph/order/fields/externalReference value: type: text source: dynamic->'externalReference' jobDefinitions: - id: hantera-test-app-order-processing componentId: components/hantera-test-app-order-processing.hrc settings: webhookSecret: label: default: Webhook secret description: default: Shared secret used to authenticate incoming webhook requests. secret: true ``` Each top-level field corresponds to a resource declaration or app-level configuration. ## App ID The `id` uniquely identifies the app resource and scopes app settings in Hantera. ***Example:*** ``` id: hantera-test-app ``` App settings are stored under: ``` apps/hantera-test-app/settings/`` ``` ## Portal Extensions The `extensions.portal` field references a directory that will be bundled into the app package. ```yaml= extensions: portal: ./portal ``` If defined, the portal extension directory is bundled and registered when the app is installed. Portal extensions are optional. Without one, the app can still execute backend logic and modify system state. See the [Portal Extension documentation](/resources/apps/portal-extensions) for implementation details. ## Components The `components` field references component files included in the app. ```yaml= components: - id: components/webhooks/orders.hrc - id: rules/cart-to-order.hrl ``` Each entry points to a Filtrera component file that will be packaged and registered during installation See: * [Reactor](/resources/apps/reactor-component-structure) * [Rules](/resources/rules/) * [Discounts](/resources/actors/order/discounts) ## Ingresses The `ingresses` field declares transport bindings for components. ***Example:*** ```yaml= ingresses: - id: orders componentId: components/webhooks/orders.hrc type: http acl: - actors/order:create properties: route: hantera-test-app/orders httpMethod: POST body: mode: structured ``` Each ingress: * References a component via componentId * Declares transport type (http, etc.) * Defines an ACL * Specifies transport properties See the [Ingress documentation](/resources/ingresses/) for full specification details. ## Registry The `registryEntries` field declares registry paths that will be created or updated at installation time. ```yaml= registryEntries: - path: /graph/ticket.cart/fields/email value: type: text source: dynamic->'email' ``` Each entry defines: * A registry `path` * A `value` payload Registry semantics are defined in the [Registry documentation](/resources/registry/). ## Jobs Definitions Apps can include `jobDefinitions` fields that reference components and make them schedulable for execution. ``` jobDefinitions: - id: hantera-test-app-order-processing componentId: components/hantera-test-app-order-processing.hrc ``` Job definitions and their executions are managed through: * `/resources/job-definitions` * `/resources/jobs` For the full model, examples, scheduling, retention, and monitoring, see the [Job definitions](/resources/job-definitions) and [Jobs](/resources/jobs/) documentation. ### App Settings The `settings` field declares configurable values exposed in the Portal UI. ***Example:*** ```yaml= settings: webhookSecret: label: default: Webhook secret secret: true ``` When installed, this creates a configuration field in the Portal UI. The value is stored in the registry under: ``` apps/``/settings/`` ``` Components may read these values if granted appropriate permissions. ``` registry->'apps/hantera-test-app/settings/webhookSecret' ``` If `secret: true`, users cannot read it from the UI, but components can access it if granted ACL permission. ## Requires The `requires` section declares the graph shape and Filtrera modules an app depends on from other apps. For conceptual guidance and workflow, see [Declaring App Dependencies](/resources/apps/declaring-dependencies). ### Top-level structure ```yaml requires: graph: # optional modules: # optional ``` | Field | Type | Required | Description | | --------- | ----------------- | -------- | -------------------------------------------------------------- | | `graph` | `RequiresGraph` | No | Graph contract: nodes, fields, edges, and sets the app reads. | | `modules` | `RequiresModules` | No | Module contract: Filtrera modules the app imports. | Omitting `requires` means the app depends only on base graph shape and modules shipped in its own package. ### `requires.graph` ```yaml requires: graph: nodes: : sets: : ``` | Field | Type | Required | Description | | ------- | -------------------------------- | -------- | ------------------------------------------------- | | `nodes` | `Map`| No | Keyed by node id (`asset.product`, `order`, etc) | | `sets` | `Map` | No | Keyed by top-level set names used in queries | #### `RequiresGraphNode` ```yaml : fields: : edges: : ``` | Field | Type | Required | Description | | -------- | --------------------------------- | -------- | ---------------------------------------- | | `fields` | `Map` | No | Fields the app reads from the node | | `edges` | `Map` | No | Edges the app traverses from the node | #### `RequiresGraphField` ```yaml : type: dimension: # optional enumDefinition: # optional ``` | Field | Type | Required | Description | | ---------------- | ----------------- | -------- | -------------------------------------------------------------------------- | | `type` | Graph field type | Yes | One of: `text`, `enum`, `number`, `instant`, `[text]`, `[enum]` | | `dimension` | string | No | Required dimension, for example `locale` | | `enumDefinition` | string | No | Enum definition id; valid only when `type` is `enum` or `[enum]` | #### `RequiresGraphEdge` ```yaml : cardinality: single | many relatedNode: ``` | Field | Type | Required | Description | | ------------- | ------------------ | -------- | ------------------------------------------------------- | | `cardinality` | `single` | `many` | Yes | Whether traversal yields one node or many | | `relatedNode` | Node id | Yes | Target node id, resolvable in the composite context | #### `RequiresGraphSet` ```yaml : node: ``` | Field | Type | Required | Description | | ------ | ------- | -------- | ----------------------------------------------- | | `node` | Node id | Yes | Node bound to this top-level set name | ### `requires.modules` ```yaml requires: modules: : ``` The map key is the path portion of `component://` URI. For `component://apps/products/price-lookup.module.hrc`, the key is `/apps/products/price-lookup.module.hrc`. #### `RequiresModule` ```yaml : exports: : ``` | Field | Type | Required | Description | | --------- | ----------------------------------- | -------- | ---------------------------------------------------- | | `exports` | `Map` | No | Exports the app references; unused exports omitted | #### `RequiresModuleExport` ```yaml : type: ``` | Field | Type | Required | Description | | ------ | -------------------- | -------- | -------------------------------------------------- | | `type` | Filtrera type string | Yes | Consumer-declared expected export type | ### Manifest-level validation CLI and server reject invalid `requires` declarations, including: * `requires.modules` key resolves to a local module in this same app. * `requires.graph` field/edge overlaps with graph contributions this app already provides via `registryEntries`. * `requires.graph` field `type` is outside the supported graph field type set. * `requires.modules` export `type` strings are not parseable Filtrera types. * `relatedNode` references a node not resolvable in the composite context. At activation, consumer components compile against real producer source from active apps. If declared and real module export types diverge, standard Filtrera type diagnostics are produced. --- --- url: /resources/apps/declaring-dependencies.md description: >- How to declare graph and module contracts in h_app.yaml so your app compiles, installs, and activates cleanly. --- # Declaring App Dependencies An app rarely lives in isolation. A pricing rule reads `asset.product.name`. An order-enrichment reactor imports a `lookupPrices` function from the Products app. A returns app navigates from `order` to custom `returnRequest` nodes contributed by another app. The `requires` section of `h_app.yaml` turns those assumptions into explicit contracts. The CLI, language server, and server use the same declaration to: * Compile your components against a realistic stub while you author. * Reject installation if your app references graph shapes or modules that are neither declared nor part of the base graph. * Surface `Broken` runtime state in the portal when the active tenant no longer satisfies your contract. If your app only reads fields that exist in the base Hantera graph and only imports modules shipped inside your own app, you do not need a `requires` block. ## Where `requires` lives `requires` is an optional top-level field of `h_app.yaml`, alongside `components`, `ingresses`, `registryEntries`, and others. ```yaml id: my-pricing-app name: My Pricing App version: 1.0.0 requires: graph: # Nodes, fields, edges, and sets the app reads or navigates. ... modules: # Filtrera modules the app imports from other apps. ... components: - id: components/apply-pricing.hrc ``` You can declare only `requires.graph`, only `requires.modules`, or both. ## Graph contracts (`requires.graph`) A graph contract says: *I depend on this graph shape. Wherever my app runs, the graph must provide at least these nodes, fields, edges, and sets.* ### Nodes, fields, and edges Inside `requires.graph.nodes`, list every node you reference. For each node, declare only the fields and edges you actually use. ```yaml requires: graph: nodes: asset.product: fields: name: type: text dimension: locale vatClass: type: enum edges: skus: cardinality: many relatedNode: asset.product.sku prices: cardinality: many relatedNode: asset.price asset.product.sku: fields: skuNumber: type: text quantity: type: number asset.price: fields: currentPrice: type: number ``` ### Sets `requires.graph.sets` declares top-level query entry points your components address. ```yaml requires: graph: sets: products: { node: asset.product } prices: { node: asset.price } ``` If your components never query from a top-level set, you can omit `sets`. ### Graph field types For `requires.graph.nodes..fields..type`, use graph field types: * `text` * `enum` * `number` * `instant` * `[text]` * `[enum]` `enumDefinition` is only valid for `enum` and `[enum]`. ### Dimensions and enums If a field has a dimension, declare it explicitly. ```yaml fields: name: type: text dimension: locale ``` Enum fields use `type: enum` and optionally `enumDefinition`. ```yaml fields: vatClass: type: enum enumDefinition: vat-class ``` ## Module contracts (`requires.modules`) When one app imports a module exported by another app, declare it under `requires.modules`. ```yaml requires: modules: /apps/products/price-lookup.module.hrc: exports: lookupPrices: type: '(params: { productNumbers: [text], currencyCode: text, priceListKeys: [text], window: duration }) => { text -> { currentPrice: number | nothing, history: [{ at: instant, price: number | nothing }], lowestPrice: number | nothing, highestPrice: number | nothing } }' ``` The example above is the actual contract for the Hantera Products app's [Price Lookup module](/official-apps/products/price-lookup). The key is the path part of the `component://` URI (everything after `component://`). For `requires.modules..exports..type`, values are Filtrera type strings. Any valid Filtrera type expression is allowed. ## Full example ```yaml id: order-enrichment name: Order Enrichment version: 1.0.0 requires: graph: nodes: asset.product: fields: name: type: text dimension: locale edges: skus: cardinality: many relatedNode: asset.product.sku asset.product.sku: fields: skuNumber: type: text sets: products: { node: asset.product } modules: /apps/products/price-lookup.module.hrc: exports: lookupPrices: type: '(params: { productNumbers: [text], currencyCode: text, priceListKeys: [text], window: duration }) => { text -> { currentPrice: number | nothing, history: [{ at: instant, price: number | nothing }], lowestPrice: number | nothing, highestPrice: number | nothing } }' components: - id: components/enrich-order.hrc ingresses: - id: orders componentId: components/enrich-order.hrc type: http acl: - actors/order:create properties: route: order-enrichment/orders httpMethod: POST ``` ## How validation works Validation runs in three phases. ### 1) `h_ app pack` (manifest-level validation) The CLI rejects manifests if, for example: * A `requires.modules` key resolves to a local module in the same app. * A `requires.graph` declaration overlaps with your own `registryEntries` contribution. * A `requires.graph` field type is outside the supported graph field type set (`text`, `enum`, `number`, `instant`, `[text]`, `[enum]`). * A `requires.modules` export `type` string cannot be parsed as Filtrera. * An edge `relatedNode` cannot be resolved in the composite context. ### 2) Install / isolated compile Components compile against a stub composed of: * Base Hantera graph * This app's own contributions * `requires.graph` overlays * `requires.modules` stub modules ### 3) Activation When activated in a tenant, components are compiled against real producer apps in the activated union. Type mismatches surface as regular Filtrera diagnostics, and runtime state becomes `Broken` until compatibility is restored. ## Portal view In **Apps → App details**, you can see: * Runtime state (`Starting`, `Running`, `Broken`) * Diagnostics (for `Broken`) * Declared Dependencies (`requires.graph`, `requires.modules`) * Derived Provides (graph fields and edges from `registryEntries`) ## Related * [App Configuration Model → Requires](/resources/apps/app-configuration-model#requires) * [App Configuration Model](/resources/apps/app-configuration-model) * [Registry](/resources/registry/) * [Components](/resources/components/) --- --- url: /resources/apps/create-your-first-app.md --- # Create Your First App This section walks through the Hantera App runtime by building a minimal backend webhook app. The app will: * Expose an HTTP ingress * Accept a structured JSON payload * Execute a Reactor component * Emit a command using `messageActor` * Create an order inside Hantera The goal is to understand how `Ingress`, `Reactor`, `Actors`, and the `Graph` work together. ## Purpose You will understand: * How an external system reaches Hantera through ingress * How ingress routes a request to a Reactor component * How structured body mode validates request shape * How Reactor emits commands using `messageActor` * How ACL grants permission to actors ## Prerequisite * You have [Hantera CLI](/learn/hantera-cli) installed. * You’re authenticated to a [Tenant](/) ## Step 1 — Create the app Run: ```bash h_ app new ``` ![Shell Commands for creating Apps](../../public/images/apps/create-app-shell-commands.png) This creates a new app scaffold containing: * `h_app.yaml` — the app manifest * A portal extension (if enabled during setup) The manifest defines how your app integrates with Hantera core. ![Generated Hantera App Manifest](../../public/images/apps/generated_manifest.png) Portal extensions are optional. Orders created through Reactor are persisted in the `graph` regardless. A portal extension simply provides a UI surface to view and interact with that data. ## Step 2 — Create a dummy payload The payload below represents an example of an external system sending order data into Hantera. The URL represents the webhook ingress endpoint exposed by the app. ```bash touch orders.http ``` Paste the dummy payload in ```http POST https://core.demo-tech1.hantera.cloud/ingress/hantera-test-app/orders Authorization: Bearer `` Content-Type: application/json { "order": { "get": { "id": "69862ad139e84f0259ff8fd2", "reference": "", "createdAt": "2026-02-15T17:54:25.000Z", "updatedAt": "2026-02-15T17:54:25.000Z", "customer": { "identifier": "johnkingcustomer", "firstName": "John", "lastName": "King", "addresses": [ { "type": "delivery", "firstName": "John", "lastName": "King", "street": "123 main st", "street2": null, "streetNumber": "123", "postalCode": "90210", "city": "Los Angeles", "country": "United States", "email": "johnking@mail.com" }, { "type": "billing", "firstName": "John", "lastName": "King", "street": "123 main st", "street2": null, "streetNumber": "123", "postalCode": "90210", "city": "Los Angeles", "country": "United States", "email": "johnking@mail.com" } ] }, "cart": [ { "name": "Palissade lounge sofa Iron Red", "sku": "palissade-lounge-sofa-iron-red", "quantity": 2, "imageUrl": "https://media.crystallize.com/hantera-demo-50/26/1/5/5628ffe7/palissade-lounge-sofa-iron-red.jpg", "price": { "currency": "eur", "gross": 1250, "net": 1000 } } ], "total": { "currency": "eur", "gross": 2500, "net": 2000 }, "pipelines": null } } } ``` Since structured mode is enabled in the ingress configuration, the request body must satisfy the Reactor component’s declared parameters. Structured mode causes the runtime to validate and deserialize the payload before the component executes. In this example, `order` is required. Optional parameters such as debug may be omitted because they define default values. ## Step 3: Create the order component Create the component file: ```bash mkdir -p components touch components/orders.hrc ``` ### Step 3.1: Populate orders.hrc with Reactor code This Reactor component maps the external payload into Hantera order commands. Open `components/orders.hrc` and define the expected payload structure: ```filtrera import 'iterators' param debug: boolean = false param order: { get: { id: text reference: text createdAt: instant customer: { identifier: text firstName: text lastName: text addresses: [{ type: 'delivery' | 'billing' firstName: text | nothing middleName: text | nothing lastName: text | nothing street: text street2: text | nothing streetNumber: text | nothing postalCode: text city: text country: text email: text | nothing }] } cart: [{ name: text sku: text quantity: number imageUrl: text | nothing price: { gross: number net: number } }] total: { currency: text } } } // Create delivery let deliveryId = newid let shippingAddress = order.get.customer.addresses where r => r.type == 'delivery' first let deliveryAddressCommands = shippingAddress match nothing |> [] |> [{ type = 'setDeliveryAddress' deliveryId = deliveryId name = $'{shippingAddress.firstName} {shippingAddress.middleName} {shippingAddress.lastName}' addressLine1 = shippingAddress.street addressLine2 = shippingAddress.street2 postalCode = shippingAddress.postalCode city = shippingAddress.city countryCode = shippingAddress.country email = shippingAddress.email }] let deliveryCommands = [[{ type = 'createDelivery' deliveryId = deliveryId }], deliveryAddressCommands] flatten // Create order lines let orderLineCommands = order.get.cart select cartItem => let orderLineId = newid from [{ type = 'createOrderLine' orderLineId = orderLineId deliveryId = deliveryId productNumber = cartItem.sku description = cartItem.name image = cartItem.imageUrl quantity = cartItem.quantity unitPrice = cartItem.price.gross skus = { (cartItem.sku) -> 1 } },{ type = 'setOrderLineTax' orderLineId = orderLineId salesTax = (cartItem.price.gross - cartItem.price.net) * cartItem.quantity }] flatten // Emit command to order actor from messageActor( 'order', 'new', [{ type = 'create' body = { orderNumber = order.get.reference createdAt = order.get.createdAt currencyCode = order.get.total.currency taxIncluded = true commands = [ deliveryCommands orderLineCommands ] flatten } }] ) ``` This component declares the expected payload structure, maps delivery and order line data and emits commands to the order actor. However, it does not directly mutate state. Actors validate and persist state in the graph. ## Step 4: Update the manifest Open `h_app.yaml` and Add: ```yaml id: hantera-test-app name: Hantera Test App description: Give architectural understanding of Hantera App structure authors: - Hantera extensions: portal: ./portal components: - id: orders.hrc ingresses: - id: orders componentId: orders.hrc acl: - actors/order:create type: http properties: route: hantera-test-app/orders httpMethod: POST body: mode: structured isPublic: true ``` The manifest registers the Reactor component, exposes it via HTTP ingress, grants permission to create orders, and enables structured body validation. It links ingress, component, and actor permissions together. ## Step 5: Start the app Run in development mode: ```bash h_ app dev ``` ### Step 5.1: Send the external payload request: ![External Payload Request](../../public/images/apps/external-payload-request.png) ### Step 5.2: View the order in the portal Because the portal extension was enabled, the created order can now be viewed in Hantera’s portal. ![Hantera Portal Order View](../../public/images/apps/portal-order-view.png) You have now gone from an external webhook payload to a persisted order visible in Hantera using Apps. --- --- url: /resources/apps/reactor-component-structure.md --- # Reactor Component Structure Reactor components are typically defined as `.hrc` files and provide imperative backend logic within an app. Reactor components follow a common shape: 1. Declare input parameters (`param`) to define what the ingress must provide. 2. Transform incoming payloads into structured commands. 3. Send the command set to an actor using `messageActor`. They may also query graph state or read values from the registry as part of their logic. Reactor components do not persist state directly. Instead, they emit commands to actors, and the actors are responsible for validating and storing data in the graph. ## Input parameters Parameters define the component’s expected inputs. They can be primitives or nested structures. ```filtrera param debug: boolean = false param order: { ... } param secret: text ``` ## Building actor commands from the payload Components do not persist state directly. Instead, they assemble the command objects required by an actor. In [Create Your first app](/), the component creates an order by sending a create message to the order actor. That create message contains the commands needed to build the order. For example, order lines are created from the incoming cart items: ```filtrera let orderLineCommands = order.get.cart select cartItem => let orderLineId = newid from [ { type = 'createOrderLine' ... }, { type = 'setOrderLineTax' ... } ] flatten ``` ## Dependency chain inside commands Some commands depend on earlier state. Order lines require a `deliveryId`, so delivery must be created first: ```filtrera let deliveryId = newid let deliveryCommands = [[{ type = 'createDelivery', deliveryId = deliveryId }], deliveryAddressCommands] flatten ``` The final order message composes both command sets: ```filtrera commands = [deliveryCommands, orderLineCommands] flatten ``` For details on Filtrera syntax (match, flatten, select, etc.), see the [Filtrera reference](https://www.filtrera.io/). --- --- url: /resources/apps/portal-extensions.md --- # Portal Extensions A portal extension is a Vue + TypeScript module bundled in a Hantera app. When the app is installed, Hantera loads the extension and calls the default export in `portal/index.ts` with a Portal instance. From there, the extension can: * Register UI components into existing portal slots * Register custom views and navigation entries * Register services that the portal can call (service contracts) ```ts export default function (portal: Portal) { portal.registerComponent(apps.orderViewSlots.delivery.footer, MyComponent); } ``` ## Registering a component The portal exposes predefined “slots” which act as extension points in existing UI surfaces. Slots are grouped under a few namespaces in `apps`, such as: * apps.orderViewSlots * apps.standardServicesSlots * apps.workspaceSlots * apps.legacySlots * apps.defineService When you register a component to a slot, the portal renders your component in that location. ```ts portal.registerComponent(apps.orderViewSlots.delivery.footer, MyComponent); ``` This tells the portal: > When rendering the delivery footer in the order view, mount MyComponent here. ### Accessing slot context inside the component Each slot gives your component a typed context within the UI surface.Inside the component, the context has to request for the same slot: ```ts const ctx = apps.componentContext(apps.orderViewSlots.delivery.footer); const delivery = ctx.delivery; ``` Here’s what is happening: * `apps.orderViewSlots.delivery.footer` identifies the UI surface. * `componentContext(...)` returns the typed runtime context for that surface. * Because this slot belongs to the order delivery view, the context includes delivery-related data. ****for example:**** ```vue ``` The component does not fetch this data manually. The portal injects the delivery number through the slot context. ## Registering views A view is a registered UI route in the portal. Views are implemented as Vue components and registered from `portal/index.ts`. ```ts const testView = portal.registerView("test", Test); ``` `registerView()` returns metadata about the view, including its fullPath, which can be used to open the view from navigation. ## Registering NavHub entries NavHub entries add navigation items in the portal. Typically, you register a section and an item that opens your view. ```ts portal.registerNavHubSection({ id: "test", items: [ { label: "My test view", action(viewContext) { viewContext.openView(testView.fullPath, {}); }, }, ], }); ``` ### View context and state Each view has a context object that includes route information and a state object. The state can be used to store view-specific values while the view is active. ```ts const context = useContext(); if (!context.currentView.value!.state.opened) { context.currentView.value!.state.opened = Date.now(); } ``` ## Registering a service A service is how an app exposes functionality to the portal through a contract. Instead of importing and syncing external data into Hantera, the portal can call your service at runtime and use the returned results inside standard UI flows. Services are registered from `portal/index.ts`: ```ts portal.registerService(apps.standardServices.productsService, (serviceContext) => ({ lookup(productNumber: string) { // ... }, order(order, phrase: string) { // ... }, search(phrase: string) { // ... }, })); ``` ### Where service code runs Services are registered in `portal/index.ts` as part of the portal extension. They run inside the portal runtime, but they are not executed inside a specific Vue component’s setup context. **This means:** A service implementation cannot use component-only composition hooks such as `useContext()` or `useAppContext()`. Instead, the portal provides a `serviceContext` object when the service is registered. Use `serviceContext` for portal-facing utilities such as: * ****serviceContext.processes:**** Used to run tasks with visible progress overlays, report errors, and surface long-running work in the portal UI. * Any other portal-provided helpers exposed through the service context. Example: ```ts portal.registerService(apps.standardServices.productsService, (serviceContext) => ({ async lookup(productNumber: string) { serviceContext.processes.start({ code: "LOOKUP_STARTED", message: "Looking up product..." }) // fetch logic here return [] } })) ``` ### Consuming services inside Vue components Although services are defined in `index.ts`, they can be consumed from Vue components. **Example inside a .vue file:** ```ts const appContext = useAppContext() const productServices = appContext.getServices( apps.standardServices.productsService ) const product = productServices.length > 0 ? await productServices[0].lookup("palissade-table-iron-red-small") : null ``` Here: * The Vue component uses useAppContext() to retrieve registered services. * The component calls lookup(). * The service runs in the portal runtime and returns data back to the component. ### Multiple providers Multiple apps can register the same service contract. The portal can merge results from multiple providers into a unified suggestion list. ### The products service contract The standard products service contract is designed to provide product suggestions in different contexts. It typically exposes three methods: #### Lookup Use lookup when the portal already has a specific product number and wants details to display or validate. ****Input****: a known productNumber ****Output****: a single product suggestion (or null if not found) ****Example****: ```ts lookup(productNumber: string) { return Promise.resolve({ productNumber, description: "Test lookup", }); } ``` #### Order Use order when the user is editing an order and needs product suggestions while searching. This is the **order editor** path, so it is where you usually want richer behavior: * tailor results based on order context * tailor pricing, tax behavior, availability, or localization * return SKUs in the structure the portal expects for order lines ****Input****: the current order context + a search phrase ****Output****: a list of `OrderProduct` suggestions ****Example:**** ```ts async order(order, phrase) { const response = await fetch(``), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ query: `query GetProducts { browse { product(term: "${phrase}") { hits { variants { sku firstImage { url } name defaultPrice } } } } }`, }), }); if (!response.ok) { serviceContext.processes.error({ code: "ERROR", message: "Product lookup failed", }); return []; } const hits = (await response.json()).data.browse.product.hits as { variants: { sku: string; firstImage: { url: string }; name: string; defaultPrice: number }[]; }[]; return hits .flatMap((h) => h.variants) .map((v) => { const skus: Record = {}; skus[v.sku] = new Decimal(1); return { description: v.name, productNumber: v.sku, imageUrl: v.firstImage.url, skus, unitPrice: new Decimal(v.defaultPrice), taxFactor: new Decimal(0), }; }); } ``` #### Search Use search for product suggestions outside an order context. For: * claims/returns flows * a product picker that is not tied to an order editor ****Input:**** a search phrase ****Output:**** a broader list of suggestions (contract-specific type) ****Example:**** ```ts search(phrase: string) { return Promise.resolve([]); } ``` #### Placing external endpoints and secrets If your service needs an external endpoint or key (URL, token, etc.), don’t hardcode it. Prefer app settings in `h_app.yaml` so it can be configured per tenant, then read it at runtime (via registry-backed settings). This keeps the portal extension portable and avoids baking environment values into code. Refer to [App Settings](/resources/apps/app-configuration-model#app-settings) --- --- url: /resources/apps/packaging-and-deployment.md --- # Packaging & Deployment ## Packing Apps are packed using the Hantera CLI. Packing bundles all declared resources into a single distributable artifact. This includes: * Components (reactor, rule, discount) * Ingresses * Registry entries * Job definitions * Portal extension files (static UI assets) To create a package, run: ```bash h_ app pack -v ``` This command generates a `.hapk` file. The `.hapk` artifact contains the app manifest and all referenced resources, ready for installation. ## Installation The `.hapk` file can be installed through the Hantera portal UI. After installation, the portal displays metadata from the manifest, including: * App ID * Name * Authors * Version * Declared settings Once installed, the app’s resources (components, ingresses, registry entries, job definitions, and portal extension) are applied to the system. ## Updating an App To update an app: 1. Modify the app source (components, registry entries, settings, etc.). 2. Repackage the app with a new version number: ```bash h_ app pack -v ``` 3. Install the new `.hapk` file. The App ID is used to identify the application. When a new version with the same App ID is installed, it replaces the currently active version. After installation, the latest version becomes the active one. For full CLI usage and command reference, see the [Hantera CLI documentation](/learn/hantera-cli) --- --- url: /resources/apps/development-and-troubleshooting.md --- # Development Workflow & Troubleshooting ## Local development workflow After creating a new app using: ```bash h_ app new ``` You can run the app in local development mode with: ```bash h_ app dev ``` This starts a development version of your app from your local folder and continuously rebuilds it as you make changes. To see those changes inside Hantera’s UI, you also need to enable **development mode** in the portal. When portal development mode is enabled, the portal loads your running dev app instead of the installed `.hapk` version. This is why development mode is useful for troubleshooting: * you can iterate on components, ingresses, rules, registry entries, and portal extension code * and immediately verify the effect in the portal before packaging and installing a new version. While development mode is enabled, it temporarily overrides the installed app version in the portal. To ship changes permanently, you still need to pack and install a new `.hapk`. ## Troubleshooting ### 1. ACL Permission Errors **Symptoms:** * Component executes but fails at runtime * Portal shows permission error * Ingress returns `403` or unauthorized-style failure **Typical Causes:** * Missing actor permission * Missing registry read permission * Missing `graph/*` access **Example:** ```yaml acl: - actors/order:create ``` But component also reads: ``` registry/apps//settings/webhookSecret ``` Without: ``` - registry/apps//settings/webhookSecret:read ``` **Fix:** Add explicit registry or graph permission to the ingress ACL. ### 2. Structured Body Mismatch **Symptom:** * HTTP request hits ingress * Component parameters are null * Validation error * "Missing parameter data" **Typical Causes:** Ingress body mode set to: ```yaml body: mode: structured ``` But incoming JSON didn’t match component `param` structure. If component expects: ```filtrera param order: { id: text cart: [...] } ``` And the payload shape differs, parameter binding fails. **Fix:** * Match the JSON body to the declared param structure * Or adjust the param contract to match the incoming JSON ### 3. Ingress component has parse errors **Symptoms:** * Ingress request returns 404 * Response body: Ingress component has parse errors **Typical Causes:** * The referenced `.hrc` or `.hrl` file contains invalid Filtrera syntax * Incomplete let expression * Mismatched braces or brackets * Invalid keyword usage If the component cannot be parsed, the ingress cannot invoke it. **Fix:** * Open the component file and correct the syntax error * If you recently changed parameter names, confirm you reference declared params directly (for example `order.get...``, not `data.order...\`\`) ### 4. Manifest Validation Errors **Symptoms:** * `h_ app dev` or `h_ app pack` fails * CLI throws JSON deserialization error in Development mode * Error mentions missing required properties **Example Error:** ```bash JSON deserialization for type 'AppManifestHttpIngress' was missing required properties including: 'properties'. ``` **Typical Causes:** A required field in `h_app.yaml` is missing. For HTTP ingresses, the properties block is mandatory. **Fix:** Add required `properties` block: ```yaml ingresses: - id: orders componentId: components/orders.hrc type: http properties: route: /my-app/orders httpMethod: POST ``` --- --- url: /resources/components.md --- # Components Components are the building blocks of custom logic in Hantera. They contain code written in Filtrera that can be executed in different contexts to automate workflows, expose APIs, enforce business rules, and calculate dynamic pricing. ## Use Cases ### Jobs Components are wrapped in [job definitions](/resources/job-definitions) to enable scheduling: * Process orders at night * Generate reports * Clean up old data * Sync with external systems [Learn more about Job Definitions →](/resources/job-definitions) | [Learn more about Jobs →](/resources/jobs/) ### Ingresses Expose components as APIs: * Custom HTTP endpoints * Webhook handlers * Message queue processors * Integration APIs [Learn more about Ingresses →](/resources/ingresses/) ### Rules React to system events and actor lifecycle hooks: * Execute logic when orders are created, updated, or deleted * Trigger actions on payment captures or refunds * Automate workflows based on entity state changes [Learn more about Rules →](/resources/rules/) ### Promotions Calculate dynamic discounts and display promotional messages on orders: * Promotions re-evaluate whenever an order changes * Pure calculation components (no side effects) * Support both discount effects and customer-facing messages * Combination rules control how promotions interact [Learn more about Promotions →](/resources/actors/order/promotions) ## Access Control The ACE for components looks like this: ``` components[/``]:read|write ``` --- --- url: /resources/components/runtimes.md --- # Runtimes Reference ## Keywords ## Modules ## Rule Effects ## Rule Hooks ## Types --- --- url: /resources/files.md --- # Files The *Files* resource class allows storage of large binary files or objects, similar to Amazon S3. Files are accessible through the graph, where edges with other nodes can be configured to give the file even more context. Files can hold *metadata* and *dynamic fields*. Dynamic fields are also available in the Graph allowing custom fields to be defined for further filtering capabilities. ## Access Control The ACE for components looks like this: ``` files[/``[/``]]:read|write ``` --- --- url: /resources/graph.md --- # Graph Hentera's Graph is a simple yet powerful way of querying for almost all data in Hantera. Using very simple declarative object structures, we can describe a query that Hantera will perform for us. Being a graph, we can navigate between entities as we wish to retrieve related data as we need it. Filters and sorting can be applied at every navigation and you choose which fields you're interested in. The main HTTP endpoint for making Graph queries is: ``` GET /resources/graph ``` ## Base Sets A the start of any query, you need to begin by targeting one of the base sets. These are the sets that provide universal access to nodes of a certain type, for example, Orders or Payments. Every type has a set, usually named the same as the type but in plural. To list all available sets and get additional metadata, such as search-indexed fields, use the below query: ``` GET /resources/graph/meta ``` Otherwise, refer to the Graph Types section in the navigation. ## Query Format A query is defined in JSON and declaratively expresses the query, not so different from what you may be used to using SQL. Although the syntax is vastly different. The main idea is that you specify the criterias for the nodes you want, and the Graph will return the resulting matching nodes. What you may not be used to, is how easy it is to fetch related data. While SQL is generally limited to resulting in a table, making joins sometimes rather verbose with a lot of duplicated data, Hantera's Graph can return a more natural nested object structure. A query is made up of a navigation, with arguments that define filters and sort orders, a list of fields to return, and potentially nested navigations to fetch related data. ### Example: Querying Orders A simple query that fetches the 10 most recent confirmed orders, their deliveries and order lines, may look like this: ```json [{ "edge": "orders", "alias": "o", "filter": "orderState == 'confirmed'", "count": false, "limit": 10, "node": { "fields": ["orderId", "orderNumber", "channelKey", "customerNumber", "orderTotal", "invoiceAddress.name"], "navigate": [{ "edge": "deliveries", "alias": "d", "count": false, "node": { "fields": ["deliveryId", "shippingTotal", "dynamic->'myDynamicField'"], "navigate": [{ "edge": "orderLines", "node": { "fields": ["orderLineNumber", "productNumber", "quantity", "unitPrice", "orderLineTotal"] } }] } }] } }] ``` ### Required Navigations Each navigation supports a `require` argument that gates the parent based on what's linked via the edge. It has three modes: * `any` (default) — no gate. * `some` — parent returned only if at least one matching child exists (inner-join semantics). * `none` — parent returned only if no matching child exists (anti-join semantics). See [Require](/resources/graph/require) for the full reference, including interactions with `filter`, `orderBy`, and cardinality. ## Authorization The Graph maps onto Hantera's resource tree based on [node types](/resources/graph/nodes/) as such: ``` graph/``/`` ``` Each node has a primary key, usually a UUID. This can be used to allow access to only specific nodes in the graph. ### Query Permissions In order to be able to query for nodes, a session must have the `query` permission. For example, the below ACE allows querying of `order` nodes: ``` graph/order:query ``` With query permission you can filter on any field and retrieve the total count. It's allso a pre-requisite to retrieve field data. To give access to an order with `orderId` `db974a97-e5ca-4114-95de-2a9ade600257`, use: ``` graph/order/db974a97-e5ca-4114-95de-2a9ade600257:query ``` ### Field Permissions The query permissions authorizes queries, but no field information. In order to authorize fields, use the `field` permission with optional sub-permission for each field: ``` graph/order:field:orderNumber ``` The above ACE authorizes the field `orderNumber` to be retrieved on `order` nodes. ::: info Field permissions can only be set on node types, not on specific nodes. This means that the following ACE is invalid: ``` graph/order/db974a97-e5ca-4114-95de-2a9ade600257:field:orderNumber ``` ::: #### Complex Fields Some fields are objects, examples are `dynamic` and `invoiceAddress`. While these are referenced using dot/arrow-notation when querying, the ACE for them are using colon. This allows permissions to be given on all sub-fields: ``` graph/order:field:dynamic ``` Or just a single dynamic field: ``` graph/order:field:dynamic:myField ``` ### Attribute Based Access [ABAC](/learn/access-control#attribute-based-access-control-abac) is supported for the `query` permission. This means that querying of nodes can be restricted based on user access attributes. All node fields are available as attributes. For example: ``` channelKey@graph/order:query ``` An even more general ACE can be specified. In this case, the ACE is ignored if the `channelKey` attribute doesn't exist on the given node type: ``` channelKey@graph:query ``` ## GraphQL Hantera also provides a GraphQL interface for the Graph, which can be accessed at the `/graphql` endpoint. Refer to the introspection API for more details. We generally recommend using the JSON-based interface for querying due to its more consistent integration with actor queries and more universal support. The GraphQL provides a convenient alternative for exploring the graph using a GraphQL client, or for federated scenarios. --- --- url: /resources/graph/filtering.md --- # Filtering Filters can be applied on any navigation. The syntax used for filtering is a sub-set of [Filtrera](https://www.filtrera.io/). Below is a list of operators and keywords supported by the query engine. | Operator | Description | |----------|-------------| | `==` | Equal | | `!=` | Not equals | | `<` | Less than | | `<=` | Less than or equal | | `>` | Greater than | | `>=` | Greater than or equal | | `and` | Logical "and", matches if both operands are true | | `or` | Logical "or", matches if any operand is true | | `not ` | Inverse a boolean expression | | `anyof [, ]` | Matches arrays that contain any of the provided value, or matches multiple values aginst a scalar field | | `allof [, ]` | Matches arrays that contain all of the provided value | ## Array Filtering Examples Use `anyof` to match nodes that have any of the specified values: ``` tags anyof ['urgent', 'high-priority'] ``` Use `allof` to match nodes that have all of the specified values: ``` tags allof ['completed', 'verified'] ``` ### Combining Array and Value Filters Array filters can be combined with other conditions: ``` tags anyof ['fraud-suspected'] and createdAt >= now - 1 days ``` ## `now` keyword `now` can be used to reference the current time. This can be combined with basic arithmetic and durations to query for relative ranges. To filter for nodes created the last 7 days (assuming the node has a `createdAt` field): ``` createdAt > now - 7 days ``` --- --- url: /resources/graph/require.md --- # Require The `require` argument on a navigation controls whether the parent node is returned based on which of its related nodes are linked through the edge. It turns a navigation from a "return child data if any" expansion into a **filter on the parent**. Every nested navigation supports `require`. It is declared alongside `filter`, `limit`, `orderBy`, etc. ## Values The `require` argument accepts a small set of JSON forms. All of them normalize to one of three modes: `any`, `some`, or `none`. ::: info The boolean shorthand `true` exists for historical reasons and is equivalent to `"some"`. Prefer the explicit string form in new queries. ::: ## `require: "some"` With `require: "some"` the navigation behaves like an inner-join filter on the parent. The parent is only included in the result when at least one child exists and that child passes the navigation's own `filter`. The child branch in the output still reflects what the client asked for — the same set of matching children that was used to gate the parent. ### Example: orders that have at least one confirmed delivery ```json [{ "edge": "orders", "node": { "fields": ["orderNumber"], "navigate": [{ "edge": "deliveries", "require": "some", "filter": "deliveryState == 'confirmed'", "node": { "fields": ["deliveryNumber"] } }] } }] ``` ### Ordering by a nested field Ordering the parent by a field on a nested many-edge navigation is only possible when that navigation is `require: "some"`. This ensures the value used for sort order is well-defined. ```json [{ "edge": "orders", "orderBy": "customer.name asc", "node": { "fields": ["orderNumber"], "navigate": [{ "edge": "customer", "alias": "customer", "require": "some", "node": { "fields": ["name"] } }] } }] ``` If you attempt to order by a nested field without `require: "some"` on that navigation, the query is rejected with: ``` Ordering by '' not allowed. To allow order, mark navigation with require='some'. ``` ## `require: "none"` With `require: "none"` the navigation behaves like an anti-join. The parent is only included when **no** matching child exists. This is the mechanism for "find parents that lack a certain relation". Because a `none` navigation never produces output, several other navigation options are incompatible with it and will be rejected during validation: | Option | Allowed under `none`? | | --- | --- | | `fields` | ❌ A `none` navigation produces no output. | | `orderBy` | ❌ No rows to order. | | `phrase` | ❌ No rows to search. | | `limit` | ❌ No rows to limit. | | `after` / `before` | ❌ No cursor applies. | | `count` | ❌ Nothing to count. | | `filter` | ✅ Defines what counts as a "matching" child. | | Nested `navigate` | ✅ But every nested navigation must itself specify `require` (see below). | ### Nested navigations under `none` A navigation with `require: "none"` may contain nested navigations, but every one of them must declare `require` explicitly as `"some"` or `"none"`. The default `any` is not allowed under `none` — it would produce no output anyway, and the explicit form makes the intent clear. The nested navigation contributes to the definition of "matching child" — the anti-join searches for children that pass the `filter` **and** satisfy their own nested requires. A child only qualifies for the anti-join if all of these hold. ### Example: orders with no cancelled delivery ```json [{ "edge": "orders", "node": { "fields": ["orderNumber"], "navigate": [{ "edge": "deliveries", "require": "none", "filter": "deliveryState == 'cancelled'" }] } }] ``` ### Example: orders that have no payment with a successful authorization This shows nested requires under a `none`: "give me orders that do not have any payment which has at least one successful authorization". ```json [{ "edge": "orders", "node": { "fields": ["orderNumber"], "navigate": [{ "edge": "payments", "require": "none", "navigate": [{ "edge": "authorizations", "require": "some", "filter": "authorizationState == 'successful'" }] }] } }] ``` ## `require: "any"` (default) `any` is the default and means no gating: the parent is returned regardless of the children, and the navigation branch is still emitted in the output — as an object or array, depending on cardinality, and as `null` or `[]` when no children are linked. Use `any` (or simply leave `require` out) whenever you want data expansion without affecting which parents are returned. ## Cardinality and edge direction `require` works on edges of any cardinality, but the semantics differ slightly: * **Many-edges (`single-to-many`, `many-to-many`)** * `some` — the parent is kept if at least one linked row matches. * `none` — the parent is kept only if no linked row matches. * **Single-edges (`single-to-single`, `many-to-single`)** * `some` — the linked record must exist and match. * `none` — the linked record must either not exist or not match. ::: info On single-edges, `filter` is only allowed when `require` is `"some"` or `"none"`. A `filter` on a single edge without `require` is rejected: ``` Filter is not allowed on single optional edges at '' ``` Add `require: "some"` to apply the filter as a parent filter, or `require: "none"` to invert it. ::: ## Interaction with `filter` `filter` and `require` on the same navigation cooperate: 1. `filter` decides what counts as a **matching** child. 2. `require` decides what **the existence** of matching children means for the parent. A `filter` without `require` on a many-edge simply restricts the children in the output — the parent is always returned. Pair it with `require: "some"` to also remove parents that have no matching child. ## See also * [Filtering](/resources/graph/filtering) --- --- url: /resources/graph/validation.md --- # Validation By default, Graph queries are validated **strictly**: any problem — an unknown field, an unknown navigation edge, an invalid filter — fails the request with a `400` error. This is usually what you want for queries authored in code, since problems surface immediately. When queries are composed by users, however, strict validation can be hostile. A saved query that references a field which has since been removed from the model would fail entirely, even though the rest of the query is perfectly fine. For those scenarios, the Graph endpoint accepts a `validation` query parameter: ``` POST /resources/graph?validation=warn ``` In `warn` mode, the query is **sanitized**: invalid parts are removed or neutralized, the sanitized query is executed, and all discovered problems are returned as **warnings** alongside the result. ## Modes | Mode | Behavior | | --- | --- | | `strict` (default) | The first validation problem fails the request with `400` and an error body. | | `warn` | Invalid parts are repaired (see below), the query executes, and problems are returned as warnings. | ## Warn-mode repairs | Problem | Repair | Warning code | | --- | --- | --- | | Unknown field in `node.fields` | Field is dropped (warning keeps the "Did you mean 'x'?" suggestion) | `INVALID_FIELD` | | Unknown edge in `node.navigate` with `require='any'` (default) | Sub-navigation including its subtree is dropped | `INVALID_EDGE` | | Unknown edge in `node.navigate` with `require='some'` or `'none'` | Sub-navigation is dropped and the **parent matches nothing** — an unevaluable gate never widens the result | `INVALID_EDGE` | | Invalid `filter` | Filter is replaced by a constant-false predicate — it **matches nothing** | `INVALID_FILTER` | | Invalid `orderBy` | `orderBy` is cleared (result is unordered) | `INVALID_ORDER_BY` | | `phrase` longer than 100 characters | `phrase` is cleared | `INVALID_PHRASE` | | Options not allowed with `require='none'` | Offending options are cleared | `INVALID_OPTION` | | Unknown root `edge` | No query is executed; the result is empty (`nodes: []`) | `INVALID_EDGE` | ::: warning Invalid filters match nothing Dropping an invalid filter would return *more* data than requested, which could be dangerous. A filter that cannot be understood therefore behaves as if it matched nothing. ::: ## Response shape Warnings are returned per query, nested inside that query's result object. The `warnings` key is only present when warnings exist: ```json { "orders": { "nodes": [], "warnings": [ { "code": "INVALID_FIELD", "message": "Field 'nam' not recognized. Did you mean name?" } ] } } ``` * **JsonTable format** (`?format=jsontable`): warnings are available on the optional `warnings` property of the table object. * **CSV format**: warnings are returned in the `X-Query-Warnings` response header as a JSON array. ## Scope The validation mode only applies to the Graph HTTP endpoint. Filtrera `query` usage in components (rules, reactors, jobs) is always strict, as are actor query messages and live queries. --- --- url: /resources/graph/custom-fields.md --- # Custom Fields Dynamic fields are used across most nodes to model arbitrary/dynamic data added to the system. Due to the dynamic nature of these fields, the system does not automatically know how to index and treat these values. So in order to enable filtering and sorting by dynamic fields, they must be defined as *custom fields*. Once defined, a custom field appears as a regular system field on the node it belongs to. A custom field *maps* a dynamic field, and updating the field's value is a matter of updating the underlying dynamic value. A custom field can source from any dynamic-fields map on the node. Most nodes expose their dynamic data through the `dynamic` field, but some nodes use other fields — for example, the `jobs` node exposes its dynamic data through `parameters`. Custom fields have a defined type. If the underlying value is not compatible with this type, the field will be regarded as *NULL* in terms of sorting and filtering. Additionally the field will be empty upon returning. This ensures that you can trust the Graph to return a valid value or nothing, but never an invalid value. Custom fields does not apply any restriction on the underlying dynamic field. It's still perfectly fine to write an invalid value. Similarly, a custom field can be defined over a dynamic field that doesn't exist, and will start to return values once a valid value has been set. ::: info Custom Fields is a Graph-only feature. That means that Rules do not have access to custom fields. Use the underlying dynamic field in rules instead. ::: ## Dimensions Custom fields can be configured to use one of the supported dimensions: * `locale` for translated values * `channel` for values that are unique between channels If a field is defined with a dimension, the underlying value is expected to be an object with a property mathing the dimension's key. Not all dimensions must be present. It's fine to only provide the dimensions that have an actual value. For more info about dimensions, refer to [Dimensions](/learn/dimensions). ### Querying Dimensions When querying a dimensional field, you may chose to get all dimensions, or specific ones. To get all dimensions, simply specify the field itself. If you want one or more dimensions, separate the field and dimension key with a period. When filtering however, you must specify the dimension you want to filter by. Example: ```json [{ "edge": "orders", "filter": "myField.b2b == 'value'", "node": { "fields": ["myField"], } }] ``` ## Types ## Configuring a Custom Field Custom Fields are defined in the [Registry](/resources/registry/) using the following key pattern: ``` graph/``/fields/`` ``` The value should be an object with the following properties: ## Index Creation When a field has been defined in the registry, the index will be created in the background. The field will be available for filtering and sorting once the index is complete. This can take anything from a few seconds to hours depending on the size of the dataset. To follow the progress, you can refer to the [System Signals](/resources/registry/reference/signals). Also, the graph meta model will tell you when the index is valid by marking the field as filterable. ## Troubleshooting After a field has been defined, it should be pretty much immediately available in the graph (even if the index has not been completed). If the field doesn't show up, there should be an error in the signals explaining what the problem is: ```bash h_ manage signals ``` ## Example Here's an example manifest for defining a Custom Field: ```yaml uri: /resources/registry/graph/sku/fields/name spec: type: text source: dynamic->'name' dimension: locale ``` And one defining a Custom Field over a job parameter: ```yaml uri: /resources/registry/graph/job/fields/channelKey spec: type: text source: parameters->'channelKey' ``` --- --- url: /resources/graph/custom-edges.md --- # Custom Edges The Graph comes with a set of natural, built-in edges between nodes. Additionally, [Actor Extensions](/resources/actors/actor-extensions) can define custom relations between nodes. However, there are cases where you want to create an edge based on an implicit relationship, or based on an existing system field. One such case might be to create a relationship between orderLine's productNumber and a [sku](/resources/graph/nodes/sku). This is a common practice for businesses that sell single-sku products. Any field including [custom fields](/resources/graph/custom-fields) can be used by custom edges, except dynamic fields. If you have a dynamic field that you want to use as the relationship key between nodes, define it as a custom field first. ## Configuring a Custom Edge Edges are configured in the [Registry](/resources/registry/) using keys as follows: ``` graph/``/edges/`` ``` The value should be an object with the following properties: ## Two-way Edges Note that custom edges in Hantera are one-way by nature. In order to make a two-way edge, simply define the oppsite edge on the related node. ## Troubleshooting After defining a custom edge, it should show up in the graph pretty much immediately. If you can't see it, check signals to see it has reported any error: ```bash h_ manage signals ``` ## Example Here's an example manifest for defining a custom edge: ```yaml uri: /resources/registry/graph/orderLine/edges/sku spec: field: 'productNumber' relatedSet: 'skus' relatedField: 'skuNumber' cardinality: 'single' --- uri: /resources/registry/graph/sku/edges/orderLines spec: field: 'skuNumber' relatedSet: 'orderLiones' relatedField: 'productNumber' cardinality: 'many' ``` --- --- url: /resources/graph/phrase-search.md --- # Graph Phrase Search Phrase Search is when the graph is queried using the `phrase` argument. This argument uses a pre-calculated search index to find entities. The index can be configured to include edges, so that when you search for orders, but get a hit on an order's delivery, the system will still return the order. ## Configuring and Managing Search Indexes Before a phrase search can be successfully performed, the search index must be prepared. Hantera normally comes with a basic standard configuration, which can be adjusted for your needs. To configure a search index for a given graph node, use the [`/system/graph//search`](/resources/registry/reference/) registry key. Note that nodes related to *typed actors* are separately indexed. Once the search index is configured and applied, you need to trigger an index update. This can be done using [hantera-cli](/learn/hantera-cli). ```bash h_ manage update-search [-s ``] `` [--rebuild] ``` For example, to update the order search index: ```bash h_ manage use `` h_ manage update-search order ``` The `--rebuild` flag can be used to completely rebuild an index. This is generally never needed but can be used in case there's something wrong with the existing index. ::: warning A full rebuild will render search partially unavailable during the rebuild. A full rebuild might take anything from a few seconds to several hours depending on environment size. ::: If you want to see the status of an ongoing update, or just check the status of the search indexes in general, you can check Hantera's signals: ```bash h_ manage signals graph -w ``` ## Performing Phrase Search When using the Graph to query, you may use the `phrase` argument to add phrase search filtering to the result. Phrase filter is applied in combination with any `filter` you might apply as well. `orderBy` is not allowed in combination with `phrase`. Records are automatically sorted by search rank, which is basically how well the search phrase matches the node. ### Example ```json [{ "edge": "orders", "phrase": "ORD1234", "limit": 1, "node": { "fields": ["orderId", "orderNumber"] } }] ``` ### Nested Search If a node search index is configured to contain edges, it's possible to make a search on one set that matches a navigation. To expand on our previous example, let's say that `order` search index has edge `delivery` configured, and `delivery` has field `deliveryAddress.name` indexed, the following query will search both `orderNumber` and `deliveries.deliveryAddress.name` and only return orders where either match: ```json [{ "edge": "orders", "phrase": "John Doe", "limit": 1, "node": { "fields": ["orderId", "orderNumber"], "navigate": [{ "edge": "deliveries", "node": { "fields": ["deliveryAddress.name"], } }] } }] ``` ### Phrase Matching Logic Each separate word in a search must match a node for it to be returned, in essence the the words are `AND`ed. For fields that are indexed as *non-keywords*, any word less than 3 characters are ignored. #### Prefix Matching Wildcards (`*`) can be used to match the beginning of a word. For example, `phrase*` matches any word that begins with "phrase". #### Exact Matching Single or multiple words can be quoted (single or double quotes) to search for exact phrases: * `"phrase"` - Matches words that are exactly "phrase" * `"John Doe"` - Matches exactly "John Doe" appearing in exactly that order ::: info Exact matching is case insensitive, just like all other phrase searches. ::: --- --- url: /resources/graph/csv-formatting.md --- # CSV Formatting Normally, a graph query will be returned as a nested JSON-serialized object graph. This representation truthfully maps the data to the structure of the query. Sometimes however, it's more useful to retrieve the data as a 2D CSV table. ## How to Request CSV Result There are two ways to tell the graph query API that you want the result as csv: * Add a `?format=csv` query parameter to the `/resources/graph` endpoint request * Add a `Accept: text/csv` header in the `/resources/graph` endpoint request ## Limitations When returning CSV, it's important to understand the limitations: * Only a single query can be included in the request * For any navigations, only the first "many" navgiation on the same level will be expanded. If there are more, only the first node will be returned. * Total count is only available of the root node ## Pagination The total count, as well as the first/last cursors are returned as HTTP headers: * `Total-Count` - The total number of available nodes (only available if queried with `"count": true`) * `First-Cursor` - The cursor of the first node * `Last-Cursor` - The cursor of the last node ### Query Overrides To simplify pagination, the `after` and `before` properties of the root query can be overridden using query parameters. This allows you to paginate without having to update the root query. For example: ```json [{ "edge": "deliveries", "alias": "d", "count": true, "node": { "fields": ["deliveryNumber"], "navigate": [ { "edge": "order", "alias": "o", "node": { "fields": ["orderNumber"], } } ] } }] ``` ::: info `after` and `before` query parameter is only available during CSV querying, as a regular request might contain multiple queries at once. ::: --- --- url: /resources/graph/nodes.md --- # Graph Nodes --- --- url: /resources/iam.md description: Identity and access management system for authentication and authorization --- Identity and Access Management (IAM) in Hantera provides a comprehensive system for managing identities, roles, and permissions. IAM is the foundation for authentication and authorization across all Hantera services. ::: tip For detailed API endpoint documentation, see the [OpenAPI browser](/api/http/). This page focuses on domain concepts and integration patterns. ::: ## What is IAM? IAM manages three core concepts: 1. **Identities** - Who can access the system (principals and clients) 2. **Roles** - What permissions identities have 3. **Sessions** - Active authentication sessions IAM provides REST APIs for creating and managing these entities, while the [Graph API](/resources/graph/) provides read access for querying identities. ## Identity Types Hantera supports three types of identities: ### Principal A **principal** represents a human identity that authenticates with Hantera. **Key characteristics:** * Email address (used as username, must be unique) * Password or OAuth authentication * Can be assigned multiple roles * Can be temporarily suspended * Dynamic properties for custom data **When to use:** * Portal users (administrators, staff) * Customer accounts * Partner or vendor users * Any human that needs to log in **Learn more:** [Principals Documentation](/resources/iam/principals) ### Client A **client** represents an application or service identity for OAuth and API access. **Key characteristics:** * OAuth configuration (redirect URIs, grant types, scopes) * Client secret authentication * No password-based login * System clients are protected from modification **When to use:** * Third-party integrations * Mobile or web applications * Service-to-service communication * Webhook consumers **Learn more:** [Clients Documentation](/resources/iam/clients) ## Roles and Permissions ### Built-in System Roles Hantera provides built-in roles that use the `system:` namespace: #### system:owner Full administrative access to all Hantera resources and operations. **Capabilities:** * Manage all identities, roles, and permissions * Access all resources without restrictions * Configure system settings * Perform destructive operations ::: warning Grant `system:owner` only to fully trusted administrators. This role bypasses all authorization checks. ::: #### system:portal:full Complete access to the Hantera Portal and portal-managed resources. **Capabilities:** * Access all portal features * Manage orders, customers, products * View analytics and reports * Configure portal settings **Typical users:** * Portal administrators * Customer service managers * Operations staff ### Custom Roles You can define custom roles with any namespace **except** `system:`. **Role naming pattern:** ``` {namespace}:{capability} ``` **Examples:** * `store:manager` - Store management permissions * `support:agent` - Customer support access * `warehouse:operator` - Warehouse operations * `reporting:viewer` - Read-only reporting access **Custom role definition:** ```json { "key": "support:agent", "description": "Customer support representative", "acl": { "entries": [ { "resource": "actors", "permission": "*" }, { "resource": "graph", "permission": "*" }, { "resource": "registry", "permission": "read" } ] } } ``` ### Role-Based Categorization Roles serve dual purposes in Hantera: 1. **Authorization** - Define what an identity can do 2. **Categorization** - Group identities by function This means you can filter identities by role prefix to create semantic groups: ```javascript // Get all portal users GET /resources/iam/principals?rolePrefix=system:portal: // Get all support agents GET /resources/iam/principals?rolePrefix=support: // Get all store managers GET /resources/iam/principals?rolePrefix=store: ``` This pattern eliminates the need for separate "user type" fields while ensuring categorization always reflects actual permissions. ## Authorization Model ### Required Permissions IAM operations require specific permissions: **Principal management:** ``` iam/principals:read # List and get principals iam/principals:write # Create and update principals iam/principals:delete # Delete principals ``` **Client management:** ``` iam/clients:read # List and get clients iam/clients:write # Create and update clients iam/clients:delete # Delete clients ``` **Role management:** ``` iam/roles:read # List and get roles iam/roles:write # Create and update custom roles iam/roles:delete # Delete custom roles ``` ### Safe Updates with ETags IAM APIs use **ETags** to prevent conflicting updates when multiple users modify the same identity. **Usage:** ```http # 1. Get the identity (response includes ETag header) GET /resources/iam/principals/{id} # 2. Update with If-Match header using the ETag PUT /resources/iam/principals/{id} If-Match: "etag-value-from-step-1" { "properties": { ... } } ``` If someone else modified the identity between your GET and PUT, the request will fail with `409 Conflict`, preventing you from accidentally overwriting their changes. ::: tip Always include the `If-Match` header when updating identities to ensure safe concurrent modifications. ::: ## API Endpoints Summary IAM provides REST endpoints organized by resource type: * **`/resources/iam/principals/*`** - Principal management ([details](/resources/iam/principals)) * **`/resources/iam/clients/*`** - Client management ([details](/resources/iam/clients)) * **`/resources/iam/roles/*`** - Role management For complete endpoint documentation, request/response formats, and error codes, see the [HTTP API Reference](/api/http/). ## Quick Start Examples ### Creating a Portal User ```http PUT /resources/iam/principals/{userId} { "properties": { "name": "John Admin", "email": "john@company.com" }, "roles": ["system:portal:full"] } ``` Then generate a temporary password and send via email using the [Sendings API](/resources/sendings). ### Creating an OAuth Client ```http PUT /resources/iam/clients/{clientId} { "properties": { "name": "Mobile App", "redirectUris": ["myapp://callback"], "grantTypes": ["authorization_code", "refresh_token"] }, "roles": [] } ``` ### Querying Identities Use the Graph API to query identities: ```http POST /resources/graph/query { "resource": "identity", "filters": [ { "field": "roles", "op": "contains", "value": "system:portal:full" } ], "limit": 50 } ``` ## Common Workflows ### User Onboarding 1. **Create principal** ```http PUT /resources/iam/principals/{id} ``` 2. **Assign roles** ```http PUT /resources/iam/principals/{id}/roles ``` 3. **Generate temporary password** ```http POST /resources/iam/principals/{id}/password/reset ``` 4. **Send welcome email** Use [Sendings API](/resources/sendings) to send credentials 5. **User logs in and changes password** ```http POST /resources/me/password ``` ### Application Integration 1. **Create OAuth client** ```http PUT /resources/iam/clients/{id} ``` 2. **Generate client secret** ```http POST /resources/iam/clients/{id}/secrets ``` 3. **Configure application** Use client ID and secret in your OAuth flow 4. **Assign necessary roles** ```http PUT /resources/iam/clients/{id}/roles ``` ## Next Steps * **[Principals Documentation](/resources/iam/principals)** - Deep dive into user management * **[Clients Documentation](/resources/iam/clients)** - OAuth clients and service accounts * **[Access Control](/learn/access-control)** - Permission patterns and best practices * **[Graph API](/resources/graph/)** - Querying identities * **[HTTP API Reference](/api/http/)** - Complete endpoint documentation ## Related Resources * [Identity Graph Node](/resources/graph/nodes/identity) - Query identities via Graph * [Me Endpoint](/resources/me) - Current user information and password management * [Sendings API](/resources/sendings) - Send emails (for password resets) * [Authentication Guide](/learn/authentication) - How authentication works in Hantera --- --- url: /resources/iam/principals.md description: User identity management in Hantera IAM --- A **principal** represents a human identity that authenticates with Hantera. Principals are the primary way users interact with the Hantera platform. ::: tip This page covers principals in depth. For an overview of IAM, see the [IAM Overview](/resources/iam/). ::: ## What is a Principal? Principals represent people who need to access Hantera - whether they're administrators managing the portal, customer service agents, or end customers accessing their accounts. **Key characteristics:** * Email address (used as username, must be unique) * Password or OAuth authentication * Can be assigned multiple roles * Can be temporarily suspended * Dynamic properties for custom data * Active session tracking **Example principal:** ```json { "id": "01933e8f-7c45-7123-9abc-123456789abc", "type": "principal", "properties": { "name": "John Admin", "email": "john@company.com", "phone": "+1234567890", "picture": "https://example.com/photo.jpg", "settings": { "theme": "dark", "language": "en" } }, "roles": ["system:portal:full"], "suspendedAt": null, "lastActiveAt": "2025-01-07T15:30:00Z" } ``` ## When to Use Principals **✅ Use principals for:** * Portal users (administrators, staff) * Customer accounts * Partner or vendor users * Any human that needs to log in **❌ Don't use principals for:** * Application-to-application authentication (use [Clients](/resources/iam/clients) instead) * Service accounts (use [Clients](/resources/iam/clients) instead) * Webhook consumers (use [Clients](/resources/iam/clients) instead) ## List API Features The principals list endpoint supports comprehensive filtering, search, and cursor-based pagination for efficient data retrieval. ### Cursor-Based Pagination All list operations use **cursor-based pagination** for optimal performance with large datasets: **Query parameters:** * `limit` - Items per page (default: 50, max: 100) * `after` - Cursor for next page * `before` - Cursor for previous page **Response format:** ```json { "principals": [...], "totalCount": 245, "lastCursor": "01933e8f-7c45-7123-9abc-123456789abc", "firstCursor": "01933e8f-7c45-7123-9abc-123456789def" } ``` **Example - First page:** ```http GET /resources/iam/principals?limit=50 ``` **Example - Next page:** ```http GET /resources/iam/principals?limit=50&after=01933e8f-7c45-7123-9abc-123456789abc ``` **Example - Previous page:** ```http GET /resources/iam/principals?limit=50&before=01933e8f-7c45-7123-9abc-123456789def ``` ::: tip Use `totalCount` to display result counts. Use `lastCursor` and `firstCursor` for pagination navigation. ::: ### Search Search by name or email (case-insensitive, partial matches): ```http GET /resources/iam/principals?search=john ``` This will match principals with "john" in their name or email address. ### Filtering **Filter by roles:** ```http # Filter by multiple roles (matches principals with ANY of these roles) GET /resources/iam/principals?roles=system:owner&roles=system:portal:full # Filter by single role (convenience parameter) GET /resources/iam/principals?role=support:agent # Filter by role prefix (all roles starting with prefix) GET /resources/iam/principals?rolePrefix=system:portal: ``` **Include suspended principals:** ```http # By default, suspended principals are excluded GET /resources/iam/principals?includeSuspended=true ``` ### Sorting Sort results using the `orderBy` parameter: ```http # Sort by name ascending (default) GET /resources/iam/principals?orderBy=name asc # Sort by email descending GET /resources/iam/principals?orderBy=email desc # Sort by last active date GET /resources/iam/principals?orderBy=lastActiveAt desc ``` **Available sort fields:** * `name` - Principal's name * `email` - Email address * `lastActiveAt` - Last authentication timestamp * `suspendedAt` - Suspension date ::: info The `orderBy` parameter combines field name and direction in a single value (e.g., `name asc`, `email desc`). ::: ### Combined Queries You can combine search, filters, sorting, and pagination: ```http GET /resources/iam/principals?search=admin&roles=system:portal:full&orderBy=name asc&limit=20 ``` This query: * Searches for "admin" in name/email * Filters by `system:portal:full` role * Sorts by name ascending * Returns 20 results per page ## Principal Management ### Create or Update Principal **Full replacement** of a principal (use for initial creation or complete updates): ```http PUT /resources/iam/principals/{id} If-Match: "etag-value" { "properties": { "name": "John Admin", "email": "john@example.com", "phone": "+1234567890", "picture": "https://example.com/photo.jpg", "settings": { "theme": "dark", "language": "en" } }, "roles": ["system:portal:full"], "acl": { "entries": [ { "resource": "orders", "permission": "*" } ] }, "accessAttributes": { "channelKey": ["STORE-NYC"] } } ``` ::: warning The `If-Match` header with ETag is required for updates to prevent conflicting changes. Omit it only when creating a new principal. ::: ### Partial Update Update specific properties without replacing the entire principal: ```http PATCH /resources/iam/principals/{id} If-Match: "etag-value" { "name": "John Updated", "email": "john.new@example.com", "settings": { "theme": "light" } } ``` Partial updates: * Only update specified fields * Merge settings (don't replace them) * Leave other properties unchanged * Require ETag for safety ### Update Roles Replace the entire roles list: ```http PUT /resources/iam/principals/{id}/roles If-Match: "etag-value" { "roles": ["system:portal:full", "support:agent"] } ``` ### Update ACL Replace the entire ACL: ```http PUT /resources/iam/principals/{id}/acl If-Match: "etag-value" { "acl": [ "orders:*", "customers:read" ] } ``` ### Get Principal Details Retrieve a principal with all properties: ```http GET /resources/iam/principals/{id} ``` **Response includes:** ```json { "id": "01933e8f-7c45-7123-9abc-123456789abc", "name": "John Admin", "email": "john@example.com", "phone": "+1234567890", "picture": "https://example.com/photo.jpg", "settings": { "theme": "dark", "language": "en" }, "roles": ["system:portal:full"], "acl": { "entries": [...] }, "accessAttributes": { "channelKey": ["STORE-NYC"] }, "suspendedAt": null, "lastActiveAt": "2025-01-07T15:30:00Z", "createdAt": "2024-01-01T10:00:00Z", "etag": "W/\"abc123\"", "passwordLogin": true, "passwordExpiresAt": null } ``` **Additional fields:** * `passwordLogin` - Whether password authentication is enabled * `passwordExpiresAt` - When the password expires (null for permanent passwords) * `lastActiveAt` - Last successful authentication timestamp * `etag` - Version identifier for safe updates ### Delete Principal Remove a principal permanently: ```http DELETE /resources/iam/principals/{id} ``` Returns `204 No Content` on success. ::: warning **Deletion is permanent** and cannot be undone. Consider [suspension](#principal-suspension) for temporary account deactivation. ::: ### Self-Service Restrictions Principals cannot suspend or delete themselves. If you attempt to suspend or delete your own account, the API returns `403 Forbidden` with the error message: "Cannot suspend your own principal" or "Cannot delete your own principal". This prevents accidental account lockouts and ensures administrative continuity. ## Password Management ### Password Reset Flow Password resets generate **temporary passwords** that expire on first use: 1. **Administrator initiates reset** ```http POST /resources/iam/principals/{id}/password/reset ``` 2. **API returns temporary password** ```json { "temporaryPassword": "TempPass123" } ``` 3. **Administrator sends email to user** Use the [Sendings API](/resources/sendings) to send the temporary password: ```http POST /resources/sendings { "category": "password_reset", "to": "user@example.com", "subject": "Password Reset", "bodyHtml": "

Your temporary password is: TempPass123

" } ``` 4. **User logs in with temporary password** * Session is created but marked as requiring password change 5. **User must change password** ```http POST /resources/me/password { "currentPassword": "TempPass123", "newPassword": "NewSecurePass456!" } ``` ::: info The IAM API generates the temporary password but does NOT send emails. Email sending is the responsibility of the calling application (e.g., Portal) using the Sendings API. ::: ### Password Requirements Passwords must meet these criteria: * Minimum 8 characters * At least one uppercase letter * At least one lowercase letter * At least one digit * No common/weak passwords Invalid passwords will be rejected with an appropriate error message. ## Email and Phone Uniqueness Email addresses and phone numbers must be **unique across all principals**. ### Email Uniqueness Attempting to create a principal with an existing email address returns an error: ```json { "error": { "code": "EMAIL_NOT_UNIQUE", "message": "Email address already in use" } } ``` ### Changing Email Addresses When you change a principal's email address: 1. The new email must not be in use by another principal 2. The change updates the principal's login username automatically (if password authentication is enabled) 3. The principal must log in with the new email address going forward **Example:** ```http PATCH /resources/iam/principals/{id} If-Match: "etag-value" { "email": "newemail@example.com" } ``` ::: tip Before creating or updating a principal with an email, you can query existing principals by email using the Graph API to check availability. ::: ## Principal Suspension Principals can be **suspended** to temporarily block authentication without deleting the account. ### Suspension vs Deletion | Feature | Suspension | Deletion | |---------|-----------|----------| | Reversible | ✅ Yes | ❌ No | | Data retained | ✅ Yes | ❌ No | | Can authenticate | ❌ No | ❌ No | | Roles retained | ✅ Yes | ❌ No | | Visible in API | ❌ No (by default) | ❌ No | ### Suspension Workflow 1. **Suspend the principal** ```http POST /resources/iam/principals/{id}/suspend If-Match: "etag-value" ``` * Sets suspension timestamp * Automatically revokes all active sessions * Principal cannot create new sessions * Requires ETag for concurrency control 2. **Principal attempts to log in** * Authentication fails with "suspended" status * Error message: "Account has been suspended" 3. **Reactivate when ready** ```http POST /resources/iam/principals/{id}/reactivate If-Match: "etag-value" ``` * Clears suspension timestamp * Principal can authenticate again * Must log in to create new sessions * Requires ETag for concurrency control ### When to Use Suspension **✅ Appropriate uses:** * Security incidents (compromised account) * Policy violations (temporary punishment) * Account review periods * Inactive account cleanup (temporary) * Employee leave of absence **❌ Not appropriate:** * Permanent account removal (use deletion instead) * Password changes (use password reset) * Role changes (update roles directly) ## Session Management Principals can have multiple active sessions from different devices or applications. The session management endpoints allow viewing and revoking these sessions. ### List Sessions Get all active sessions for a principal: ```http GET /resources/iam/principals/{id}/sessions ``` **Response:** ```json [ { "id": "01933e8f-7c45-7123-9abc-sessionid123", "type": "interactive", "createdAt": "2025-01-07T15:30:00Z", "accessTokenExpiresAt": "2025-01-07T16:30:00Z", "isCurrent": true, "isRevoked": false, "revokedAt": null, "clientId": "01933e8f-7c45-7123-9abc-clientid456", "clientName": "Portal Web App", "description": "Chrome on Windows" } ] ``` **Session fields:** * `id` - Unique session identifier * `type` - Session type (e.g., `interactive`, `api`) * `isCurrent` - Whether this is the current session making the request * `isRevoked` - Whether the session has been revoked * `clientId` - OAuth client that created the session * `clientName` - Human-readable client name * `description` - Session description (e.g., browser and device info) ### Revoke Session Revoke a specific session: ```http DELETE /resources/iam/principals/{id}/sessions/{sessionId} ``` Returns `204 No Content` on success. The session is immediately invalidated and cannot be used for further requests. ### Session Management Permissions **Session management permissions:** * Principals can view and revoke their own sessions without special permissions * Viewing/revoking another principal's sessions requires `iam/principals:read` or `iam/principals:write` permission This enables self-service session management while allowing administrators to help users who may have lost access. ## Access Attributes (ABAC) Access attributes enable **Attribute-Based Access Control** (ABAC), allowing you to scope permissions to specific data. **Use case:** A store manager should only access orders and inventory for their assigned locations. **Solution:** Set `channelKey` attributes on the principal: ```json { "accessAttributes": { "channelKey": ["STORE-NYC", "STORE-BOS"] } } ``` When this principal accesses resources, they can only view/modify data where the resource's `channelKey` matches one of their attribute values. Attempting to access other channels returns `403 Forbidden`. **Example scenarios:** * Multi-location businesses (store managers) * Multi-tenant applications (tenant isolation) * Departmental access (only HR records) * Regional restrictions (GDPR compliance) ::: tip Access attributes work with any resource that supports attribute-based filtering. The attribute names are customizable based on your domain model. ::: ## Integration Patterns ### Creating Portal Users ```http PUT /resources/iam/principals/{userId} { "properties": { "name": "John Admin", "email": "john@company.com" }, "roles": ["system:portal:full"] } ``` Then generate a temporary password and send via email using the [Sendings API](/resources/sendings). ### Role-Based Filtering ```http GET /resources/iam/principals?rolePrefix=system:portal: ``` Returns only principals with portal access, useful for building user management UIs. ### Bulk Role Assignment Update multiple principals' roles using the Graph API and batch operations: ```http POST /resources/graph/query { "resource": "identity", "filters": [ { "field": "email", "op": "endsWith", "value": "@company.com" } ] } ``` Then update each principal's roles individually. ## API Endpoints **Principal management endpoints:** * `GET /resources/iam/principals` - List principals * `GET /resources/iam/principals/{id}` - Get principal * `PUT /resources/iam/principals/{id}` - Create or update (full) * `PATCH /resources/iam/principals/{id}` - Update properties (partial) * `DELETE /resources/iam/principals/{id}` - Delete principal * `POST /resources/iam/principals/{id}/suspend` - Suspend principal * `POST /resources/iam/principals/{id}/reactivate` - Reactivate principal * `POST /resources/iam/principals/{id}/password/reset` - Reset password * `PUT /resources/iam/principals/{id}/roles` - Update roles * `PUT /resources/iam/principals/{id}/acl` - Update ACL * `GET /resources/iam/principals/{id}/sessions` - List sessions * `DELETE /resources/iam/principals/{id}/sessions/{sessionId}` - Revoke session For complete endpoint documentation, request/response formats, and error codes, see the [HTTP API Reference](/api/http/). ## Related Resources * [IAM Overview](/resources/iam/) - Introduction to IAM concepts * [Clients](/resources/iam/clients) - Application and service identities * [Identity Graph Node](/resources/graph/nodes/identity) - Query identities via Graph * [Access Control](/learn/access-control) - Permission patterns and best practices * [Me Endpoint](/resources/me) - Current principal information * [Sendings API](/resources/sendings) - Send emails (for password resets) --- --- url: /resources/iam/clients.md description: OAuth client and service account management in Hantera IAM --- A **client** represents an application or service identity for OAuth and API access. Clients are how applications authenticate with Hantera without requiring human interaction. ::: tip This page covers clients in depth. For an overview of IAM, see the [IAM Overview](/resources/iam/). ::: ## What is a Client? Clients represent applications or services that need to access Hantera APIs - whether they're mobile apps, web applications, backend services, or third-party integrations. **Key characteristics:** * OAuth 2.0 configuration (redirect URIs, grant types, scopes) * Client secret authentication * No password-based login (cannot log in to portal) * Can be assigned roles for API access * System clients are protected from modification **Example client:** ```json { "id": "01933e8f-7c45-7123-9abc-123456789xyz", "type": "client", "properties": { "name": "Mobile App", "description": "iOS and Android mobile application", "redirectUris": ["myapp://callback", "myapp://logout"], "grantTypes": ["authorization_code", "refresh_token"], "scopes": ["openid", "profile", "email"], "tokenEndpointAuthMethod": "client_secret_post" }, "roles": ["app:mobile:access"], "suspendedAt": null } ``` ## When to Use Clients **✅ Use clients for:** * Third-party integrations * Mobile or web applications (OAuth flows) * Service-to-service communication * Webhook consumers * Background jobs requiring API access * Developer tools and scripts **❌ Don't use clients for:** * Human users needing portal access (use [Principals](/resources/iam/principals) instead) * Accounts that need password login (use [Principals](/resources/iam/principals) instead) ## Client Types ### Interactive Clients **Interactive clients** use OAuth flows where a user grants permission to the client. **Characteristics:** * Require redirect URIs for OAuth callbacks * Support authorization code flow * User consent required * Best for mobile apps, SPAs, web applications **Example use cases:** * Mobile application that accesses user's Hantera data * Third-party app integration * Partner portal access **Grant types:** * `authorization_code` - Standard OAuth flow * `refresh_token` - Long-lived access **Example configuration:** ```json { "properties": { "name": "Partner Mobile App", "redirectUris": ["myapp://callback"], "grantTypes": ["authorization_code", "refresh_token"], "scopes": ["openid", "profile", "orders:read"] } } ``` ### Service Accounts **Service accounts** are non-interactive clients for server-to-server communication. **Characteristics:** * No redirect URIs needed * Use client credentials flow * No user interaction required * Best for background services, APIs, batch jobs **Example use cases:** * Integration service syncing data * Scheduled batch processing * Webhook receiver * Internal microservice **Grant types:** * `client_credentials` - Server-to-server flow **Example configuration:** ```json { "properties": { "name": "Integration Service", "description": "Data synchronization service", "grantTypes": ["client_credentials"] }, "roles": ["integration:sync:access"] } ``` ### System Clients **System clients** are built-in clients managed by Hantera. **Characteristics:** * Cannot be modified or deleted * Reserved for Hantera's internal use * Predefined configuration ::: info System clients are read-only. They cannot be created, modified, or deleted via the API. ::: ## OAuth Configuration ### Grant Types Clients support different OAuth 2.0 grant types: #### authorization\_code Standard OAuth authorization flow for user-facing applications. **Flow:** 1. User redirects to authorization endpoint 2. User grants permission 3. Client receives authorization code 4. Client exchanges code for access token **Use for:** * Web applications * Mobile applications * Single-page applications (SPA) #### refresh\_token Allows obtaining new access tokens without user interaction. **Flow:** 1. Client uses refresh token 2. Receives new access token **Use for:** * Long-lived sessions * Background sync * Always combine with `authorization_code` #### client\_credentials Server-to-server authentication without user context. **Flow:** 1. Client authenticates with client ID and secret 2. Receives access token **Use for:** * Service accounts * Background jobs * API integrations * Webhook handlers ### Redirect URIs Redirect URIs specify where users are sent after OAuth authorization. **Requirements:** * Must be HTTPS in production (HTTP allowed for localhost) * Custom schemes allowed for mobile apps (e.g., `myapp://callback`) * Exact match required (no wildcards) * Multiple URIs supported **Example:** ```json { "redirectUris": [ "https://app.example.com/oauth/callback", "https://app.example.com/oauth/logout", "myapp://callback", "http://localhost:3000/callback" ] } ``` ### Scopes Scopes define what permissions the client requests. **Standard OpenID Connect scopes:** * `openid` - Basic identity information * `profile` - User profile data * `email` - User email address **Custom Hantera scopes:** * Resource-based scopes (e.g., `orders:read`, `orders:write`) * Application-specific scopes **Example:** ```json { "scopes": [ "openid", "profile", "orders:read", "customers:read" ] } ``` ### Token Endpoint Authentication Clients authenticate at the token endpoint using different methods: #### client\_secret\_post Client sends ID and secret in POST body. ```http POST /oauth/token Content-Type: application/x-www-form-urlencoded grant_type=authorization_code& code=ABC123& client_id=client_id_here& client_secret=secret_here ``` #### client\_secret\_basic Client sends ID and secret in Authorization header. ```http POST /oauth/token Authorization: Basic base64(client_id:client_secret) Content-Type: application/x-www-form-urlencoded grant_type=authorization_code&code=ABC123 ``` ## Client Secret Management ### Creating Secrets Generate a new client secret: ```http POST /resources/iam/clients/{id}/secrets If-Match: "etag-value" { "description": "Production API access", "expiresAt": "2026-01-01T00:00:00Z" } ``` **Response:** ```json { "id": "01933e8f-7c45-7123-secret-abc123", "secret": "sk_live_abcdef123456...", "description": "Production API access", "createdAt": "2025-01-07T15:30:00Z", "expiresAt": "2026-01-01T00:00:00Z", "lastUsedAt": null } ``` ::: warning **The client secret is only shown once** during creation. Store it securely - it cannot be retrieved later. ::: ### Secret Best Practices **Expiration:** * Always set expiration dates for secrets * Rotate secrets before expiration * Use short expiration for high-security environments **Description:** * Document secret purpose * Include environment (production, staging) * Note the system using the secret **Security:** * Never commit secrets to version control * Use environment variables or secret managers * Rotate compromised secrets immediately * Monitor `lastUsedAt` for unused secrets ### Listing Secrets View all active secrets for a client: ```http GET /resources/iam/clients/{id}/secrets ``` **Response:** ```json [ { "id": "01933e8f-7c45-7123-secret-abc123", "description": "Production API access", "createdAt": "2025-01-07T15:30:00Z", "expiresAt": "2026-01-01T00:00:00Z", "lastUsedAt": "2025-01-08T10:00:00Z" } ] ``` ::: info The actual secret value is never returned after creation. Only metadata is shown. ::: ### Revoking Secrets Delete a client secret: ```http DELETE /resources/iam/clients/{id}/secrets/{secretId} ``` Returns `204 No Content` on success. The secret is immediately invalidated. **When to revoke:** * Secret compromised or exposed * Secret no longer needed * Before secret expiration (graceful rotation) * During security incidents ## List API Features The clients list endpoint supports filtering, search, and cursor-based pagination. ### Cursor-Based Pagination All list operations use **cursor-based pagination**: **Query parameters:** * `limit` - Items per page (default: 50, max: 100) * `after` - Cursor for next page * `before` - Cursor for previous page **Response format:** ```json { "clients": [...], "totalCount": 42, "lastCursor": "01933e8f-7c45-7123-9abc-123456789abc", "firstCursor": "01933e8f-7c45-7123-9abc-123456789def" } ``` **Example:** ```http GET /resources/iam/clients?limit=50 GET /resources/iam/clients?limit=50&after=cursor_here ``` ### Search Search by client name (case-insensitive, partial matches): ```http GET /resources/iam/clients?search=mobile ``` ### Filtering **Filter by type:** ```http GET /resources/iam/clients?type=service_account ``` **Include suspended clients:** ```http # By default, suspended clients are excluded GET /resources/iam/clients?includeSuspended=true ``` ### Sorting Sort results using the `orderBy` parameter: ```http # Sort by name ascending (default) GET /resources/iam/clients?orderBy=name asc # Sort by created date GET /resources/iam/clients?orderBy=createdAt desc ``` ## Client Management ### Create or Update Client **Full replacement** of a client: ```http PUT /resources/iam/clients/{id} If-Match: "etag-value" { "properties": { "name": "Mobile App", "description": "iOS and Android application", "redirectUris": ["myapp://callback"], "grantTypes": ["authorization_code", "refresh_token"], "scopes": ["openid", "profile", "orders:read"], "tokenEndpointAuthMethod": "client_secret_post" }, "roles": ["app:mobile:access"] } ``` ::: warning The `If-Match` header with ETag is required for updates to prevent conflicting changes. Omit it only when creating a new client. ::: ### Partial Update Update specific properties: ```http PATCH /resources/iam/clients/{id} If-Match: "etag-value" { "name": "Mobile App v2", "description": "Updated mobile application" } ``` ### Update Roles Replace the entire roles list: ```http PUT /resources/iam/clients/{id}/roles If-Match: "etag-value" { "roles": ["app:mobile:access", "integration:api:read"] } ``` ### Get Client Details Retrieve a client with all properties: ```http GET /resources/iam/clients/{id} ``` **Response:** ```json { "id": "01933e8f-7c45-7123-9abc-123456789xyz", "name": "Mobile App", "description": "iOS and Android application", "redirectUris": ["myapp://callback"], "grantTypes": ["authorization_code", "refresh_token"], "scopes": ["openid", "profile", "orders:read"], "tokenEndpointAuthMethod": "client_secret_post", "roles": ["app:mobile:access"], "suspendedAt": null, "createdAt": "2024-01-01T10:00:00Z", "etag": "W/\"abc123\"" } ``` ### Delete Client Remove a client permanently: ```http DELETE /resources/iam/clients/{id} ``` Returns `204 No Content` on success. ::: warning **Deletion is permanent** and cannot be undone. All associated secrets are also deleted. ::: ## Integration Patterns ### OAuth Authorization Code Flow 1. **Create OAuth client** ```http PUT /resources/iam/clients/mobile-app-v1 { "properties": { "name": "Mobile App", "redirectUris": ["myapp://callback"], "grantTypes": ["authorization_code", "refresh_token"] } } ``` 2. **Generate client secret** ```http POST /resources/iam/clients/mobile-app-v1/secrets { "description": "Production secret" } ``` 3. **Redirect user to authorization endpoint** ``` GET /oauth/authorize? response_type=code& client_id=mobile-app-v1& redirect_uri=myapp://callback& scope=openid%20profile%20orders:read ``` 4. **Exchange authorization code for tokens** ```http POST /oauth/token { "grant_type": "authorization_code", "code": "auth_code_here", "client_id": "mobile-app-v1", "client_secret": "secret_here", "redirect_uri": "myapp://callback" } ``` 5. **Use access token for API calls** ```http GET /resources/orders/123 Authorization: Bearer access_token_here ``` ### Service Account Pattern 1. **Create service account client** ```http PUT /resources/iam/clients/sync-service { "properties": { "name": "Data Sync Service", "grantTypes": ["client_credentials"] }, "roles": ["integration:sync:full"] } ``` 2. **Generate long-lived secret** ```http POST /resources/iam/clients/sync-service/secrets { "description": "Production sync service", "expiresAt": "2026-12-31T23:59:59Z" } ``` 3. **Obtain access token** ```http POST /oauth/token { "grant_type": "client_credentials", "client_id": "sync-service", "client_secret": "secret_here" } ``` 4. **Make API calls** ```http GET /resources/orders Authorization: Bearer access_token_here ``` ### Secret Rotation 1. **Create new secret (before old expires)** ```http POST /resources/iam/clients/{id}/secrets { "description": "Rotated secret 2025-01", "expiresAt": "2026-01-31T00:00:00Z" } ``` 2. **Deploy new secret to application** Update environment variables or secret manager 3. **Verify new secret works** Test authentication with new secret 4. **Revoke old secret** ```http DELETE /resources/iam/clients/{id}/secrets/{old-secret-id} ``` ## API Endpoints **Client management endpoints:** * `GET /resources/iam/clients` - List clients * `GET /resources/iam/clients/{id}` - Get client * `PUT /resources/iam/clients/{id}` - Create or update (full) * `PATCH /resources/iam/clients/{id}` - Update properties (partial) * `DELETE /resources/iam/clients/{id}` - Delete client * `PUT /resources/iam/clients/{id}/roles` - Update roles * `GET /resources/iam/clients/{id}/secrets` - List secrets * `POST /resources/iam/clients/{id}/secrets` - Create secret * `DELETE /resources/iam/clients/{id}/secrets/{secretId}` - Revoke secret For complete endpoint documentation, request/response formats, and error codes, see the [HTTP API Reference](/api/http/). ## Security Considerations ### Secret Storage **Never:** * ❌ Commit secrets to version control * ❌ Log secrets in plain text * ❌ Include secrets in URLs * ❌ Send secrets via email * ❌ Store secrets in client-side code **Always:** * ✅ Use environment variables * ✅ Use secret management services (AWS Secrets Manager, Azure Key Vault) * ✅ Rotate secrets regularly * ✅ Set expiration dates * ✅ Monitor secret usage ### Rate Limiting Implement rate limiting for client authentication: * Prevent brute force attacks * Monitor failed authentication attempts * Suspend clients with excessive failures ### Audit Logging Track client activity: * Authentication attempts * API calls made * Secret creation/revocation * Configuration changes ## Troubleshooting ### Invalid Client Error **Cause:** Client ID doesn't exist or client is suspended **Solution:** * Verify client ID is correct * Check if client exists: `GET /resources/iam/clients/{id}` * Check if client is suspended (look for `suspendedAt`) ### Invalid Client Secret **Cause:** Secret is incorrect, expired, or revoked **Solution:** * Verify secret is correct * Check secret expiration date * Generate new secret if needed * Ensure secret hasn't been revoked ### Invalid Redirect URI **Cause:** Redirect URI doesn't match client configuration **Solution:** * Verify exact URI match (including protocol, port, path) * Check client configuration: `GET /resources/iam/clients/{id}` * Update client if redirect URI changed ### Unsupported Grant Type **Cause:** Requested grant type not configured for client **Solution:** * Check client's `grantTypes` configuration * Update client to include required grant type ## Related Resources * [IAM Overview](/resources/iam/) - Introduction to IAM concepts * [Principals](/resources/iam/principals) - User identity management * [Authentication Guide](/learn/authentication) - OAuth flows and patterns * [Access Control](/learn/access-control) - Permission patterns * [HTTP API Reference](/api/http/) - Complete endpoint documentation --- --- url: /resources/me.md --- # Me The Me API provides endpoints for authenticated users to manage their own profile, authentication, and settings. ## Overview The Me API enables self-service user management with the following capabilities: ### Profile Management * Read and update profile information (name, email, phone, picture) * Change email address with verification workflow * Manage user preferences and settings ### Authentication * Change password with current password verification * Password complexity enforcement (8+ characters, uppercase, lowercase, digit) ### Personal Access Tokens (PATs) * Create long-lived API tokens for programmatic access * List active PATs * Revoke PATs ## Resource Structure All Me API endpoints are under `/resources/me/`: ``` /resources/me/ ├── profile # Profile data ├── password # Password management ├── email/ │ ├── change # Request email change │ └── validate # Verify email change ├── settings # User preferences └── pat # Personal Access Tokens └── {id} # Specific PAT ``` ## Key Features ### Email Verification Email changes require verification to prevent unauthorized account takeover: 1. User requests email change → Me API generates verification secret and stores in `pendingEmail` 2. Client (e.g., Portal) sends verification email via Sendings API 3. User clicks link with secret → Client calls validation endpoint 4. Me API validates secret → Email updated if valid 5. Secret expires after 24 hours **Important**: The Me API does not send emails automatically. Clients are responsible for: * Calling the Sendings API to deliver verification emails * Providing the verification link endpoint * Handling the validation callback This separation allows clients to customize email templates and delivery logic. ### Password Security Password changes require: * Current password verification * Complexity requirements (enforced by BCrypt): * Minimum 8 characters * At least one uppercase letter * At least one lowercase letter * At least one digit ### Personal Access Tokens PATs enable programmatic API access: * Long-lived tokens (up to 1 year) * Scope-limited (inherit user's permissions) * Individually revocable * Require client secret for creation ## Access Control The available access control entries for the Me resources are: ``` me/profile:read # Read own profile me/profile:write # Update profile and email me/auth:write # Change password me/settings:read # Read settings me/settings:write # Update settings me/pat:read # List PATs me/pat:write # Create/revoke PATs ``` ## Common Use Cases ### Self-Service Profile Updates Users can update their own information without administrator involvement: * Change display name * Update phone number * Upload profile picture * Change email (with verification) * Update password ### API Integration Developers can create PATs for: * CI/CD pipelines * Custom integrations * Automation scripts * External applications ### Personalization Users can customize their experience via settings: * Interface language * Date/time formatting * Other application preferences ## Related Resources * **[IAM](/resources/iam/)** - User and role management (admin) --- --- url: /resources/ingresses.md --- # Ingresses Ingresses create a harmonized API for invoking [reactor components](/resources/components/) over various transport protocols. They provide a consistent way to expose component functionality to external systems, regardless of the underlying transport mechanism. ## What are Ingresses? An ingress is a resource that defines how to call a reactor component using a specific transport protocol. Currently, Hantera supports **HTTP ingresses**, with additional transports like AMQP planned for the future. Ingresses bridge the gap between external systems and Hantera's reactor components, allowing you to: * Create custom API endpoints * Process messages from message queues * Handle webhooks and callbacks * Expose component functionality to external applications ## Relationship to Components and Jobs Reactor components can be used in two main ways in Hantera: 1. **Jobs**: Schedule components to run in the background at specific times or intervals 2. **Ingresses**: Expose components as callable endpoints over various transport protocols Both use the same reactor runtime to execute component code, but serve different purposes: * Jobs are time-triggered and run autonomously * Ingresses are request-triggered and respond to external calls ## A Simple Example Here's how to create an HTTP ingress that calls a component to create orders: 1. First, create a reactor component that handles order creation: ```filtrera //create-order.hreactor param channelKey: text param items: [{ productNumber: text, quantity: number }] let deliveryId = newid let channel = registry->$'channels/{channelKey}' let orderCommands = [{ type = 'setChannelKey' value = channelKey },{ type = 'createDelivery' deliveryId = deliveryId }] let orderLineCommands = items select i => { type = 'createOrderLine' deliveryId = deliveryId productNumber = i.productNumber quantity = i.quantity } from channel match nothing |> 'Channel not found' { currencyCode: not nothing, taxIncluded: bool } |> messageActor( 'order' 'new' [{ type = 'create' body = { currencyCode = channel.currencyCode taxIncluded = channel.taxIncluded commands = [orderCommands, orderLineCommands] flatten } }] ) |> 'Something went wrong' ``` 2. Create a manifest file to install the component and create the ingress: ```yaml # Install the component uri: /resources/components/create-order.hreactor spec: componentVersion: '1.0.0' codeFile: create-order.hreactor --- # Create an HTTP ingress that calls the component uri: /resources/ingresses/api/orders/create spec: type: http componentId: create-order.hreactor properties: route: api/orders/create httpMethod: post body: mode: structured --- # Add a test channel for the example uri: /resources/registry/channels/WEB_SE spec: value: currencyCode: 'SEK' taxIncluded: true ``` 3. Apply the manifest to your Hantera instance: ```bash h_ manage apply h_manifest.yaml ``` 4. The ingress is now live! You can call it via HTTP: ```bash curl -X POST https://your-instance.hantera.io/ingress/api/orders/create \ -H "Content-Type: application/json" \ -H "Authorization: Bearer YOUR_TOKEN" \ -d '{ "channelKey": "WEB_SE", "items": [{ "productNumber": "PROD001", "quantity": 2 }] }' ``` The ingress automatically maps the HTTP request to component parameters and handles the response. ## Access Control ### Managing Ingresses Managing ingress resources requires standard resource permissions: ``` ingresses:read # View ingress configurations ingresses:write # Create, update, and delete ingresses ``` These permissions control access to the ingress resource itself through the management API (`/resources/ingresses`). ### ACL Elevation Ingresses can define an Access Control List (ACL) that determines what permissions the component has when it runs. This is a powerful feature that enables secure delegation: **Key Capabilities:** * The ingress component can run with **different permissions** than the calling session * Grant the ingress access to resources the caller doesn't have * Enable limited users to trigger privileged operations * Access internal resources on behalf of external systems **Example:** ```yaml uri: /resources/ingresses/admin/reset-cache spec: type: http componentId: cache-manager.hreactor # This ingress runs with elevated permissions acl: - system/admin:* properties: route: admin/reset-cache httpMethod: post ``` In this example, any user authorized to call the ingress can trigger cache management operations, but the actual cache operations run with admin privileges as defined in the ACL. **Important:** The ACL applies to what the component can do, not who can call the ingress. Authorization to call an ingress is transport-specific (see individual transport documentation for details). ## Transport Types ### HTTP Ingresses HTTP ingresses expose components as HTTP endpoints. They support: * All standard HTTP methods (GET, POST, PUT, PATCH, DELETE) * Route parameters, headers, query strings, and body mapping * Structured and raw body modes * Public and authenticated access * **Server-Sent Events (SSE)** for real-time streaming [Learn more about HTTP Ingresses →](/resources/ingresses/http/) **Deep-dive guides:** * [Parameter Mapping](/resources/ingresses/http/parameters) - Headers, query params, body handling * [Response Handling](/resources/ingresses/http/responses) - JSON, streams, error responses * [SSE Streaming](/resources/ingresses/http/sse) - Real-time server-sent events ### Future Transports Additional transports are planned, including: * **AMQP**: Process messages from message queues Each transport will provide transport-specific configuration while maintaining the same core ingress model. ## Managing Ingresses Ingresses are managed through the `/resources/ingresses` API endpoints. You can: * **List all ingresses**: `GET /resources/ingresses` * **Get an ingress**: `GET /resources/ingresses/{ingressId}` * **Create/update an ingress**: `PUT /resources/ingresses/{ingressId}` * **Delete an ingress**: `DELETE /resources/ingresses/{ingressId}` See the [API Reference](/api/http/) in the OpenAPI browser for detailed endpoint documentation. ## Best Practices ### Component Design When creating components for ingresses: * **Validate input**: Always validate parameters, especially for public ingresses * **Handle errors gracefully**: Return meaningful error messages * **Keep it focused**: One ingress should do one thing well * **Document parameters**: Use clear parameter names and types ### Security * **Use ACLs carefully**: Only grant necessary permissions * **Avoid public ingresses** unless absolutely needed * **Validate input carefully**: Especially for public ingresses ### Naming Conventions * Use hierarchical ingress IDs: `api/orders/create`, `webhooks/payment/callback` * Match HTTP routes to ingress IDs when possible * Use clear, descriptive names that indicate purpose ## See Also * [Components](/resources/components/) - Learn about reactor components * [Jobs](/resources/jobs/) - Schedule components to run in the background * [HTTP Ingresses](/resources/ingresses/http/) - Detailed HTTP ingress configuration * [API Reference](/api/http/) - Complete API documentation --- --- url: /resources/ingresses/http.md --- HTTP ingresses expose [reactor components](/resources/components/) as HTTP endpoints, allowing external systems to call component methods via standard HTTP requests. ## Configuration An HTTP ingress is configured through the `properties` field when creating an ingress with `type: http`. ### Basic Structure ```yaml uri: /resources/ingresses/my-ingress spec: type: http componentId: my-component.hreactor properties: route: api/my-endpoint httpMethod: post isPublic: false headers: {} queryParams: {} body: mode: structured ``` ## Route Configuration ### Route The `route` property defines the URL path where the ingress will be accessible: ```yaml properties: route: api/orders/create ``` The ingress will be callable at `/ingress/api/orders/create`. ### Route Parameters Routes can include dynamic parameters using the `{paramName}` syntax: ```yaml properties: route: api/orders/{orderId}/status ``` Route parameters are automatically extracted and passed to the component: ```bash curl -X GET https://your-instance.hantera.io/ingress/api/orders/12345/status # orderId parameter will be "12345" ``` Multiple route parameters are supported: ```yaml properties: route: api/warehouses/{warehouseId}/products/{productId} ``` ## HTTP Method The `httpMethod` property defines which HTTP method the ingress accepts: ```yaml properties: httpMethod: post # get, post, put, patch, or delete ``` Available methods: * `get` - HTTP GET requests * `post` - HTTP POST requests * `put` - HTTP PUT requests * `patch` - HTTP PATCH requests * `delete` - HTTP DELETE requests Requests using other HTTP methods will receive a 405 Method Not Allowed response. ## Access Control ### Calling HTTP Ingresses To call an HTTP ingress, a session must have the appropriate permission based on the ingress **resource ID**: ``` ingresses[/``]:http ``` **Important:** The permission uses the ingress resource ID (the `uri` when creating the ingress), NOT the HTTP route. **Example:** ```yaml uri: /resources/ingresses/api/skus/search # This is the ingressId spec: type: http properties: route: search/skus # This is the HTTP route (can be different!) ``` **Permissions needed:** * To call this ingress: `ingresses/api/skus/search:http` (uses the ingressId) * To manage this ingress: `ingresses:read` or `ingresses:write` ### Public Ingresses Mark an ingress as public to allow unauthenticated access: ```yaml properties: isPublic: true ``` Public ingresses: * Don't require authentication * Have a 10 MB maximum payload size * Rate limiting applied by the platform * Should validate input carefully in component code * Are ideal for webhooks and public APIs ## Quick Example Here's a complete example of an HTTP ingress: ```yaml uri: /resources/ingresses/api/skus/search spec: type: http componentId: sku-search.hreactor acl: - graph/sku:read properties: route: api/skus/search httpMethod: get queryParams: searchTerm: q sortBy: sort ``` ```filtrera //sku-search.hreactor param searchTerm: text param sortBy: text let sort = sortBy match nothing |> 'skuNumber' |> sortBy from query skus(skuNumber) phrase searchTerm orderBy $'{sort} asc' ``` ```bash curl -X GET \ "https://your-instance.hantera.cloud/ingress/api/skus/search?q=laptop&sort=skuNumber" \ -H "Authorization: Bearer YOUR_TOKEN" ``` ## Best Practices ### Validation Always validate input parameters, especially for public ingresses: ```filtrera param email: text | nothing param name: text | nothing from { email, name } match { email: /^[A-Za-z0-9!#$%&'*+\/=?^_`{|}~-]+(?:\.[A-Za-z0-9!#$%&'*+\/=?^_`{|}~-]+)*@(?:[A-Za-z0-9](?:[A-Za-z0-9-){0,61}[A-Za-z0-9))?\.)+[A-Za-z]{2,63}$/i, name: /.+/ } |> createUser(email, name) |> { error = { code = 'INVALID_INPUT' message = 'Email and name are required' } } ``` ## See Also * [Parameter Mapping](/resources/ingresses/http/parameters) - Headers, query params, and body handling * [Response Handling](/resources/ingresses/http/responses) - JSON, streams, and error responses * [SSE Streaming](/resources/ingresses/http/sse) - Real-time server-sent events * [Ingresses Overview](/resources/ingresses/) - Core ingress concepts * [Components](/resources/components/) - Creating reactor components --- --- url: /resources/ingresses/http/parameters.md --- HTTP ingresses map various parts of HTTP requests to component parameters. This page covers all the ways to extract data from incoming requests. ## Parameter Resolution Order Parameters are resolved in a specific order, with later mappings potentially overriding earlier ones: 1. **Route parameters** - Extracted from the URL path 2. **Headers** - Mapped HTTP headers 3. **Query parameters** - URL query string values 4. **Body** - Request body content This order allows intentional parameter overloading. For example, a query parameter can override a route parameter if needed. ## Route Parameters Route parameters are defined in the route path using `{paramName}` syntax: ```yaml properties: route: api/orders/{orderId}/items/{itemId} ``` Parameters are automatically extracted and passed to the component: ```bash curl https://example.hantera.io/ingress/api/orders/ORD-123/items/ITEM-456 # Component receives: orderId="ORD-123", itemId="ITEM-456" ``` Route parameters are always extracted as text values. ## Headers Map HTTP headers to component parameters using the `headers` property: ```yaml properties: headers: userId: X-User-Id apiKey: X-API-Key correlationId: X-Correlation-ID ``` The key is the component parameter name, and the value is the HTTP header name: ```bash curl -H "X-User-Id: user-123" \ -H "X-API-Key: secret-key" \ https://example.hantera.io/ingress/api/protected # Component receives: userId="user-123", apiKey="secret-key" ``` If multiple values exist for a header, they are joined with semicolons. ## Query Parameters Map URL query string parameters using the `queryParams` property: ```yaml properties: queryParams: pageSize: limit pageNumber: offset searchTerm: q ``` The key is the component parameter name, and the value is the query string key: ```bash curl "https://example.hantera.io/ingress/api/products?limit=50&offset=0&q=laptop" # Component receives: pageSize="50", pageNumber="0", searchTerm="laptop" ``` If multiple values exist for a query parameter, they are joined with semicolons: ```bash curl "https://example.hantera.io/ingress/api/products?tag=electronics&tag=sale" # Component receives: tag="electronics;sale" ``` ## Body Handling The `body` configuration determines how the HTTP request body is processed. ### Structured Mode In structured mode, the request body must be JSON and is parsed into component parameters: ```yaml properties: body: mode: structured ``` **Without a parameter name**, each JSON property becomes a component parameter: ```bash curl -X POST https://example.hantera.io/ingress/api/orders \ -H "Content-Type: application/json" \ -d '{ "customerId": "CUST001", "items": [{"sku": "PROD001", "quantity": 2}] }' # Component receives: customerId="CUST001", items=[...] ``` **With a parameter name**, the entire JSON body becomes one parameter: ```yaml properties: body: mode: structured parameter: orderData ``` ```bash curl -X POST https://example.hantera.io/ingress/api/orders \ -H "Content-Type: application/json" \ -d '{ "customerId": "CUST001", "items": [{"sku": "PROD001", "quantity": 2}] }' # Component receives: orderData={customerId: "CUST001", items: [...]} ``` Structured mode requires `Content-Type: application/json`. Other content types result in a 400 Bad Request. ### Raw Mode In raw mode, the request body is passed as a stream to the component: ```yaml properties: body: mode: raw parameter: fileData ``` This is useful for: * Large structured data imports (XML, CSV) * Binary data processing * File uploads * Custom content types The component receives the raw stream as a byte iterator (`[byte]`): ```filtrera import 'xml' param xmlData: [byte] let products = xmlData |> xml.readXml |> xml.whereElement('product') select product => { skuNumber = product |> xml.whereElement('sku') |> xml.getText name = product |> xml.whereElement('name') |> xml.getText price = product |> xml.whereElement('price') |> xml.getText } from products ``` ## Complete Example Here's an example combining multiple parameter sources to create an order cancellation endpoint: ```yaml uri: /resources/ingresses/api/orders/cancel spec: type: http componentId: cancel-order.hreactor acl: - orders:write properties: route: api/orders/{orderId}/cancel httpMethod: post headers: requestId: X-Request-ID body: mode: structured ``` Component: ```filtrera param orderId: uuid param requestId: text | nothing param reason: text | nothing let order = query orders(orderId, orderState) filter $'orderId == ''{orderId}''' first from order match nothing |> { error = { code = 'NOT_FOUND', message = 'Order not found' } } { orderState: 'cancelled' } |> { error = { code = 'ALREADY_CANCELLED', message = 'Order is already cancelled' } } |> messageActor( actorType = 'order' actorId = orderId messages = [{ type = 'applyCommands' commands = [{ type = 'setOrderState' orderState = 'cancelled' }] }] ) ``` Request: ```bash curl -X POST \ "https://example.hantera.io/ingress/api/orders/abc-123/cancel" \ -H "X-Request-ID: req-456" \ -H "Content-Type: application/json" \ -d '{"reason": "Customer requested cancellation"}' # Component receives: # - orderId: "abc-123" (from route) # - requestId: "req-456" (from header) # - reason: "Customer requested cancellation" (from body) ``` ## Parameter Type Conversion Parameters extracted from routes, headers, and query strings are always text. The component can define typed parameters, and Hantera will attempt to convert: ```filtrera param orderId: text param limit: number | nothing param includeDeleted: bool | nothing ``` For JSON bodies, the original JSON types are preserved. ## Missing Parameters If a required parameter is not provided, the ingress returns 400 Bad Request: ```json { "error": { "code": "MISSING_PARAMETER", "message": "Missing parameter orderId" } } ``` Optional parameters (those with `| nothing` types or default values) are simply not passed to the component. ## See Also * [HTTP Ingresses](/resources/ingresses/http/) - Overview and configuration * [Response Handling](/resources/ingresses/http/responses) - JSON, streams, and error responses * [SSE Streaming](/resources/ingresses/http/sse) - Real-time server-sent events --- --- url: /resources/ingresses/http/responses.md --- # HTTP Response Handling Component return values are automatically converted to HTTP responses. This page covers all response types and how to handle errors. ## JSON Response Most component return values are serialized as JSON with a 200 OK status: ```filtrera from { status = 'success' data = ['item1', 'item2'] } ``` Response: ```http HTTP/1.1 200 OK Content-Type: application/json { "status": "success", "data": ["item1", "item2"] } ``` Arrays, records, numbers, text, and booleans are all serialized to their JSON equivalents. ## No Content Response Return `nothing` to send a 204 No Content response: ```filtrera param orderId: text let _ = deleteOrder orderId from nothing ``` Response: ```http HTTP/1.1 204 No Content ``` This is useful for fire-and-forget operations where no response body is needed. ## Stream Response Components can return streams for file downloads or large responses: ```filtrera param fileId: text from files.read($'documents/{fileId}') ``` The stream's MIME type is automatically set as the Content-Type header, and the content is streamed to the client without buffering. This is efficient for: * File downloads * Large data exports * Binary content ## Raw HTTP Response For full control over status code, headers, and body, return a record with a numeric `statusCode` field. The presence of this field is what signals "this is a raw response": ```filtrera from { statusCode = 302 headers = { 'Location' -> '/login' } } ``` Response: ```http HTTP/1.1 302 Found Location: /login ``` The shape of the response record is: ```typescript { statusCode: number headers?: { text -> text | [text] } content?: value } ``` ### Status Code Any HTTP status code is supported: ```filtrera // Created from { statusCode = 201, content = { id = newid } } // Custom error with details from { statusCode = 422 content = { error = { code = 'VALIDATION_FAILED' details = { field = 'email' } } } } // I'm a teapot from { statusCode = 418 } ``` ### Headers A header value can be a single `text` (the common case) or a list of `text` for multi-value headers like `Set-Cookie`: ```filtrera from { statusCode = 200 headers = { 'Cache-Control' -> 'public, max-age=3600' 'X-Trace-Id' -> 'abc-123' 'Set-Cookie' -> [ 'session=abc; HttpOnly' 'theme=dark' ] } } ``` If you set `Content-Type` via `headers`, it overrides the default that would otherwise be inferred from the `content`: ```filtrera from { statusCode = 200 headers = { 'Content-Type' -> 'application/xml' } content = '' } ``` ### Content The `content` field can be any value: | `content` type | Behavior | |----------------|----------| | omitted / `nothing` | Empty body | | `text` | Written as-is, default `Content-Type: text/plain; charset=utf-8` | | stream | Written as binary, default `Content-Type` from stream's MIME type | | record/list/number/etc. | JSON-serialized, default `Content-Type: application/json` | ```filtrera // Plain text response from { statusCode = 200 content = 'Hello, world!' } // Custom JSON shape with custom status from { statusCode = 202 headers = { 'Location' -> '/jobs/123/status' } content = { jobId = '123', status = 'queued' } } ``` ### When to use raw responses Use the raw response model when you need to: * Set a non-default HTTP status code (redirects, custom errors, 2xx variants) * Add custom response headers (`Location`, `Cache-Control`, `Set-Cookie`, ...) * Return a non-JSON body with a custom `Content-Type` For everything else, prefer the simpler "just return a value" or "return an `error` record" patterns shown above. ## Error Response Return a record matching the Error type to indicate failure: ```filtrera param orderId: text | nothing from orderId match nothing |> { error = { code = 'MISSING_PARAMETER' message = 'Order ID is required' } } id |> processOrder(id) ``` Error type structure: ```typescript { error: { code: string message?: string } } ``` Response: ```http HTTP/1.1 400 Bad Request Content-Type: application/json { "error": { "code": "MISSING_PARAMETER", "message": "Order ID is required" } } ``` ### Error Status Codes The HTTP status code is determined by the error code: | Error Code Pattern | HTTP Status | |-------------------|-------------| | `NOT_FOUND` | 404 | | `UNAUTHORIZED` | 401 | | `FORBIDDEN` | 403 | | Other codes | 400 | ### Error Handling Best Practices Provide meaningful error messages: ```filtrera param productId: text let product = query skus(skuNumber) where skuNumber == productId first from product match nothing |> { error = { code = 'NOT_FOUND' message = $'Product {productId} not found' } } p |> p ``` Validate input early: ```filtrera param email: text | nothing param name: text | nothing from { email, name } match { email: text, name: text } |> createUser(email, name) |> { error = { code = 'INVALID_INPUT' message = 'Email and name are required' } } ``` ## HTTP Status Codes | Status | Condition | |--------|-----------| | 200 | Successful response with body | | 204 | Successful response, no content | | 400 | Invalid request or general error | | 401 | Authentication required | | 403 | Forbidden (authorization failed) | | 404 | Resource not found | | 405 | Wrong HTTP method | | 500 | Internal server error | ## Response Headers Response headers are automatically set based on the content type: | Content Type | Header Value | |--------------|--------------| | JSON data | `application/json` | | Stream | Stream's MIME type | | SSE | `text/event-stream` | Custom response headers are not configurable through ingress properties — use the [Raw HTTP Response](#raw-http-response) model when you need to set them. ## Large Responses For large responses, consider: 1. **Pagination**: Return data in pages with cursor-based pagination 2. **Streaming**: Use iterators for lazy evaluation 3. **SSE**: For real-time updates, use [Server-Sent Events](/resources/ingresses/http/sse) Graph queries automatically use cursor-based pagination: ```filtrera param cursor: text | nothing param pageSize: number = 100 from query orders(orderNumber, status, createdAt) where status == 'pending' pageSize pageSize cursor cursor ``` ## See Also * [HTTP Ingresses](/resources/ingresses/http/) - Overview and configuration * [Parameter Mapping](/resources/ingresses/http/parameters) - Headers, query params, and body handling * [SSE Streaming](/resources/ingresses/http/sse) - Real-time server-sent events --- --- url: /resources/ingresses/http/sse.md --- # Server-Sent Events (SSE) Server-Sent Events (SSE) enables real-time streaming from HTTP ingresses to clients. When a client requests an SSE stream, the ingress keeps the connection open and pushes events as they occur. ## Enabling SSE SSE mode is automatically enabled when a client sends the `Accept: text/event-stream` header: ```bash curl -N -H "Accept: text/event-stream" \ https://example.hantera.io/ingress/api/orders/ORD-123/events ``` The same HTTP ingress can serve both regular JSON responses and SSE streams based on the Accept header. ## Message Format SSE messages are sent as records with special fields that map to SSE protocol fields: ```filtrera from { event = 'orderUpdated' data = { orderId = 'ORD-123', status = 'shipped' } } ``` Output: ``` event: orderUpdated data: {"orderId":"ORD-123","status":"shipped"} ``` ### Standard SSE Fields | Field | Purpose | Serialization | |---------|---------|---------------| | `event` | Event type for `addEventListener()` | String | | `data` | Main payload | JSON serialized | | `id` | Event ID for reconnection tracking | String | | `retry` | Reconnection delay hint (ms) | String | All other fields are serialized as strings and passed through to the client. ### Example with All Fields ```filtrera from { event = 'heartbeat' id = 'hb-42' retry = 5000 data = { timestamp = now } } ``` Output: ``` event: heartbeat id: hb-42 retry: 5000 data: {"timestamp":"2026-01-11T19:30:00Z"} ``` ### Simple Values Non-record values are automatically wrapped in `data:`: ```filtrera from 'Hello world' ``` Output: ``` data: "Hello world" ``` ## Real-Time Events with `events()` The [`events()`](/resources/components/runtimes/keywords/events) function subscribes to Hantera's [event bus](/learn/event-streaming), enabling real-time updates: ```filtrera param orderId: text from events ($'actors/order/{orderId}', 'checkpoint') select e => { event = 'updated', data = e } ``` This subscribes to checkpoint events for a specific order actor and streams them to the client. ## Complete Example: SKU Stock Updates Here's a real-world example streaming real-time stock availability for a SKU: ### Ingress Configuration ```yaml uri: /resources/ingresses/api/skus/stock-events spec: type: http componentId: sku-stock-events.hreactor acl: - skus:read properties: route: api/skus/{skuNumber}/stock httpMethod: get ``` ### Component Code ```filtrera import 'text' param skuNumber: text let safeSkuNumber = skuNumber replace("''", "''''") let skuQuery = query skus(skuId, skuNumber) filter $'skuNumber == ''{safeSkuNumber}''' from skuQuery match (e: QueryError) |> { error = { code = 'QUERY_ERROR', message = e.message } } |> let sku = skuQuery first from sku match nothing |> { error = { code = 'NOT_FOUND', message = 'SKU not found' } } |> let initialStock = messageActor( 'sku' sku.skuId [{ type = 'calculateAvailableStock' }] ) from { event = 'init', data = initialStock } from events ($'actors/sku/{sku.skuId}', 'checkpoint') select e => let stock = messageActor( 'sku' sku.skuId [{ type = 'calculateAvailableStock' }] ) from { event = 'stockUpdated', data = stock } ``` This ingress: 1. Looks up the SKU by its skuNumber 2. Sends an `init` event with the current available stock 3. Streams `stockUpdated` events whenever the SKU's stock changes ### Client-Side Consumption ```javascript const skuNumber = 'PROD-001' const eventSource = new EventSource( `https://api.example.com/ingress/api/skus/${skuNumber}/stock` ) eventSource.addEventListener('init', (e) => { const stock = JSON.parse(e.data) console.log('Initial stock:', stock) updateStockDisplay(stock) }) eventSource.addEventListener('stockUpdated', (e) => { const stock = JSON.parse(e.data) console.log('Stock updated:', stock) updateStockDisplay(stock) }) eventSource.onerror = (e) => { console.log('Connection error, auto-reconnecting...') } // Clean up when done function cleanup() { eventSource.close() } ``` ## Multiple Streams You can combine multiple event sources and immediate values: ```filtrera param orderId: uuid param paymentId: uuid from { event = 'init', data = { orderId, paymentId } } from events ($'actors/order/{orderId}', 'checkpoint') select e => { event = 'orderUpdated', data = e } from events ($'actors/payment/{paymentId}', 'checkpoint') select e => { event = 'paymentUpdated', data = e } ``` Multiple iterators are merged and events are delivered as they arrive from any source. ## Error Handling ### Pre-Stream Errors Errors returned before streaming starts result in standard HTTP errors: ```filtrera import 'text' param skuNumber: text let safeSkuNumber = skuNumber replace("''", "''''") let skuQuery = query skus(skuId) filter $'skuNumber == ''{safeSkuNumber}''' from skuQuery match (e: QueryError) |> { error = { code = 'QUERY_ERROR', message = e.message } } |> let sku = skuQuery first from sku match nothing |> { error = { code = 'NOT_FOUND', message = 'SKU not found' } } |> { event = 'init', data = sku } ``` If the SKU doesn't exist, the client receives: ```http HTTP/1.1 404 Not Found Content-Type: application/json {"error":{"code":"NOT_FOUND","message":"SKU not found"}} ``` The `EventSource.onerror` handler will fire for HTTP error responses. ### Application-Level Errors For errors during streaming, send an error event: ```filtrera from events (topic, eventType) select e => e match { error: text } |> { event = 'error', data = { code = 'STREAM_ERROR', message = e.error } } |> { event = 'updated', data = e } ``` Client handling: ```javascript eventSource.addEventListener('error', (e) => { const error = JSON.parse(e.data) console.error('Application error:', error) }) ``` ### Connection Errors Connection drops trigger the `onerror` handler and automatic reconnection: ```javascript eventSource.onerror = (e) => { if (eventSource.readyState === EventSource.CONNECTING) { console.log('Reconnecting...') } else if (eventSource.readyState === EventSource.CLOSED) { console.log('Connection closed') } } ``` ## Keepalive Hantera sends keepalive comments every 30 seconds to maintain the connection: ``` :keepalive ``` These are automatically ignored by EventSource clients but prevent proxy timeouts. ## Reconnection Behavior The browser's EventSource API automatically reconnects on connection loss: 1. Connection drops 2. Browser waits (default 3000ms, or value from `retry` field) 3. Browser reconnects with `Last-Event-ID` header if IDs were sent On reconnection, the component is re-executed. Design your component to handle this by always sending initial state: ```filtrera from { event = 'init', data = currentState } from events (topic, eventType) select e => { event = 'updated', data = e } ``` ## Testing with cURL ```bash curl -N -H "Accept: text/event-stream" \ https://example.hantera.io/ingress/api/skus/PROD-001/stock ``` The `-N` flag disables buffering for immediate output. ## See Also * [HTTP Ingresses](/resources/ingresses/http/) - Overview and configuration * [Parameter Mapping](/resources/ingresses/http/parameters) - Headers, query params, and body handling * [Response Handling](/resources/ingresses/http/responses) - JSON, streams, and error responses --- --- url: /resources/job-definitions.md --- # Job Definitions Job Definitions are resources that wrap [components](/resources/components/) to make them schedulable. They define what code should run when a [job](/resources/jobs/) is executed. ## What are Job Definitions? A Job Definition is a simple resource that associates a unique ID with a component. It acts as a template for creating jobs: ``` Component (code) → Job Definition (wrapper) → Job (scheduled instance) ``` ### Why Job Definitions? Job Definitions provide a layer of indirection that enables: * **Reusability**: One component can have multiple job definitions * **Organization**: Group related scheduled tasks * **Permissions**: Control who can schedule which components * **Statistics**: Track performance by job definition ## Structure A Job Definition consists of just two fields: * `jobDefinitionId` - Unique identifier for the definition * `componentId` - Which component to execute ```yaml uri: /resources/job-definitions/nightly-order-processing spec: componentId: process-orders.hreactor ``` ## Complete Example Here's how to create a scheduled background task: 1. **Create a component** with the logic to execute: ```filtrera //process-orders.hreactor import 'resources' param processingDate: text let orders = query orders(orderNumber) filter $'createdAt >= {processingDate}' orderBy 'createdAt asc' from orders select order => sendEmail { to = order.customer.email subject = 'Processing Update' body = { html = '

Your order is being processed

' } dynamic = { orderId = order.id } } ``` 2. **Create a job definition** that wraps the component: ```yaml uri: /resources/components/process-orders.hreactor spec: codeFile: process-orders.hreactor --- uri: /resources/job-definitions/nightly-order-processing spec: componentId: process-orders.hreactor ``` 3. **Deploy the manifest:** ```bash h_ manage apply h_manifest.yaml ``` 4. **Schedule a job** using the definition: ```http POST /resources/jobs { "jobDefinitionId": "nightly-order-processing", "parameters": { "processingDate": "2025-11-15T00:00:00Z" }, "runAt": "2025-11-16T02:00:00Z" } ``` Now the component will run at 2 AM with the specified parameters. ## One Component, Multiple Definitions A single component can be wrapped by multiple job definitions for different purposes: ```yaml # Same component for different use cases uri: /resources/job-definitions/hourly-sync spec: componentId: data-sync.hreactor --- uri: /resources/job-definitions/daily-full-sync spec: componentId: data-sync.hreactor --- uri: /resources/job-definitions/manual-sync spec: componentId: data-sync.hreactor ``` This allows: * Different scheduling patterns (hourly vs daily) * Different monitoring/statistics tracking * Different permission scopes * Same underlying logic ## Managing Job Definitions Job definitions are managed via the `/resources/job-definitions` API: * **Create/Update**: `PUT /resources/job-definitions/{definitionId}` * **Get**: `GET /resources/job-definitions/{definitionId}` * **List**: `GET /resources/job-definitions` * **Delete**: `DELETE /resources/job-definitions/{definitionId}` See the [API Reference](/api/http/) for complete endpoint documentation. ::: warning **Deletion Warning**: If you delete a job definition while scheduled jobs for it still exist, those jobs will fail when they attempt to run. Consider waiting for pending jobs to complete before deleting definitions. ::: ## Job Definition vs Job **Job Definition**: * Template/configuration * Points to a component * Permanent resource * One per scheduled task type **Job**: * Scheduled instance * References a job definition * Temporary (deleted after retention period) * Many instances per definition Example: * Job Definition: `nightly-order-processing` (permanent) * Jobs: Individual executions (Nov 15 2AM, Nov 16 2AM, Nov 17 2AM, etc.) ## Access Control Managing job definitions requires permissions: ``` job-definitions:read # View job definitions job-definitions:write # Create, update, delete job definitions ``` ## Statistics & Monitoring Job statistics are tracked by `jobDefinitionId`, making it easy to monitor performance of specific scheduled tasks: ```http GET /resources/jobs/statistics?jobDefinitionId=nightly-order-processing ``` This allows you to track: * Success/failure rates for this specific task * Execution time trends * Queue depth for this definition See [Jobs documentation](/resources/jobs/) for complete statistics information. ## Recurring Jobs Pattern Hantera doesn't have built-in cron scheduling. Instead, components can schedule themselves to create recurring jobs using **relative time scheduling**. ### How It Works A component schedules the next run at the end of its execution: ```filtrera //nightly-processor.hreactor // Do the work let orders = query orders(orderNumber) filter 'status = pending' let processResult = orders select order => processOrder(order) // Schedule next run (24 hours from now) from { effect = 'scheduleJob' definition = 'nightly-order-processing' at = now addDays 1 parameters = {} } ``` This creates a self-perpetuating chain: Job completes → Schedules next run → Next job executes → Schedules another run → ... ::: info **Timing**: The `at` time is relative to when the job **completes**, not when it starts. If a job takes 10 minutes to run and schedules itself with `now addHours 1`, the next run is 1 hour and 10 minutes from the previous start. ::: ### Common Patterns #### Hourly Processing ```filtrera // At end of component from { effect = 'scheduleJob' definition = 'hourly-sync' at = now addHours 1 parameters = {} } ``` #### Daily Processing ```filtrera // At end of component from { effect = 'scheduleJob' definition = 'daily-report' at = now addDays 1 parameters = {} } ``` #### Weekly Processing ```filtrera // At end of component from { effect = 'scheduleJob' definition = 'weekly-cleanup' at = now addDays 7 parameters = {} } ``` #### Custom Intervals ```filtrera // Every 30 minutes from { effect = 'scheduleJob' definition = 'frequent-sync' at = now addMinutes 30 parameters = {} } ``` ### Starting the Recurring Chain Create the first job manually via API or rule to start the chain: ```http POST /resources/jobs { "jobDefinitionId": "nightly-order-processing", "parameters": {}, "runAt": "2025-11-16T02:00:00Z" } ``` After this first execution, the job schedules itself automatically. ### Stopping Recurring Jobs To stop a recurring job chain, cancel the next pending job: ```http DELETE /resources/jobs/{jobId} ``` When a job is cancelled, it never executes, so it never schedules the next job. The chain breaks automatically. **Alternative methods:** * Update the component to remove self-scheduling logic (permanent change) * Delete the job definition (will fail any pending jobs) ## Best Practices ### Use Descriptive IDs Choose clear job definition IDs that describe purpose and frequency: * ✅ `hourly-inventory-sync`, `daily-report-generation`, `weekly-cleanup` * ❌ `job1`, `sync`, `task` ### Use Separate Definitions for Different Purposes Create separate job definitions when you need distinct monitoring or different use cases: ```yaml # Same component, different purposes/monitoring uri: /resources/job-definitions/sync-customers spec: componentId: sync.hreactor --- uri: /resources/job-definitions/sync-products spec: componentId: sync.hreactor ``` This allows independent statistics tracking and clearer monitoring of each use case. ### Check for Pending Jobs Before Deletion Before deleting a job definition, verify no pending jobs exist: ```http GET /resources/jobs?jobDefinitionId=my-definition&status=pending ``` ## See Also * [Components](/resources/components/) - Learn about creating components * [Jobs](/resources/jobs/) - Scheduling and monitoring job executions * [API Reference](/api/http/) - Complete API documentation --- --- url: /resources/jobs.md --- # Jobs Jobs are scheduled executions of [job definitions](/resources/job-definitions) that run components in the background. Jobs capture the result of each execution which can be fetched over API. Jobs are also available in the [Graph](/resources/graph/nodes/job) allowing easy oversight of what is happening in the system. ::: info **Architecture**: Jobs work through a three-layer model: * **Component** - Contains the Filtrera code to execute * **Job Definition** - Wraps a component to make it schedulable * **Job** - Scheduled instance that runs the job definition's component See [Job Definitions](/resources/job-definitions) for details on this architecture. ::: Jobs can be created in multiple ways: * Directly using the REST API (see [HTTP API Reference](/api/http/)) * From a component using the [`scheduleJob`](/resources/components/runtimes/keywords/scheduleJob) keyword * From a Rule using the [`scheduleJob`](/resources/components/runtimes/rule-effects/scheduleJob) effect ## Retention Jobs are generally stored for 14 days after they have finished, after which they are deleted. ## Monitoring & Statistics The Jobs system provides detailed statistics for monitoring job execution and performance. Statistics are aggregated per-minute and can be queried via the REST API. ### Available Metrics Each statistic bucket includes: * **Execution Counts**: Number of jobs scheduled, successfully completed, and failed * **Performance Metrics**: Minimum, maximum, and average execution times (in milliseconds) * **Queue Monitoring**: Snapshot of cache queue depth and total queue depth ### Querying Statistics Statistics can be queried using `GET /resources/jobs/statistics`: ```http GET /resources/jobs/statistics?from=2025-11-14T00:00:00Z&to=2025-11-14T23:59:59Z ``` **Query Parameters:** * `from` (optional): Start time for statistics (defaults to 24 hours ago) * `to` (optional): End time for statistics (defaults to current time) * `jobDefinitionId` (optional): Filter to specific job definition **Response Example:** ```json [ { "jobDefinitionId": "send-email", "bucketTime": "2025-11-14T14:30:00Z", "scheduled": 45, "successful": 43, "failed": 2, "minExecution": 120.5, "maxExecution": 2340.8, "avgExecution": 450.3, "cacheQueueDepth": 12, "totalQueueDepth": 67 } ] ``` ### Common Monitoring Scenarios **Track Job Performance:** ```http GET /resources/jobs/statistics?jobDefinitionId=send-email&from=2025-11-14T00:00:00Z ``` **Monitor Failure Rates:** Query statistics and compare `failed` vs `successful` counts to identify problematic periods. **Identify Performance Bottlenecks:** Look for high `avgExecution` times or increasing queue depths to spot performance issues before they become critical. **Queue Depth Monitoring:** Track `totalQueueDepth` to ensure your job processing capacity matches demand. Rising queue depths may indicate need for scaling. ### Authorization Statistics follow job-level permissions. You can view statistics for jobs you have permission to read. If you have global job statistics permission, you can view statistics across all job definitions. ## Access Control Managing jobs requires permissions: ``` jobs[/]:read # View job data, such as result and status jobs[/]:write # Delete a pending job jobs[/]:schedule # Schedule a job of the given definition type ``` --- --- url: /resources/registry.md --- # Registry Overview ## Access Control The ACE pattern for registry entries looks like this: ``` registry[/]:read|write ``` --- --- url: /resources/registry/reference.md --- # Registry Standard Reference Below is a list of standard registry keys supported by Hantera. Follow the links to find more information about the value format. ::: warning Setting registry values can severely impact the performance of your system. There may also be undocumented keys and undocumented behavior. Proceed with caution. ::: --- --- url: /resources/rules.md --- # Rules Rules are event-driven automation [components](/resources/components/) that react to system events in Hantera. They enable you to validate state changes, enforce business logic, and trigger automated workflows. ## What are Rules? A rule is a component that executes when specific events occur in the system. Rules can: * **Validate** operations before they complete (prevent invalid states) * **Automate** workflows when entities change * **Enforce** business logic across the platform * **Integrate** with external systems via effects Unlike [Jobs](/resources/jobs/) which are time-triggered, rules are **event-triggered** - they run automatically when specific system events occur. ## How Rules Work Rules follow a three-step pattern: 1. **Hook Triggers**: A system event occurs (e.g., order created) 2. **Component Evaluates**: Rule component receives event data and evaluates conditions 3. **Effects Execute**: Component returns effects to execute (commands, validations, integrations) ```mermaid graph LR A[Event] --> B[Rule Hook Triggers] B --> C[Component Evaluates] C --> D[Return Effects] D --> E[Effects Execute] ``` ::: info Rules use components with the **rule runtime**, which has more capabilities than the discount runtime but less than the reactor runtime. See [Runtime Reference](/resources/components/runtimes/) for details on what's available. ::: ## Simple Example: Order Confirmation Email Here's a complete example that sends an email when an order is created: 1. **Create the rule component:** ```filtrera //order-confirmation.hrule import 'resources' // 'input' is a special parameter and the designated type determines when/if the rule will evaluate: param input: OnOrderCreated let customerEmail = input.order.invoiceAddress.email from customerEmail match /@/ |> { effect = 'sendEmail' to = email subject = $'Order #{input.order.orderNumber} Confirmed' body = { html = $'

Thank you for your order!

Order number: {input.order.orderNumber}

' } category = 'order_confirmation' dynamic = { orderId = input.order.orderId } } ] ``` 2. **Deploy via manifest:** ```yaml uri: /resources/rules/order-confirmation spec: codeFile: order-confirmation.hrule ``` 3. **Apply the manifest:** ```bash h_ manage apply h_manifest.yaml ``` Now every time an order is created, the customer receives a confirmation email automatically. ## Rule Hooks Rules are triggered by system defined **hooks**. [Read more about hooks →](/resources/rules/hooks) ## Rule Effects Rules return **effects** - instructions for what should happen. ::: warning **Effect Availability**: Not all effects work in all hooks. Before hooks typically support validation and commands, while after hooks support broader effects like messageActor and scheduleJob. Check individual hook documentation in the [Runtime Reference](/resources/components/runtimes/) to see which effects are supported in each context. ::: Effects include: ### Actor Commands Apply commands to actors: * `orderCommand` - Modify orders * `paymentCommand` - Modify payments * `ticketCommand` - Modify tickets * `skuCommand` - Modify SKUs * `assetCommand` - Modify assets ### System Operations * `messageActor` - Send messages to other actors * `scheduleJob` - Schedule background jobs ### Validation * `validationError` - Prevent operation and return error [See all effects →](/resources/rules/effects) ## Common Use Cases ### Validation Rules Prevent invalid state changes: ```filtrera import 'iterators' param input: OnOrderBeforeCreated let hasItems = input.order.deliveries select d => d.orderLines flatten count > 0 from hasItems match false |> { effect = 'validationError' code = 'EMPTY_ORDER' message = 'Orders must have at least one item' } ``` ### Automation Rules Trigger workflows automatically: ```filtrera param input: OnPaymentCapture from input.paymentCapture.capturedAmount > 10000 match true |> { effect = 'messageActor' actorType = 'ticket' actorId = 'new' messages = [{ type = 'create' commands = [{ type = 'setDynamicFields', fields = { title = 'Large Payment Review' description = $'Payment {input.payment.paymentNumber} for {input.paymentCapture.capturedAmount} needs review' paymentId = input.payment.paymentId } }] }] } ``` ### Integration Rules Sync with external systems: ```filtrera param input: OnOrderCreated from [{ effect = 'scheduleJob' definition = 'sync-to-erp' at = datetime.now addMinutes 5 parameters = { orderId = order.id } }] ``` ## Multiple Effects Rules can return multiple effects as an array: ```filtrera param input: OnOrderCreated from { effect = 'orderCommand' type = 'createComputedOrderDiscount' componentId = 'free-shipping' description = 'Free Shipping' parameters = { threshold = '500' } } from { effect = 'sendEmail' to = order.customer.email subject = 'Order Confirmed' body = { html = '

Thank you!

' } dynamic = {} } ``` All effects execute in the order specified. ## Access Control Managing rule resources requires standard permissions: ``` rules:read # View rule configurations rules:write # Create, update, and delete rules ``` Rules inherit the execution permissions of the system - they run with elevated privileges to enforce business logic across the platform. ## Best Practices ### 1. Use Before Hooks for Validation Prevent invalid operations with before hooks: ```filtrera hook OnOrderBeforeCreated from validateOrder(order) match Error |> [{ effect = 'validationError', code = error.code, message = error.message }] |> [] ``` ### 2. Use After Hooks for Automation Trigger workflows after successful operations: ```filtrera hook OnOrderCreated from [ { effect = 'sendEmail', ... }, { effect = 'scheduleJob', ... } ] ``` ### 3. Handle Missing Data Gracefully Always handle optional data: ```filtrera from input.order.invoiceAddress.email match email |> { effect = 'sendEmail', to = email, ... } // Default case returns nothing ``` ## See Also * [Components](/resources/components/) - Learn about components and runtimes * [Rule Hooks](/resources/rules/hooks) - Available lifecycle hooks * [Rule Effects](/resources/rules/effects) - Available effects * [Common Patterns](/resources/rules/patterns) - Recipe-style examples * [Runtime Reference](/resources/components/runtimes/) - Detailed runtime documentation --- --- url: /resources/rules/hooks.md --- # Rule Hooks Rule hooks are system events that trigger rule components. Each hook fires at a specific point in a resource's lifecycle, allowing you to validate operations, enforce business logic, or trigger automations. The hook system is organized into two families: * **Actor Lifecycle Hooks** — events tied to the lifecycle of actors (orders, payments, tickets, SKUs, assets) * **Rule Lifecycle Hooks** — events tied to the management of rules themselves ## Actor Lifecycle Hooks These hooks fire at specific points in an actor's create/delete/command/journal lifecycle. ### Hook Timing **Before Hooks** (`OnXxxBeforeCreated`, `OnXxxBeforeDeleted`): Execute before the operation completes. Can prevent it with a validation error. **After Hooks** (`OnXxxCreated`, `OnXxxDeleted`, and state-change hooks like `OnPaymentCapture`): Execute after the operation completes, once changes are persisted. **Command Hooks** (`OnXxxCommands`): Execute when commands are applied to an actor. Can emit additional commands. **Calculate Hooks** (`OnOrderCalculate`): Execute after `OnOrderCommands` and its emitted commands have been applied, and before `OnOrderValidate`. Intended for derived calculations that depend on the enriched state produced by `OnOrderCommands` rules — for example tax calculation after price enrichment. **Validate Hooks** (`OnXxxValidate`): Execute after `OnXxxCommands` (and, for the order actor, `OnOrderCalculate`) and their emitted commands have been applied. Intended for invariants that can only be checked once all preceding rules have settled — either reject the actor state with `validationError` or auto-fix it by emitting more commands. **Invoice Hooks** (`OnOrderInvoiceCreated`, `OnOrderInvoiceCancelled`): Execute when an actor message has created or cancelled invoices on the order. The `invoices` field carries only the invoices that changed in that message, never pre-existing ones. Cancellation includes rewinding the order past an invoice, since invoices are cancelled rather than removed. Re-activating an invoice by rewinding forward again fires neither hook. **Journal Hooks** (`OnXxxJournal`): Execute when a journal entry is created on an actor. [See all actor lifecycle hooks →](/resources/actors/lifecycle-hooks) ## Rule Lifecycle Hooks These hooks fire when rules themselves are created, updated, or deleted — useful for syncing rule configuration to external systems or triggering downstream workflows. ### OnRuleCreated Fires after a rule has been created. Receives the new rule's full state. **Supported effects**: `messageActor`, `scheduleJob`, `sendEmail` [→ Runtime reference](/resources/components/runtimes/rule-hooks/onRuleCreated) ### OnRuleUpdated Fires after a rule has been updated. Receives both the new state (`rule`) and the previous state (`before`), making it easy to detect what changed. **Supported effects**: `messageActor`, `scheduleJob`, `sendEmail` [→ Runtime reference](/resources/components/runtimes/rule-hooks/onRuleUpdated) ### OnRuleDeleted Fires after a rule has been deleted. Receives the rule's final state before deletion. **Supported effects**: `messageActor`, `scheduleJob`, `sendEmail` [→ Runtime reference](/resources/components/runtimes/rule-hooks/onRuleDeleted) ## Choosing the Right Hook ### Use Actor Before Hooks when: * Validating input before operations complete * Preventing invalid state changes * Checking business rule constraints ### Use Actor After Hooks when: * Sending notifications * Triggering external integrations * Creating related entities * Scheduling follow-up work ### Use Actor Command Hooks when: * Deriving additional changes from commands * Applying cascading updates * Enforcing command combinations ### Use Actor Validate Hooks when: * Enforcing invariants that depend on the post-`*Commands` state (where one `*Commands` rule's output is another rule's input) * Auto-fixing derived state that other rules left inconsistent * Rejecting actor changes based on the final shape of the actor after all reactions have settled ### Use Rule Lifecycle Hooks when: * Auditing rule changes in an external system * Invalidating caches when rule configuration changes * Triggering workflows that depend on which rules are active ## See Also * [Rules Overview](/resources/rules/) — Introduction to rules * [Actor Lifecycle Hooks](/resources/actors/lifecycle-hooks) — Full actor hook reference * [Rule Effects](/resources/rules/effects) — Available effects * [Common Patterns](/resources/rules/patterns) — Recipe-style examples * [Runtime Reference](/resources/components/runtimes/) — Detailed hook documentation --- --- url: /resources/rules/effects.md --- # Rule Effects Rule effects are the actions that rules execute in response to system events. A rule component returns one or more effects that instruct the system what operations to perform. ::: info **Current Effects**: Available effects currently focus on actor commands and system operations. As new hook types are added (e.g., for files, registry), corresponding effects may be introduced. ::: ::: warning **Effect Availability Varies by Hook**: Not all effects work in all hooks. For example, `validationError` only works in Before hooks, while `messageActor` and `scheduleJob` typically work in After hooks. Always check the individual hook documentation in the [Runtime Reference](/resources/components/runtimes/) to see which effects are supported in each specific hook context. ::: ## Effect Categories ### Actor Command Effects Apply commands to specific actor types (current primary effect category): #### orderCommand Apply commands to orders: ```filtrera from { effect = 'orderCommand' type = 'createPromotion' componentId = 'free-shipping' promotionGroup = 'shipping' description = 'Free Shipping' parameters = { threshold = '500' } } ``` See [Order Commands](/resources/actors/order/commands/) for available command types. #### paymentCommand Apply commands to payments: ```filtrera from { effect = 'paymentCommand' type = 'setDynamicFields' fields = { externalId = 'PAY-12345' } } ``` See [Payment Commands](/resources/actors/payment/commands/) for available command types. #### ticketCommand Apply commands to tickets: ```filtrera from { effect = 'ticketCommand' type = 'assign' userId = 'user-123' } ``` See [Ticket Commands](/resources/actors/custom/ticket/commands/) for available command types. #### skuCommand Apply commands to SKUs: ```filtrera from { effect = 'skuCommand' type = 'setDynamicFields' fields = { supplier = 'SUPPLIER-001' } } ``` See [Sku Commands](/resources/actors/sku/commands/) for available command types. #### assetCommand Apply commands to assets: ```filtrera from [{ effect = 'assetCommand' type = 'setDynamicFields' fields = { location = 'WAREHOUSE-A' } }] ``` See [Asset Commands](/resources/actors/custom/asset/commands/) for available command types. ### System Operation Effects #### messageActor Send messages to other actors: ```filtrera from { effect = 'messageActor' actorType = 'order' actorId = '550e8400-e29b-41d4-a716-446655440000' messages = [{ type = 'applyCommands' body = { commands = [{ type = 'addTag' value = 'processed' }] } }] } ``` **Fields:** * `actorType` - Type of actor (order, payment, ticket, sku, asset) * `actorId` - ID of the actor (or 'new' to create) * `messages` - Array of messages to send #### scheduleJob Schedule a job to run later: ```filtrera from { effect = 'scheduleJob' definition = 'process-order' at = datetime.now addHours 24 parameters = { orderId = order.id } } ``` **Fields:** * `definition` - Job definition/component ID * `at` - When to run (instant or nothing, defaults to immediate if omitted) * `parameters` - Parameters to pass (optional) #### sendEmail Queue an email for delivery: ```filtrera from { effect = 'sendEmail' to = input.order.invoiceAddress.email subject = 'Order Confirmation' body = { plainText = 'Thank you for your order!' html = '

Thank you!

' } category = 'order-confirmation' dynamic = { orderId = input.order.orderId :: text } } ``` **Fields:** * `to` - Recipient email address (required) * `subject` - Email subject line (required) * `body` - Email body with `plainText` and/or `html` (at least one required) * `cc` - CC recipients array (optional) * `bcc` - BCC recipients array (optional) * `from` - Sender email address (optional, uses system default) * `fromName` - Sender display name (optional) * `replyTo` - Reply-to email address (optional) * `category` - Category for tracking (optional, defaults to 'rule') * `dynamic` - Custom metadata/tracking data (optional) ### Custom Effect #### custom A pass-through effect that is **not processed by the platform**. Custom effects are designed for inter-rule communication via [`triggerHook`](/resources/rules/hooks#triggerhook). Hook listeners emit custom effects, and the calling rule collects them to decide what action to take. ```filtrera from { effect = 'custom' type = 'createReplacementOrder' productNumber = 'SKU-001' quantity = 1 } ``` **Required Fields:** * `effect` - Must be the literal `'custom'` * `type` - A text identifier for the custom effect type Additional fields are allowed — the record is open, so you can include any data the calling rule needs. **How it works:** 1. A parent rule calls `triggerHook` to trigger a secondary hook evaluation 2. Hook listeners return effects — including custom effects 3. The parent rule receives all effects as an iterator and decides what to do with them ```filtrera // Parent rule collects custom effects from hook listeners let hookEffects = { orderId = order.id } triggerHook 'OnOrderValidated' let replacements = hookEffects where e is { effect: 'custom', type: 'createReplacementOrder', productNumber: text, quantity: number } // Parent decides how to handle them from replacements select r => { effect = 'messageActor' actorType = 'order' actorId = 'new' messageType = 'create' body = { productNumber = r.productNumber, quantity = r.quantity } } ``` ::: tip Custom effects are silently ignored by the platform's effect processor. They only have meaning when collected by a calling rule via `triggerHook`. ::: ### Validation Effect #### validationError Prevent an operation and return an error: ```filtrera from { effect = 'validationError' code = 'INVALID_STATE' message = 'Operation not allowed in current state' } ``` **Fields:** * `code` - Error code (string) * `message` - Error message (string) **Important**: Only works in Before hooks. After hooks cannot prevent operations. ## Effect Syntax ### Single Effect Return one effect: ```filtrera param input: OnOrderCreated from { effect = 'orderCommand' type = 'addTag' value = 'processed' } ``` ### Multiple Effects Return array of effects: ```filtrera param input: OnOrderCreated from [ { effect = 'orderCommand' type = 'addTag' value = 'confirmed' }, sendEmail { to = input.order.invoiceAddress.email subject = 'Order Confirmed' body = { html = '

Thank you!

' } dynamic = {} } ] ``` ### No Effects Return empty array when no effects needed: ```filtrera param input: OnOrderCreated from input.order.total > 1000 match false |> [] // No effects true |> [{ effect = 'orderCommand', ... }] ``` ## Combining Effects Rules can combine different effect types: ```filtrera param input: OnOrderCreated from [ // Add promotion { effect = 'orderCommand' type = 'createPromotion' componentId = 'loyalty-discount' promotionGroup = 'loyalty' description = 'Loyalty Member Discount' }, // Send email sendEmail { to = order.customer.email subject = 'Order Confirmed' body = { html = '

Thank you!

' } dynamic = { orderId = order.id } }, // Schedule follow-up { effect = 'scheduleJob' definition = 'order-follow-up' at = datetime.now addDays 7 parameters = { orderId = order.id } } ] ``` ## Common Patterns ### Conditional Effects Use pattern matching to conditionally execute effects: ```filtrera param input: OnOrderCreated from order.total >= 500 match true |> { effect = 'orderCommand' type = 'addTag' value = 'high-value' } ``` ### Effect Based on Data Build effects dynamically: ```filtrera param input: OnOrderCreated let tags = input.order.total match when input.order.total >= 1000 |> ['high-value', 'priority'] when input.order.total >= 500 |> ['high-value'] |> [] from tags select tag => { effect = 'orderCommand' type = 'addTag' value = tag } ``` ### Validation Errors Prevent operations in before hooks: ```filtrera param input: OnOrderBeforeCreated let errors = validateOrder(input.order) from errors count > 0 match false |> [] true |> errors select e => { effect = 'validationError' code = e.code message = e.message } ``` ## See Also * [Rules Overview](/resources/rules/) - Introduction to rules * [Rule Hooks](/resources/rules/hooks) - Available lifecycle hooks * [Common Patterns](/resources/rules/patterns) - Recipe-style examples * [Runtime Reference](/resources/components/runtimes/) - Detailed effect documentation --- --- url: /resources/rules/trigger-hook.md --- # Custom Hooks - triggerHook `triggerHook` enables apps to define custom hook points that other rules can listen to, without requiring platform changes. It triggers a secondary rule evaluation inline and returns the resulting effects to the caller. ## Availability | Context | Available | |---------|-----------| | Rules (`.hrl`) | ✅ | | Ingresses (`.hrc`) | ✅ | | Jobs (`.hrc`) | ✅ | | Modules (`.module.hrc`) | ✅ (when imported by an ingress or job) | ## Syntax `triggerHook` is a filter that takes hook data as input and the hook name as argument: ```filtrera let hookEffects = { some = 'data', more = 'fields' } triggerHook 'OnMyHook' ``` ### Input Any record value. The fields become directly accessible to listening rules alongside a `hook` field that the runtime adds automatically. ### Argument A text literal — the hook name. Convention: `On` prefix, PascalCase (e.g., `OnClaimResolve`, `OnCartMutation`). ### Return Value An iterator of records — the effects emitted by all rules that matched the hook. Returns an empty iterator `[]` when called inside a secondary evaluation (recursion prevention). ## Hook Input Construction The runtime adds a `hook` field to the input record before evaluating rules: ```filtrera // Caller: { orderId = '...' } triggerHook 'OnOrderValidated' // What listening rules receive: { hook = 'OnOrderValidated' orderId = '...' } ``` There is no `data` wrapper — the hook data fields are at the top level alongside `hook`. ## Listening Rules Rules listen to a custom hook by declaring a `param input` with a literal `hook` type: ```filtrera param input: { hook: 'OnOrderValidated' orderId: uuid } import 'iterators' from { effect = 'custom' type = 'addComplianceFlag' flag = 'validated' } ``` The literal type `hook: 'OnOrderValidated'` ensures this rule only fires for that specific hook. It is automatically skipped for all native hooks and other custom hooks. ::: tip Listening rules can declare a subset of the input fields. As long as the types match, Filtrera's type system will accept the rule. You don't need to match every field the caller provides. ::: ## Recursion Prevention `triggerHook` is single-level only. If a rule triggered by `triggerHook` calls `triggerHook` again, it immediately returns an empty iterator. This is enforced by the runtime — there is no configuration. ## Using triggerHook in Rules In rules, `triggerHook` returns effects that the calling rule can forward, filter, or batch. The actor spec processes all forwarded effects. ### Forwarding All Effects ```filtrera param input: OnOrderCreated let hookEffects = { orderId = input.order.orderId } triggerHook 'OnOrderValidated' // Forward everything from hookEffects ``` ### Filtering Effects ```filtrera param input: OnOrderCreated let hookEffects = { orderId = input.order.orderId } triggerHook 'OnOrderValidated' // Only forward validation errors from hookEffects where is { effect: 'validationError' } ``` ### Batching orderCommand Effects A common pattern: collect `orderCommand` effects from hook listeners and batch them into a single `messageActor`: ```filtrera let hookEffects = { orderId = orderId, lines = lines } triggerHook 'OnClaimResolve' let hookOrderCommands = hookEffects where e => e.effect == 'orderCommand' from hookOrderCommands count > 0 match true |> { effect = 'messageActor' actorType = 'order' actorId = orderId messageType = 'applyCommands' body = { commands = hookOrderCommands } } ``` ## Using triggerHook in Ingresses and Jobs In the reactor runtime (ingresses, jobs, modules), `triggerHook` works the same way — it triggers rule evaluation and returns the effects. However, there is no actor spec to automatically process the returned effects. The calling component must explicitly apply them. ### Effect Processing The caller receives effect records and must use its own runtime capabilities (`messageActor`, `scheduleJob`, etc.) to apply them: ```filtrera import 'iterators' let hookEffects = { cartId = cartId, cart = renderedCart } triggerHook 'OnCartMutation' let effects = hookEffects buffer // Apply ticketCommand effects via messageActor let commandEffects = effects where is { effect: 'ticketCommand' } buffer from commandEffects count > 0 match true |> from messageActor ('ticket', cartId, [{ type = 'applyCommands' body = { commands = commandEffects select e => { type = e.type, fields = e.fields } as `list` } }]) // Schedule jobs from scheduleJob effects let jobEffects = effects where is { effect: 'scheduleJob' } buffer from jobEffects select j => from scheduleJob (j.definition, j.at, j.parameters) ``` ## Custom Effects When defining hook points, listeners often use the `custom` effect type to return data to the caller without triggering platform-level processing: ```filtrera // Listener returns custom effect from { effect = 'custom' type = 'priceLookup' priceListId = 'wholesale-2025' discount = 0.15 } ``` ```filtrera // Caller collects custom effects let hookEffects = { currencyCode = 'SEK' } triggerHook 'OnPriceListSelect' let priceList = hookEffects where is { effect: 'custom', type: 'priceLookup' } first ``` Custom effects are silently ignored by the platform's effect processor — they only have meaning when collected by the caller. ## Hook Naming Convention | Convention | Example | |---|---| | Use `On` prefix | `OnCartMutation`, `OnClaimResolve` | | PascalCase | `OnOrderValidated`, `OnPriceListSelect` | | Describe the event | What happened, not what should happen | ## See Also * [Rule Effects](/resources/rules/effects) — Available effect types including `custom` * [Rule Hooks](/resources/rules/hooks) — Built-in lifecycle hooks * [Common Patterns](/resources/rules/patterns) — Recipe-style examples --- --- url: /resources/rules/patterns.md --- # Common Rule Patterns This page provides recipe-style examples of common rule patterns you can adapt for your own use cases. ## Validation Patterns ### Require Minimum Order Value ```filtrera param input: OnOrderBeforeCreated from input.order.total >= 100 match false |> [{ effect = 'validationError' code = 'MINIMUM_ORDER_VALUE' message = 'Minimum order value is 100' }] true |> [] ``` ### Validate Email Address ```filtrera param input: OnOrderBeforeCreated let hasEmail = input.order.invoiceAddress.email match nothing |> false email |> email contains '@' from hasEmail match false |> [{ effect = 'validationError' code = 'INVALID_EMAIL' message = 'Valid email address required' }] true |> [] ``` ### Prevent Deletion of Active Entities ```filtrera param input: OnTicketBeforeDeleted from input.ticket.status == 'active' match true |> [{ effect = 'validationError' code = 'CANNOT_DELETE_ACTIVE' message = 'Cannot delete active tickets' }] false |> [] ``` ## Automation Patterns ### Auto-Tag High-Value Orders ```filtrera param input: OnOrderCreated let tags = input.order.total match when input.order.total >= 10000 |> ['vip', 'high-value', 'priority-shipping'] when input.order.total >= 5000 |> ['high-value', 'priority-shipping'] when input.order.total >= 1000 |> ['high-value'] |> [] from tags select tag => { effect = 'orderCommand' type = 'addTag' value = tag } ``` ### Create Support Ticket on Payment Failure ```filtrera param input: OnPaymentCapture from input.payment.status == 'failed' match false |> [] true |> [{ effect = 'messageActor' actorType = 'ticket' actorId = 'new' messages = [{ type = 'create' body = { title = 'Payment Failed' description = $'Payment {input.payment.id} failed for order {input.payment.orderId}' priority = 'high' } }] }] ``` ### Schedule Follow-Up Actions ```filtrera param input: OnOrderCreated from [{ effect = 'scheduleJob' definition = 'order-follow-up' at = now + 7 days parameters = { orderId = input.order.orderId } }] ``` ## Notification Patterns ### Order Confirmation Email ```filtrera import 'resources' param input: OnOrderCreated from input.order.invoiceAddress.email match nothing |> [] email |> [ sendEmail { to = email subject = $'Order #{input.order.orderNumber} Confirmed' body = { html = $'

Thank you for your order!

Order number: {input.order.orderNumber}

Total: {input.order.total} {input.order.currencyCode}

' } category = 'order_confirmation' dynamic = { orderId = input.order.orderId :: text } } ] ``` ### Payment Captured Notification ```filtrera import 'resources' param input: OnPaymentCapture from input.payment.orderId match nothing |> [] orderId |> [ sendEmail { to = 'finance@company.com' subject = $'Payment Captured: {input.payment.amount} {input.payment.currencyCode}' body = { html = $'

Payment {input.payment.id} for order {orderId} has been captured.

' } category = 'payment_captured' dynamic = { paymentId = input.payment.id :: text } } ] ``` ### Ticket Completion Notification ```filtrera param input: OnTicketComplete from [{ effect = 'messageActor' actorType = 'order' actorId = input.ticket.orderId messages = [{ type = 'applyCommands' body = { commands = [{ type = 'addActivityLog' message = $'Support ticket {input.ticket.id} resolved' }] } }] }] ``` ## Promotion Patterns ### Loyalty Member Promotion ```filtrera param input: OnOrderCreated let isLoyaltyMember = input.order.tags any t => t == 'loyalty-member' from isLoyaltyMember match false |> [] true |> [{ effect = 'orderCommand' type = 'createPromotion' componentId = 'loyalty-discount' promotionGroup = 'loyalty' description = 'Loyalty Member Discount' parameters = { discountRate = '10' } }] ``` ## Integration Patterns ### Sync to External ERP ```filtrera param input: OnOrderCreated from [{ effect = 'scheduleJob' definition = 'erp-sync' at = now + 5 minutes parameters = { orderId = input.order.orderId action = 'create' } }] ``` ### Webhook on Payment Capture ```filtrera param input: OnPaymentCapture from [{ effect = 'scheduleJob' definition = 'payment-webhook' parameters = { paymentId = input.payment.id webhookUrl = 'https://api.external.com/webhooks/payment' } }] ``` ## Cascading Logic Patterns ### Auto-Complete Delivery on All Items Shipped ```filtrera param input: OnOrderCommands let allShipped = input.order.orderLines all line => line.shippedQuantity == line.quantity let hasDelivery = input.order.deliveries count > 0 from allShipped and hasDelivery match false |> [] true |> input.order.deliveries select delivery => { effect = 'orderCommand' type = 'completeDelivery' deliveryId = delivery.deliveryId } ``` ### Set Invoice Address from Delivery ```filtrera param input: OnOrderCommands let needsInvoiceAddress = input.order.invoiceAddress match nothing |> true |> false from needsInvoiceAddress match false |> [] true |> input.order.deliveries first match nothing |> [] delivery |> [{ effect = 'orderCommand' type = 'setInvoiceAddress' address = delivery.address }] ``` ## Multi-Step Patterns ### Order Confirmation with Promotion Combine multiple effects for complex workflows: ```filtrera import 'resources' param input: OnOrderCreated let promotionEffect = input.order.tags any t => t == 'loyalty' match false |> [] true |> [{ effect = 'orderCommand' type = 'createPromotion' componentId = 'loyalty-discount' promotionGroup = 'loyalty' description = 'Loyalty Member - 10% Off' }] let emailEffect = input.order.invoiceAddress.email match nothing |> [] email |> [ sendEmail { to = email subject = $'Order #{input.order.orderNumber} Confirmed' body = { html = '

Thank you!

' } dynamic = { orderId = input.order.orderId :: text } } ] from [promotionEffect, emailEffect] flatten ``` ## Conditional Validation ### Stock Availability Check ```filtrera param input: OnOrderBeforeCreated let stockCheck = input.order.orderLines select line => query skus(skuNumber, availableStock) filter $'skuNumber = {line.productNumber}' first match nothing |> { sku = line.productNumber, available = 0, needed = line.quantity } sku |> { sku = line.productNumber, available = sku.availableStock, needed = line.quantity } let outOfStock = stockCheck where item => item.available < item.needed from outOfStock count > 0 match false |> [] true |> [{ effect = 'validationError' code = 'INSUFFICIENT_STOCK' message = $'{outOfStock count} items out of stock' }] ``` ## See Also * [Rules Overview](/resources/rules/) - Introduction to rules * [Rule Hooks](/resources/rules/hooks) - Available lifecycle hooks * [Rule Effects](/resources/rules/effects) - Available effects * [Runtime Reference](/resources/components/runtimes/) - Detailed documentation * [Order Discounts](/resources/actors/order/discounts) - Static discount patterns * [Order Promotions](/resources/actors/order/promotions) - Dynamic promotion patterns --- --- url: /resources/sendings.md description: > Queue-based communication delivery system for emails, SMS, and push notifications --- # Sendings The **Sending** resource is Hantera's centralized communication queue system. It tracks queued messages (emails, and in the future: SMS, push notifications) through the entire delivery lifecycle with granular status tracking and automatic retries. ## What is a Sending? A Sending represents a single queued communication to a primary recipient. When you send an email using the `sendEmail` function, a Sending record is created to track delivery status, retries, and any errors that occur. **Key characteristics:** * **One record per primary recipient**: Multi-recipient emails create one record for the `to` recipient; CC/BCC are best-effort delivery * **Asynchronous processing**: Messages are queued and processed in the background * **Status tracking**: Monitor pending, sent, and bounced messages via the Graph API * **Rate limiting**: Respects configured rate limits to avoid overwhelming mail servers * **Automatic retries**: Transient failures are automatically retried with exponential backoff (up to 3 times) ::: info **System Sendings:** The Sending system is used both by apps (through `sendEmail`) and by Hantera's internal platform features (password resets, email validations, etc.). Platform-generated sendings use categories with the `system:` prefix (e.g., `system:password_reset`). This prefix is reserved and cannot be used in app-created sendings. ::: ## Creating Sendings Sendings can be created in two ways depending on your use case: ### Via Filtrera (Components/Reactors) Use the `sendEmail` function in Filtrera for component logic: ```filtrera import 'resources' from sendEmail { to = 'customer@example.com' subject = 'Order Confirmation' body = { plainText = 'Thank you for your order!' html = '

Thank you for your order!

' } category = 'order_confirmation' dynamic = { orderId = order.id } } ``` **See:** [sendEmail function documentation](/resources/components/runtimes/modules/resources/sendEmail) ### Via REST API (External Applications) For external applications, use the HTTP API: ```bash POST /resources/sendings/email ``` ```json { "to": "customer@example.com", "subject": "Order Confirmation", "body": { "plainText": "Thank you for your order!", "html": "

Thank you for your order!

" }, "category": "order_confirmation", "dynamic": { "orderId": "12345" } } ``` ::: tip See the [HTTP API Reference](/api/http/) for complete REST API documentation including all endpoints, request/response schemas, and authentication requirements. ::: ## Lifecycle & Status Each Sending progresses through these states: ### pending The message is queued and waiting to be processed by the background service. Pending sendings can be cancelled via the REST API. ### sent The message was successfully delivered to the mail server. Note that this indicates delivery to the mail server, not necessarily to the recipient's inbox. ### bounced Delivery failed after all retries were exhausted. Check the `errorMessage` field for details. ### cancelled The sending was cancelled before being processed. Only pending sendings can be cancelled using the `DELETE /resources/sendings/{id}` REST endpoint. ## Transport Types The `transport` field indicates the communication channel: * **`email`**: Email delivery (currently supported) * **`sms`**: SMS delivery (planned) * **`push`**: Push notification (planned) Currently, only `email` transport is implemented. ## Processing Behavior ::: info Sendings are processed **asynchronously** by a background service. They are not sent immediately when created. ::: **How it works:** 1. **Queueing**: When `sendEmail` is called, a Sending record is created with `pending` status 2. **Processing**: Background service processes the queue every 10 seconds (configurable) 3. **Rate limiting**: Default 60 emails/minute (configurable) 4. **Retries**: Failed deliveries are retried up to 3 times with exponential backoff 5. **Final status**: After retries, status becomes either `sent` or `bounced` ## Multi-Recipient Behavior When you provide `cc` or `bcc` lists: * **One Sending record** is created for the primary `to` recipient (with tracked status) * **CC and BCC recipients** receive the email as best-effort delivery * **No individual tracking** for CC/BCC recipients The `cc` and `bcc` recipients are stored as comma-separated strings in the Sending record's data field, but do not have their own Sending records or status tracking. ## Querying Sendings Use the Graph API to query Sending records: ```json [ { "edge": "sendings", "filter": "status == 'pending'", "orderBy": "createdAt desc", "node": { "fields": ["sendingId", "recipient", "category", "createdAt"] } } ] ``` **See:** [Sending Graph Node](/resources/graph/nodes/sending) for complete query documentation ## Custom Data Store queryable data in the `dynamic` field: ```filtrera import 'resources' from sendEmail { to = customer.email subject = 'Campaign Offer' body = { html = '

Special offer just for you!

' } dynamic = { customerId = customer.id campaignId = campaign.id segmentId = segment.id } } ``` Then define custom Graph fields to query this data: ```yaml uri: /resources/registry/graph/sending/fields/campaignId spec: value: type: text source: dynamic->'campaignId' ``` Now you can query by campaign: ```json [ { "edge": "sendings", "filter": "campaignId == '12345' and status == 'sent'", "count": true } ] ``` ## Access Control **Creating sendings** requires the `sendings:write` permission: ``` sendings:write # Create new sendings via sendEmail or REST API ``` **Querying sendings** uses the Graph API, which requires Graph-specific permissions: ``` graph/sending:query # Query sending records graph/sending:field # Access specific fields ``` **Managing sendings via REST** requires: ``` sendings:write # POST /resources/sendings/email (create) sendings:read # GET /resources/sendings/{id} (retrieve status) sendings:write # DELETE /resources/sendings/{id} (cancel) ``` ::: info The REST API provides endpoints for creating and managing sendings. For querying and analytics, use the [Graph API](/resources/graph/). See [Access Control](/learn/access-control) for more on permission patterns. ::: ## REST API The Sending resource includes a REST API for queueing, monitoring, and cancelling email delivery from external applications. ### Available Endpoints * **`POST /resources/sendings/email`** - Queue an email for delivery * **`GET /resources/sendings/{id}`** - Retrieve sending status and details * **`DELETE /resources/sendings/{id}`** - Cancel a pending sending ::: tip See the [HTTP API Reference](/api/http/) for complete endpoint documentation including request/response schemas, error codes, and authentication requirements. ::: ### When to Use REST vs Filtrera **Use REST API when:** * Building external integrations * Sending emails from non-Hantera environments * Need direct HTTP access without Filtrera runtime **Use Filtrera `sendEmail` when:** * Writing automations using rules and jobs within Hantera * Building Hantera apps ### Examples For practical examples of using the Sendings REST API in real-world scenarios, see the [Customer Registration for E-Commerce](/learn/guides/customer-registration) guide which demonstrates sending registration confirmations and password reset emails. ### Cancelling Sendings Only sendings with `pending` status can be cancelled. Attempting to cancel sent, bounced, or already cancelled sendings returns a `409 Conflict` error. ```bash DELETE /resources/sendings/{sendingId} ``` **Returns:** * `204 No Content` - Successfully cancelled * `404 Not Found` - Sending does not exist * `409 Conflict` - Sending status is not pending ## Monitoring & Troubleshooting ### Check Queue Depth Monitor the number of pending sendings: ```json [ { "edge": "sendings", "filter": "status == 'pending'", "count": true } ] ``` **Health indicators:** * Healthy: < 50 pending * Warning: 50-100 pending * Critical: > 100 pending ### Find Recent Failures ```json [ { "edge": "sendings", "filter": "status == 'bounced' and createdAt >= '2025-10-30T00:00:00Z'", "orderBy": "createdAt desc", "node": { "fields": ["sendingId", "recipient", "errorMessage", "retryCount", "category"] } } ] ``` ### Calculate Bounce Rate ```json [ { "edge": "sendings", "alias": "total", "filter": "createdAt >= '2025-10-01T00:00:00Z'", "count": true }, { "edge": "sendings", "alias": "bounced", "filter": "createdAt >= '2025-10-01T00:00:00Z' and status == 'bounced'", "count": true } ] ``` A bounce rate above 5% may indicate email list quality issues. ## Related Resources * **[sendEmail Function](/resources/components/runtimes/modules/resources/sendEmail)** - Filtrera function for creating sendings * **[Sending Graph Node](/resources/graph/nodes/sending)** - Query syntax and patterns * **[Sending Emails Guide](/learn/guides/sending-emails)** - Complete guide with use cases and examples * **[Custom Fields](/resources/graph/custom-fields)** - Define queryable fields on dynamic data --- --- url: /official-apps.md --- # Official Apps Official Apps are apps maintained by Hantera that extend the platform with common functionality. They are installed on your Hantera instance and provide ingresses, rules, jobs, and portal extensions. Many apps can be interacted with through APIs and rules, and this section contains relevant developer-centric documentation. ## Available Apps ### [Commerce](/official-apps/commerce/) Cart and promotion management for e-commerce. Provides HTTP ingresses for creating and managing shopping carts, a rendering pipeline for computing totals and prices, and hooks for PSP integration. **Key features:** * Cart CRUD operations via public HTTP ingresses * Cart rendering with computed prices, taxes, and shipping * Real-time cart updates via Server-Sent Events (SSE) * Custom hook (`OnCartMutation`) for PSP and other app integrations * Cart profiles for multi-currency and multi-country support ### [Products](/official-apps/products/) Unified product, price list, and price management. Contributes Graph nodes and edges for the product/price/price-list model, derived price lists, and a Filtrera module that other apps can import to compute effective prices. **Key features:** * `asset.product`, `asset.product.sku`, `asset.priceList`, `asset.price` Graph nodes with their edges and sets * Manual and derived price lists, with automatic propagation of source-list changes * [`lookupPrices` Filtrera module](/official-apps/products/price-lookup) for computing current/lowest/highest effective prices and price-change history within a window — useful for custom price-lookup ingresses, ERP/PIM exports, and EU Omnibus 30-day-low displays * Activity-log event types for price and price-list lifecycle changes ### [Inventory Routing](/official-apps/inventory-routing/) A family of apps that automatically assign order deliveries to warehouses based on stock availability and country-keyed inventory priority lists. Choose a variant depending on whether your downstream systems expect one warehouse per delivery or can consolidate multiple warehouses into a single delivery. **Key features:** * Channel-keyed, country-aware inventory priority lists * `auto_assign` trigger key that the storefront / cart-to-order flow can set on new deliveries * Best-effort multi-warehouse fill with a single-fulfiller preference for picking efficiency * Automatic back-order handling — unfulfilable units stay on the country's primary warehouse instead of being silently dropped * Stock reservations placed and released automatically across the order lifecycle * Two variants: * **[`inventory-routing.per-delivery`](/official-apps/inventory-routing/per-delivery/)** — one warehouse per delivery; multi-warehouse lines are split into additional deliveries * `inventory-routing.per-orderline` — mixed warehouses within a single delivery *(not yet available)* ### [Payment Providers](/official-apps/psp/) A family of apps that integrate Hantera's payment system — and in particular the [Payment actor](/resources/actors/payment/) — with external Payment Service Providers. Each PSP app translates Hantera payment lifecycle events (authorization, capture, refund, void) into the provider's protocol and handles incoming webhooks. Many PSP apps also ship with a Commerce integration so the cart-to-payment-to-order flow works end-to-end out of the box. **Available PSP apps:** * **[Kustom](/official-apps/psp/kustom/)** — Iframe-based checkout. Integrates the Payment actor with Kustom and ships with a complete Commerce cart integration: idempotent checkout ingress, hash-based cart synchronization driven by `OnCartMutation`, push-webhook fallback, capture flow, and the [`OnKustomValidation`](/official-apps/psp/kustom/validation-hook) custom hook for merchant-supplied checkout validation ### [Shipping Providers](/official-apps/shipping/) A family of apps that fetch shipping options (carrier products, delivery methods, pickup points) from external checkout-options providers and surface them in the portal **Select shipping product** flow. Each shipping app implements the standard [`shippingProductsService`](/resources/apps/portal-extensions) and exposes a Filtrera module other apps can import to fetch options from their own ingresses. **Available shipping apps:** * **[nShift Checkout](/official-apps/shipping/nshift-checkout/)** — Integrates [nShift Checkout](https://www.nshift.com/products/checkout) as a shipping-options provider. Ships with a public [`nshift.module.hrc`](/official-apps/shipping/nshift-checkout/shipping-module) module that owns OAuth and session management, a channel-editor extension for the per-channel `nshiftCheckoutConfigurationId`, and the [`OnNShiftCheckoutVariables`](/official-apps/shipping/nshift-checkout/variables-hook) custom hook for merchant rules to contribute provider-specific request variables. ### [Tax Providers](/official-apps/tax/) A family of apps that calculate transaction tax on orders using an external tax engine and record the resulting documents for filing. Tax apps write the absolute amounts the engine returned onto order lines and deliveries, and share a vendor-neutral `taxChecksum` convention that keeps call volume down. **Available tax apps:** * **[Avalara AvaTax](/official-apps/tax/avatax/)** — US sales tax and Canadian GST/HST/PST/QST via Avalara AvaTax. Calculates tax on carts and orders, commits `SalesInvoice` and `ReturnInvoice` documents from Hantera invoices (with the original sale's tax date on returns), voids on cancellation, and adds portal tooling for tax-code lookup, entity/use codes, and address validation. Per-channel opt-in with its own ship-from origin, and a `disableDocumentRecording` mode for tenants whose ERP owns the filing record. ### [Returns](/official-apps/returns/) Customer claim and warehouse-inspection workflows. Contributes `claim` and `rma` ticket types with their graph nodes, edges, and enum value sets, public HTTP ingresses for filing and listing claims, and the `OnClaimResolve` custom hook that other apps and the merchant's own rules use to apply the actual side effects of a resolved claim. **Key features:** * `POST /returns/claims/create` and `GET /returns/claims/get-by-order` public HTTP ingresses, with automatic order-line matching by remaining claimable quantity * `ticket.claim`, `ticket.claim.line`, `ticket.rma`, `ticket.rma.line` graph nodes and their edges/sets — see [Graph](/official-apps/returns/graph) * [`OnClaimResolve`](/official-apps/returns/hooks) custom hook fired at claim completion (for non-inspected lines) or RMA completion (for inspected lines), with `orderCommand` effects applied to the connected order in a single batch * [Registry-driven resolution types](/official-apps/returns/resolutions) — declare new resolutions and per-resolution custom field editors with no code, and consume their values via the hook * Stand-alone RMAs — RMA tickets can track returns without an originating claim * [Portal extension points](/official-apps/returns/portal) — slots and warning services on the claim and RMA views ### [Conversion Tracking](/official-apps/tracking/) A family of apps that report conversions to advertising and analytics platforms server-side, from the order rather than from the browser. Each app listens for the same order lifecycle events, maps them to its platform's event format, and sends them from a reactor — so conversions survive ad blockers, tracking prevention, and shoppers who close the tab before the thank-you page. They share a vendor-neutral [`field:tracking:* → cart:tracking:*` convention](/official-apps/tracking/) for carrying browser-side identifiers (cookies, click ids) from the storefront onto the order, so adding a platform requires no change to Commerce. See [Conversion Tracking in the Storefront SDK docs](https://storefront.hantera.dev/tracking/) for the capture-and-stamp flow. **Available tracking apps:** * **[Google Analytics 4](/official-apps/tracking/google-analytics/)** — GA4 Measurement Protocol. Sends `purchase` on order confirmation and `refund` as units are returned, attributed via the GA client id captured at the storefront and de-duplicated against the browser tag on the order number. Reports each order to every [GA property](/official-apps/tracking/google-analytics/#properties-and-channel-routing) configured for its channel — a master property plus per-channel properties for multi-site setups — with per-property refund idempotency so retries never double-deduct revenue. * **[Meta Conversions](/official-apps/tracking/meta/)** — Meta Conversions API. Sends a `Purchase` event with normalized, SHA-256 hashed customer data, de-duplicated against the browser Pixel on the order number. * **[Awin](/official-apps/tracking/awin/)** — Awin server-to-server conversion tracking. Reports a sale when an order is confirmed, attributed via the Awin click value, with per-channel and per-country advertiser resolution configured in the Channel editor. --- --- url: /official-apps/commerce.md --- # Commerce App The Commerce app provides cart and promotion management for Hantera. To integrate with e-commerce Storefronts, check out [Hantera Storefront SDK](https://storefront.hantera.dev). It uses [ticket actors](/resources/actors/custom/ticket/) as the underlying data model — each cart is a ticket with type `cart`. ## What It Provides ### HTTP Ingresses Public HTTP endpoints for storefront integration: | Category | Endpoints | |---|---| | **Cart** | Create cart, get cart, SSE events | | **Items** | Add item, remove item, set quantity | | **Details** | Set address, email, phone | | **Fields** | Set/remove custom fields | | **Coupons** | Add/remove coupons | | **Configuration** | List cart profiles | See [API Reference](/official-apps/commerce/api) for full endpoint documentation. ### Cart Rendering Pipeline Every cart mutation returns a **rendered cart** — a normalized representation with computed prices, totals, shipping, and tax. The rendering pipeline: 1. Previews the cart with a `complete` message (triggers order creation rules) 2. Queries the resulting order for totals and line items 3. Maps the data into a flat, storefront-friendly structure ### Promotion Engine The Commerce app includes a promotion engine that applies promotions to orders during order creation. Promotions are user-managed rules in the portal — each promotion selects a type, configures its parameters, and optionally requires a coupon code. All promotion logic is provided by apps through [promotion types](/official-apps/commerce/promotion-types). ### Custom Hooks The Commerce app fires the [`OnCartMutation`](/official-apps/commerce/hooks) custom hook after every successful render, enabling other apps to react to cart state changes. ### Cart Profiles Cart profiles configure currency, default country, and allowed countries per storefront channel. Configured via the Commerce app settings in the portal. ## Architecture ```mermaid graph TB Storefront[Storefront Client] subgraph "Commerce App" Ingress[HTTP Ingresses] Render[renderCart Pipeline] Hooks[OnCartMutation Hook] end subgraph "Hantera Platform" Ticket[Cart / Ticket Actor] Order[Order Actor] Rules[Rules Engine] end PSP[PSP App] Storefront -->|HTTP| Ingress Ingress -->|messageActor| Ticket Ingress --> Render Render -->|preview + query| Ticket Render -->|triggerHook| Hooks Hooks -->|rule effects| Rules Rules -->|effects| PSP Ticket -->|complete| Order ``` ## Getting Started 1. Install the Commerce app on your Hantera instance 2. Configure a cart profile (currency, country settings) 3. Use the [API](/official-apps/commerce/api) or [Storefront SDK](https://storefront.hantera.dev) from your storefront --- --- url: /official-apps/commerce/cart-lifecycle.md --- # Cart Lifecycle A Commerce cart progresses through distinct stages: creation, mutation, rendering, and completion. ## States | State | Description | |---|---| | `open` | Active cart, can be modified | | `completed` | Cart has been completed (order created) | | `rejected` | Not actively used. Carts are generally retained for a limited time before automatically removed | ## Creating a Cart Carts are created via the [Create Cart](/official-apps/commerce/api#commerce-createCart) endpoint with a **profile key** and **locale**: ```bash POST /ingress/commerce/carts Content-Type: application/json { "profileKey": "default", "locale": "sv_SE" } ``` The profile key determines the cart's currency, default country, and allowed shipping countries. The locale must be configured in the system registry. The response contains the new `cartId` — all subsequent operations use this ID. ## Mutating a Cart Cart mutations are performed via the Commerce ingresses. Most mutations return the [rendered cart](#rendering). ### Adding and Managing Items ```bash # Add an item POST /ingress/commerce/carts/{cartId}/add-item { "productNumber": "TSHIRT-001", "quantity": 2 } # Change quantity POST /ingress/commerce/carts/{cartId}/set-quantity { "cartItemId": "...", "quantity": 3 } # Remove an item POST /ingress/commerce/carts/{cartId}/remove-item { "cartItemId": "..." } ``` ### Setting Customer Details ```bash # Set email POST /ingress/commerce/carts/{cartId}/email { "email": "customer@example.com" } # Set shipping address POST /ingress/commerce/carts/{cartId}/address { "address": { "countryCode": "SE", "city": "Stockholm", ... } } ``` ### Custom Fields Custom fields allow PSP apps and other integrations to store metadata on the cart: ```bash # Set a custom field POST /ingress/commerce/carts/{cartId}/set-field/paymentProvider { "value": "stripe" } # Remove a custom field POST /ingress/commerce/carts/{cartId}/remove-field/paymentProvider ``` Custom fields appear in the rendered cart under the `fields` property, and are projected onto the created order as `cart:`. Apps store their own state on the cart the same way, using key prefixes to control what reaches the order — see [Cart Dynamic Fields](/official-apps/commerce/dynamic-fields). ## Rendering Every mutation endpoint returns the **rendered cart** — a normalized structure with computed prices, totals, and tax. The rendering pipeline: 1. Sends a `complete` message to the cart actor in **preview mode** (no persistent changes) 2. This triggers all order creation rules (including price lookup, discounts, shipping) 3. Queries the preview result for order totals and line items 4. Maps the data into the rendered cart structure The rendered cart includes: * `orderTotal`, `orderTaxTotal`, `shippingTotal`, `productTotal` — computed totals * `items[]` — each item with `unitPrice`, `total`, `tax`, `discount` * `fields` — custom fields set via the `set-field` endpoint * `cartState` — current state (`open`, `completed`, `rejected`) After rendering, the `OnCartMutation` hook fires, enabling other apps to react. See [Hooks](/official-apps/commerce/hooks). ## Real-Time Updates Subscribe to the [Cart Events](/official-apps/commerce/api#commerce-cartEvents) SSE endpoint to receive the rendered cart in real-time whenever the cart state changes: ```javascript const eventSource = new EventSource( `${baseUrl}/ingress/commerce/carts/${cartId}/events` ) eventSource.onmessage = (event) => { const cart = JSON.parse(event.data) updateUI(cart) } ``` This is essential for checkout flows where the cart may be modified by background processes (e.g., PSP synchronization). ## Completing a Cart Cart completion is typically triggered by a **PSP app** after payment authorization. The PSP app: 1. Creates a [Payment actor](/resources/actors/payment/) with an authorization 2. Links the payment to the cart via a `createRelation` command 3. Sends a `complete` message to the cart ticket actor When a cart is completed: * The `cart-to-order` rule fires, creating a real order from the cart's preview, and projecting the cart's prefixed [dynamic fields](/official-apps/commerce/dynamic-fields) onto the order and its delivery * The cart state changes to `completed` * The `orderId` and `orderNumber` become available in the rendered cart * SSE subscribers receive the final cart state See [PSP Integration](/official-apps/commerce/psp-integration) for the recommended completion pattern. ## Cart Profiles Cart profiles are configured in the Commerce app registry under `apps/commerce/profiles/{key}`. Each profile defines: | Setting | Description | |---|---| | `currencyCode` | Currency for the cart (e.g., `SEK`, `EUR`) | | `defaultCountryCode` | Default shipping country | | `allowedCountries` | List of allowed shipping country codes | | `channelKey` | Sales channel key for the order | Retrieve available profiles via the [Get Cart Profiles](/official-apps/commerce/api#commerce-getCartProfiles) endpoint. --- --- url: /official-apps/commerce/dynamic-fields.md --- # Cart Dynamic Fields Each Commerce cart is a [ticket actor](/resources/actors/custom/ticket/), and any app can store state on it in the ticket's `dynamic` map using the [`setDynamicFields`](/resources/actors/custom/ticket/commands/set-dynamic-fields) command. This is how PSP, shipping, tax and tracking apps carry their own data alongside the cart. Most of that state is private to the app that wrote it. But some of it needs to survive cart completion and end up on the created order — an external payment reference, a shipping selection, a consent flag. Commerce projects those fields onto the order using **key prefixes**. ## Key prefixes When a cart completes, the cart's dynamic fields are projected to the order: | Cart dynamic key | Lands on | As | Notes | |---|---|---|---| | `order:` | Order | `` | Verbatim — vendor-prefix your key names | | `delivery:` | The order's delivery | `` | | | `field:` | Order | `cart:` | Storefront-facing; also returned in the rendered cart's `fields` | | anything else | — | not projected | App-private cart state | Everything without a recognised prefix stays on the cart and is never copied to the order. That is the default, and it's the right place for working state such as sync hashes, session pointers or cached external payloads. ### `order:` and `delivery:` These are for apps. Keys land on the target **verbatim**, with the prefix stripped: ``` cart.dynamic: order:pspOrderId = 'ord_1a2b3c' delivery:shippingOptionId = '4f2c...' → order.dynamic: pspOrderId = 'ord_1a2b3c' → order.deliveries[0].dynamic: shippingOptionId = '4f2c...' ``` Because keys are not namespaced on arrival, the order's dynamic map is a **shared space across every installed app**. `setDynamicFields` merges per top-level key, so apps writing different keys never interfere — even concurrently. Prefix your key names with your app or vendor so two apps never fight over a generic name like `reference` or `status`. ### `field:` `field:` is the storefront-facing namespace, written through the [set-field](/official-apps/commerce/cart-lifecycle#custom-fields) ingress: ```bash POST /ingress/commerce/carts/{cartId}/set-field/giftMessage { "value": "Happy birthday!" } ``` These are returned to the storefront in the rendered cart's `fields` property, and projected onto the order under the `cart:` namespace (`cart:giftMessage`). The extra namespace is deliberate: this data originates from the client, so it is kept clearly separated from app-authored order fields. ## Writing prefixed fields Prefixed keys contain a `:`, which isn't valid in a bare identifier. Wrap the name in **backticks** and it behaves like any other record field: ```filtrera from [{ type = 'setDynamicFields' fields = { `delivery:nShiftCheckoutOptionId` = optionId `delivery:nShiftCheckoutPickupPointId` = pickupPointId } }] ``` Backticked and plain field names mix freely in the same record literal, so a prefixed field never forces you to restructure the rest: ```filtrera from [{ type = 'setDynamicFields' fields = { email = email address = shippingAddress `order:marketingConsent` = marketingConsent } }] ``` ::: tip Backticks aren't specific to dynamic fields Backtick-quoting works anywhere Filtrera expects a symbol — record fields in literals and in type declarations, and member access on a record. Reach for it whenever a name isn't a plain identifier. ::: Map-literal syntax (`'key' -> value`) is equally valid, and is the natural choice when the key is computed rather than written out: ```filtrera from [{ type = 'setDynamicFields' fields = { ('delivery:' + fieldName) -> fieldValue } }] ``` Write the fields **before** sending the `complete` message — `cart-to-order` reads the cart's state at completion time. See [PSP Integration](/official-apps/commerce/psp-integration#completing-the-cart). ## Reading fields back Off the cart, while it is still open: ```filtrera let optionId = input.ticket.dynamic->'delivery:nShiftCheckoutOptionId' match (id: text) |> id |> nothing ``` Off the order or delivery, after completion — the prefix is gone: ```filtrera param input: OnOrderValidate from input.order.deliveries select d => d.dynamic->'nShiftCheckoutOptionId' ``` ::: tip UUIDs come back as text Dynamic values are stored as JSON, which has no UUID type. A `uuid` written to a dynamic field is always read back as `text`, so match it as text and convert: ```filtrera let sessionId = d.dynamic->'nShiftCheckoutSessionActorId' match (id: text) |> id::uuid |> nothing ``` ::: To surface a field in the portal or the query graph, register it as a graph field in your app manifest with a `dynamic->` source: ```yaml registryEntries: - path: graph/ticket/cart/kustomOrderId value: source: "dynamic->'field:kustomOrderId'" ``` ## See Also * [Cart Lifecycle](/official-apps/commerce/cart-lifecycle) — Cart states, mutation and completion * [PSP Integration](/official-apps/commerce/psp-integration) — Completing a cart from a payment app * [Ticket `setDynamicFields`](/resources/actors/custom/ticket/commands/set-dynamic-fields) — Command reference * [Order `setOrderDynamicFields`](/resources/actors/order/commands/set-order-dynamic-fields) — Setting fields directly on an order * [Order `setDeliveryDynamicFields`](/resources/actors/order/commands/set-delivery-dynamic-fields) — Setting fields directly on a delivery --- --- url: /official-apps/commerce/psp-integration.md --- # PSP Integration This guide describes the recommended pattern for building a Payment Service Provider (PSP) app that integrates with the Commerce app. ## Overview PSP apps handle payment processing for Commerce carts. The core responsibility is: 1. Accept a payment from the customer (via your PSP's API) 2. Create a Hantera [Payment actor](/resources/actors/payment/) with an authorization 3. Link the payment to the cart and complete it Each PSP has its own flow (redirect, iframe, client-side SDK, etc.), but the cart completion step is the same. ## The Payment Flow ```mermaid sequenceDiagram participant Storefront participant PSP App participant PSP API as External PSP API participant Payment as Payment Actor participant Cart as Cart / Ticket Actor Storefront->>PSP App: Initiate checkout PSP App->>PSP API: Create payment session PSP API-->>PSP App: Session details PSP App-->>Storefront: Redirect / iframe / SDK Storefront->>PSP API: Customer completes payment PSP API->>PSP App: Authorization callback PSP App->>Payment: Create payment + authorization PSP App->>Cart: Link payment + complete Cart-->>Storefront: Cart completed (via SSE) ``` ## Exposing a Checkout Ingress PSP apps typically expose a checkout ingress at: ``` POST /ingress/commerce/carts/{cartId}/payment/{provider} ``` This ingress receives the cart ID and returns whatever the storefront needs to initiate the payment flow (redirect URL, client token, iframe snippet, etc.). ## Completing the Cart When your PSP confirms the payment is authorized, complete the cart with these steps: ### Step 1: Create a Payment Actor ```filtrera let paymentResult = messageActor ( 'payment' 'new' [{ type = 'create' body = { providerKey = 'your-provider' currencyCode = 'SEK' externalReference = externalPaymentId commands = [{ type = 'createAuthorization' authorizationNumber = externalPaymentId amount = orderAmount authorizationState = 'successful' },{ type = 'generatePaymentNumberByPrefix' prefix = 'YOUR-PREFIX' }] } }] ) ``` Key fields: * `providerKey` — identifies your PSP (e.g., `'stripe'`, `'kustom'`) * `externalReference` — the ID from your PSP's API * `amount` — the authorized amount in the cart's currency ### Step 2: Link Payment and Complete Cart ```filtrera let completeResult = messageActor ( 'ticket' cartId [{ type = 'applyCommands' body = { commands = [{ type = 'createRelation' relationKey = 'payments' nodeId = paymentResult.actorId }] } },{ type = 'complete' }] ) ``` The `createRelation` links the payment to the cart. The `complete` message transitions the cart to `completed` state and triggers the `cart-to-order` rule, which creates the actual order. ::: warning Important Always create the payment and link it **before** completing the cart. The order creation rules may need the payment data. ::: The same applies to any data you want on the created order: write it to the cart as an `order:` or `delivery:` dynamic field *before* sending `complete`, and the cart will project it. See [Cart Dynamic Fields](/official-apps/commerce/dynamic-fields). ## Reacting to Cart Changes If your PSP needs to stay synchronized with the cart (e.g., updating order amounts in an external checkout), you can listen to the [`OnCartMutation`](/official-apps/commerce/hooks) custom hook. This hook fires after every successful cart render and provides the full rendered cart data. See [Hooks](/official-apps/commerce/hooks) for details on how to listen and react to cart renders. ## Handling Captures For PSPs that require explicit capture (as opposed to direct charges), implement a rule that listens for `OnPaymentCapture`: ```filtrera param input: OnPaymentCapture from input.payment.providerKey match 'your-provider' |> from { effect = 'scheduleJob' definition = 'apps/your-app/capturePayment' at = now parameters = { externalReference = input.payment.externalReference amount = input.capture.amount } } ``` The capture job then calls your PSP's capture API. ## Idempotency PSP integrations should be idempotent: * **Payment creation**: Check for existing payments by `externalReference` before creating new ones * **Cart completion**: If the cart is already completed, the `complete` message is a no-op * **Webhooks**: PSPs may send the same webhook multiple times ## See Also * [Payment Actor](/resources/actors/payment/) — Payment actor reference * [Cart Dynamic Fields](/official-apps/commerce/dynamic-fields) — Carrying data from the cart onto the order * [Custom Hooks](/resources/rules/trigger-hook) — How triggerHook works --- --- url: /official-apps/commerce/promotion-types.md --- # Promotion Types The Commerce promotion engine has no built-in promotion logic. All promotion types are provided by apps. A promotion type consists of two parts: * A **Filtrera promotion script** (`.hpr`) that computes discounts against an order * A **Vue settings component** that renders the type's configuration UI in the portal ## The Promotion Script Promotion scripts use the `.hpr` extension and are referenced by a `componentId`. The runtime provides the script with an `order` value containing the full order state, and calls the script once per promotion application. ### Input The `order` value is available globally in the script (no `param` declaration needed): ```filtrera order.orderId // uuid order.currencyCode // text order.channelKey // text order.orderTotal // number order.deliveries // [Delivery] .shippingPrice // number .orderLines // [OrderLine] .orderLineId // uuid .productNumber // text .quantity // number .unitPrice // number ``` ### Parameters Declare `param` statements to receive configuration from the promotion's settings: ```filtrera param threshold: number = 0 param discountIsPercentage: bool = true ``` Parameter values are set by the portal UI via the settings component and stored on the promotion rule. The runtime injects them when the script executes. ### Discount Effects Scripts produce discount effects using two built-in functions: | Function | Description | |---|---| | `percentage(target, percent)` | Apply a percentage discount to matching entities | | `absolute(target, amount)` | Apply a fixed-amount discount to matching entities | Both functions take a `target(predicate)` expression that selects which order entities receive the discount: ```filtrera // Discount all order lines by 10% percentage(target(e => e is OrderLine), 10%) // Discount a specific delivery by 100% percentage(target(d => d is Delivery and d.deliveryId == someId), 100%) // Fixed amount off all order lines absolute(target(e => e is OrderLine), 50) ``` A script can produce multiple discount effects — one per `from` statement. ### Messages Scripts can return a user-facing message instead of (or alongside) a discount: ```filtrera from { type = 'message' message = $'Add {threshold - order.orderTotal} more to qualify!' } ``` Messages are surfaced in the rendered cart and can be used to communicate promotion thresholds to the customer. ## The Settings Component The settings component is a standard Vue component that receives the promotion context as props: ```typescript interface PromotionTypeSlotContext { promotionKey: string promotionType: string parameters: Record setParameter: (key: string, value: any) => void removeParameter: (key: string) => void isNew: boolean } ``` Use `setParameter` and `removeParameter` to manage the type-specific parameters that will be passed to the `.hpr` script at runtime: ```vue ``` Parameter names must match the `param` declarations in the `.hpr` script exactly. ## Registering the Type ### 1. Declare the component in `h_app.yaml` ```yaml components: - id: promotions/my-promotion-type.hpr ``` The `id` determines the `componentId` you'll reference during registration. ### 2. Register in `portal/index.ts` The Commerce app exposes its promotion-type extension point as a service with the ID `apps/commerce/promotionTypeProvider`. Declare it locally with its contract and register your provider via `portal.registerService`: ```typescript import { apps, portal } from '@hantera/portal-app' import MyPromotionSettings from './components/MyPromotionSettings.vue' interface PromotionTypeProvider { types: Array<{ key: string label: string componentId: string component: any // Vue component }> } const promotionTypeService = apps.defineService( 'apps/commerce/promotionTypeProvider' ) portal.registerService(promotionTypeService, () => ({ types: [ { key: 'my-promotion-type', label: 'My Promotion Type', componentId: 'apps/my-app/promotions/my-promotion-type.hpr', component: MyPromotionSettings, }, ], })) ``` The `key` is a stable identifier used to associate portal-created promotions with this type. The `componentId` must match the fully-qualified ID of the `.hpr` component as it appears in your app. Once registered, the type appears in the promotion type selector when creating a new promotion in the portal. --- --- url: /official-apps/commerce/hooks.md --- # Commerce Hooks The Commerce app provides custom hooks that other apps can listen to. ## OnCartMutation Fires after every successful cart mutation. The rendered cart record is passed directly as the hook input — listening rules receive the cart fields at root level alongside `hook: 'OnCartMutation'`. ### When It Fires * After every successful cart mutation ingress: add-item, remove-item, set-quantity, set-address, set-email, set-phone, set-field, remove-field * Only fires when the mutation succeeds — if the mutation returns an error (e.g., validation failure, item not found), the hook does **not** fire * Does **not** fire on read-only operations: get-cart, cart SSE events * Does **not** fire on cart creation (create-cart) — the cart has no items yet ### Hook Input The input is the full rendered cart object with `hook` added by the runtime: ```filtrera { hook: 'OnCartMutation' cartId: uuid cartNumber: text cartState: 'open' | 'completed' | 'rejected' channelKey: text currencyCode: text taxIncluded: bool orderTotal: number orderTaxTotal: number shippingTotal: number productTotal: number items: [{ cartItemId: uuid productNumber: text quantity: number unitPrice: number total: number tax: number discount: number }] fields: { text -> value } email: text | nothing phone: text | nothing address: value | nothing invoiceAddress: value | nothing locale: text | nothing profileKey: text | nothing } ``` ### Listening Rule Example Rules listen by declaring `param input` with `hook: 'OnCartMutation'` and the fields they need: ```filtrera param input: { hook: 'OnCartMutation' cartId: uuid currencyCode: text orderTotal: number orderTaxTotal: number items: [{ productNumber: text quantity: number unitPrice: number total: number tax: number }] fields: { text -> value } } import 'iterators' // Only process carts that have a PSP integration let hasPaymentProvider = input.fields->'paymentProvider' is text from hasPaymentProvider match true |> // Build update payload and schedule sync from { effect = 'ticketCommand' type = 'setDynamicFields' fields = { `field:lastSyncedTotal` = input.orderTotal } } from { effect = 'scheduleJob' definition = 'apps/my-psp/syncOrder' at = now parameters = { cartId = input.cartId, total = input.orderTotal } } ``` ::: tip You only need to declare the fields your rule uses. Filtrera's type system automatically matches rules to hooks based on their declared input shape. ::: ### Supported Effects The Commerce hook dispatcher processes these effect types: | Effect | How it's applied | |---|---| | `ticketCommand` | Batched into a single `applyCommands` call on the cart | | `scheduleJob` | Scheduled via `scheduleJob` runtime function | Other effects are silently ignored. ### No Feedback Loop The `OnCartMutation` hook fires from the Commerce reactor runtime (ingresses), not from `OnTicketCommands`. When hook effects apply commands to the cart (e.g., setting metadata fields), those commands trigger `OnTicketCommands` on the ticket actor — but no Commerce hook fires again. This breaks the feedback loop that would occur if you used `OnTicketCommands` directly. ### Evaluation Semantics The hook is evaluated synchronously within each mutation ingress, after the cart has been rendered but before the response is returned. This means: * `ticketCommand` effects (e.g., setting sync hashes) are applied before the response * `scheduleJob` effects are scheduled before the response * The rendered cart returned to the storefront does **not** include changes from hook effects — it reflects the state at the time of rendering, before hook effects are applied ## See Also * [triggerHook Reference](/resources/rules/trigger-hook) — How custom hooks work * [Rule Effects](/resources/rules/effects) — Available effect types * [PSP Integration](/official-apps/commerce/psp-integration) — Using hooks for PSP synchronization --- --- url: /official-apps/commerce/api.md --- # Commerce API Reference The Commerce app exposes public HTTP ingresses for cart management. All endpoints are prefixed with `/ingress/commerce/`. ::: tip All cart mutation endpoints return the rendered cart on success. Error responses return `{ error: { code, message } }`. ::: --- --- url: /official-apps/products.md --- # Products App The Products app provides unified product and price management for Hantera. It contributes the `product`, `priceList`, and `price` asset types, the Graph nodes and edges that connect them, derived price lists, validation rules, and a Filtrera module for looking up effective prices over a time window. This page describes the **public interface** that the Products app exposes to other apps. If your app needs to query product data, navigate to prices, or compute effective prices, declare a dependency on the contract documented here using the standard [`requires` mechanism](/resources/apps/declaring-dependencies). ## What it provides | Surface | Description | |---|---| | [Graph nodes & edges](/official-apps/products/graph) | `asset.product`, `asset.product.sku`, `asset.priceList`, `asset.price`, the `products` / `priceLists` / `prices` sets, and the edges that connect them. | | [Price Lookup module](/official-apps/products/price-lookup) | A Filtrera module exporting `lookupPrices`, used to compute current/lowest/highest effective prices and a price-change history within a window. | | Activity log event types | `priceChange`, `priceDeactivation`, `priceReactivation`, `priceListActivation`, `priceListDeactivation` are emitted on the price and price-list asset activity logs. | | Portal extension | Adds product, price list, and price management views to the Hantera portal. (Not part of the developer-facing contract.) | ## Depending on the Products app Apps that read product or price data, navigate price-list edges, or call into `lookupPrices` should declare the relevant subset in `requires` so their components compile in isolation and surface clear errors when the Products app is missing. A typical reactor that needs prices for an order would declare: ```yaml requires: graph: nodes: asset.product: fields: name: type: text dimension: locale vatClass: type: enum edges: prices: cardinality: many relatedNode: asset.price asset.price: fields: currentPrice: type: number priceListKey: type: text deactivatedAt: type: instant edges: priceList: cardinality: single relatedNode: asset.priceList asset.priceList: fields: active: type: boolean sets: products: { node: asset.product } modules: /apps/products/price-lookup.module.hrc: exports: lookupPrices: type: '(params: { productNumbers: [text], currencyCode: text, priceListKeys: [text], window: duration }) => { text -> { currentPrice: number | nothing, history: [{ at: instant, price: number | nothing }], lowestPrice: number | nothing, highestPrice: number | nothing } }' ``` For the full surface and copy-pasteable per-node snippets, see [Graph](/official-apps/products/graph) and [Price Lookup module](/official-apps/products/price-lookup). ## Getting started This documentation assumes the Products app is already installed on your Hantera instance. With it installed, you can: 1. Manage products, price lists, and prices through the Products portal extension, or via standard Hantera asset commands. 2. Build apps that consume the contract documented here — pricing rules, custom price-lookup ingresses, ERP/PIM exports, EU Omnibus 30-day-low displays, and so on. ## Related * [Graph reference](/official-apps/products/graph) * [Price Lookup module reference](/official-apps/products/price-lookup) * [Declaring App Dependencies](/resources/apps/declaring-dependencies) * [Custom Asset Actors](/resources/actors/custom/asset/) --- --- url: /official-apps/products/graph.md --- # Products — Graph Reference Public Graph nodes, fields, edges, and sets contributed by the Products app. Use these as the basis for your `requires.graph` declarations — see [Declaring App Dependencies](/resources/apps/declaring-dependencies). ## Sets | Set | Node | |---|---| | `products` | `asset.product` | | `priceLists` | `asset.priceList` | | `prices` | `asset.price` | ```yaml requires: graph: sets: products: { node: asset.product } priceLists: { node: asset.priceList } prices: { node: asset.price } ``` ## `asset.product` Default asset number prefix: `PROD`. Each product can have multiple SKUs (subitems) and multiple prices across price lists and currencies. ### Fields | Field | Type | Notes | |---|---|---| | `name` | `text` | Localizable. Has dimension `locale`. | | `vatClass` | `enum` | VAT class for the product. | ### Edges | Edge | Cardinality | Related node | |---|---|---| | `skus` | many | `asset.product.sku` | | `prices` | many | `asset.price` | ```yaml asset.product: fields: name: type: text dimension: locale vatClass: type: enum edges: skus: cardinality: many relatedNode: asset.product.sku prices: cardinality: many relatedNode: asset.price ``` ## `asset.product.sku` A subitem of `asset.product`, reachable via the `skus` edge. ### Fields | Field | Type | Notes | |---|---|---| | `skuNumber` | `text` | The SKU identifier. | | `quantity` | `number` | Quantity per product unit. | ```yaml asset.product.sku: fields: skuNumber: type: text quantity: type: number ``` ## `asset.priceList` Default asset number prefix: `PL`. Price lists scope prices to a set of channels and can either be authored manually or derived from another price list. ### Fields | Field | Type | Notes | |---|---|---| | `active` | `boolean` | Whether prices on this list are currently effective. | | `channelKeys` | `[enum]` | Enum definition: `channels`. | | `sourceType` | `enum` | One of `manual`, `derived`. | | `sourcePriceListKey` | `text` | For derived price lists: the source list's `assetNumber`. | ### Edges | Edge | Cardinality | Related node | |---|---|---| | `prices` | many | `asset.price` | | `sourcePriceList` | single | `asset.priceList` | | `derivedPriceLists` | many | `asset.priceList` | ```yaml asset.priceList: fields: active: type: boolean channelKeys: type: '[enum]' enumDefinition: channels sourceType: type: enum sourcePriceListKey: type: text edges: prices: cardinality: many relatedNode: asset.price sourcePriceList: cardinality: single relatedNode: asset.priceList derivedPriceLists: cardinality: many relatedNode: asset.priceList ``` ## `asset.price` Default asset number prefix: `PR`. Each `price` belongs to one product and one price list, scoped to a single currency. ### Fields | Field | Type | Notes | |---|---|---| | `currencyCode` | `text` | ISO 4217 currency code. | | `currentPrice` | `number` | Current effective unit price. | | `priceListKey` | `text` | Foreign key to `asset.priceList.assetNumber`. | | `productNumber` | `text` | Foreign key to `asset.product.assetNumber`. | | `deactivatedAt` | `instant` | Set when the individual price is deactivated. | | `sourceSetId` | `uuid` | For derived prices: ID of the source mutation set. | | `sourcePriceKey` | `text` | For derived prices: the source price's `assetNumber`. | ### Edges | Edge | Cardinality | Related node | |---|---|---| | `priceList` | single | `asset.priceList` | | `product` | single | `asset.product` | | `sourcePrice` | single | `asset.price` | ```yaml asset.price: fields: currencyCode: type: text currentPrice: type: number priceListKey: type: text productNumber: type: text deactivatedAt: type: instant sourceSetId: type: text sourcePriceKey: type: text edges: priceList: cardinality: single relatedNode: asset.priceList product: cardinality: single relatedNode: asset.product sourcePrice: cardinality: single relatedNode: asset.price ``` ::: info `uuid` in `requires.graph` The supported `requires.graph` field types are `text`, `enum`, `number`, `instant`, `[text]`, `[enum]`. Declare `sourceSetId` as `text` in your `requires` block — UUIDs are stored as text on the graph and will compare correctly. ::: ## Activity log event types The Products app emits these `eventType` values on the `activityLog` for `asset.price` and `asset.priceList` mutations: | Event type | Emitted on | |---|---| | `priceChange` | `asset.price` when the current price value changes | | `priceDeactivation` | `asset.price` when `deactivatedAt` is set | | `priceReactivation` | `asset.price` when `deactivatedAt` is cleared | | `priceListActivation` | `asset.priceList` when `active` flips to `true` | | `priceListDeactivation` | `asset.priceList` when `active` flips to `false` | The [Price Lookup module](/official-apps/products/price-lookup) consumes these to compute price-change history within a window. If your own components query the activity log directly, expect these `eventType` values. ## Related * [Price Lookup module](/official-apps/products/price-lookup) * [Declaring App Dependencies](/resources/apps/declaring-dependencies) * [Custom Fields & Edges](/resources/graph/custom-fields) --- --- url: /official-apps/products/price-lookup.md --- # Products — Price Lookup Module The Products app exports a Filtrera module at `/apps/products/price-lookup.module.hrc` that computes the **effective price** of one or more products across a set of price lists, plus a price-change history within a configurable time window. It is the canonical building block for: * **Custom price-lookup ingresses** — exposing pricing data to a storefront, marketing system, or BI tool with a request and response shape that fits your project. * **Bulk price exports** — computing prices in batches for export to ERP, PIM, or marketing platforms. * **EU Omnibus 30-day-low displays** — `lowestPrice` over a 30-day `window` is the canonical input for "lowest price in the last 30 days" labels. ## Module path ``` /apps/products/price-lookup.module.hrc ``` ## Exports ### `lookupPrices` Computes effective prices and price history for a list of products against a list of price lists, scoped to one currency and one time window. **Filtrera type signature** ``` (params: { productNumbers: [text] currencyCode: text priceListKeys: [text] window: duration }) => { text -> { currentPrice: number | nothing history: [{ at: instant, price: number | nothing }] lowestPrice: number | nothing highestPrice: number | nothing } } ``` **Inputs** | Field | Type | Description | |---|---|---| | `productNumbers` | `[text]` | Asset numbers of the products to look up. | | `currencyCode` | `text` | ISO 4217 currency code. Only prices in this currency are considered. | | `priceListKeys` | `[text]` | Price lists to consider. Only prices on these lists are considered. | | `window` | `duration` | How far back to compute history, lowest, and highest. For EU Omnibus, use `30 days`. | **Output** A map keyed by `productNumber` (`asset.product.assetNumber`) where each entry has: | Field | Type | Description | |---|---|---| | `currentPrice` | `number \| nothing` | The current best price across active price lists, ignoring deactivated prices. `nothing` if no active price applies. | | `history` | `[{ at: instant, price: number \| nothing }]` | Chronologically-ordered entries — one per change of the effective best price within `window`. `price` is `nothing` when the effective price was not defined (no active list, or all lists deactivated) at that moment. | | `lowestPrice` | `number \| nothing` | Lowest effective price observed within `window`. | | `highestPrice` | `number \| nothing` | Highest effective price observed within `window`. | **Window semantics** * The history is computed by replaying activity-log events (`priceChange`, `priceDeactivation`, `priceReactivation`, `priceListActivation`, `priceListDeactivation` — see [Activity log event types](/official-apps/products/graph#activity-log-event-types)) from the start of the window forward. * An entry is appended to `history` whenever the *effective best price* for the product changes — not on every individual `priceChange` event. Activations and deactivations of price lists or individual prices contribute to this only when they affect the effective price. * Products that exist but have no matching active prices appear in the result with `currentPrice = nothing` and an empty or deactivation-only `history`. * Products that don't exist do not appear in the result map. ## Declaring the dependency Add this to your app's `h_app.yaml`: ```yaml requires: modules: /apps/products/price-lookup.module.hrc: exports: lookupPrices: type: '(params: { productNumbers: [text], currencyCode: text, priceListKeys: [text], window: duration }) => { text -> { currentPrice: number | nothing, history: [{ at: instant, price: number | nothing }], lowestPrice: number | nothing, highestPrice: number | nothing } }' ``` See [Declaring App Dependencies](/resources/apps/declaring-dependencies) for the surrounding context. ## Use cases ### Custom price-lookup ingress Expose a project-specific HTTP ingress backed by `lookupPrices`. The contract — request body, response shape, auth, channel filtering, tax handling — is intentionally up to the implementer because storefront pricing rules vary widely. ```filtrera import './price-lookup.module.hrc' as priceLookup param request: { productNumbers: [text] currencyCode: text priceListKeys: [text] } let result = priceLookup.lookupPrices { productNumbers = request.productNumbers currencyCode = request.currencyCode priceListKeys = request.priceListKeys window = 30 days } from { currencyCode = request.currencyCode prices = result entries select e => { productNumber = e.key currentPrice = e.value.currentPrice lowestIn30Days = e.value.lowestPrice timeline = e.value.history } } ``` Register the component as a public HTTP ingress in your `h_app.yaml` and your storefront can call it directly. ### Bulk price export Call `lookupPrices` from a job that emits an export feed for ERP, PIM, or marketing platforms. Batch the input list to keep memory usage bounded and to fit any downstream API limits. ```filtrera import './price-lookup.module.hrc' as priceLookup param batch: { productNumbers: [text] priceListKeys: [text] currencyCode: text } let prices = priceLookup.lookupPrices { productNumbers = batch.productNumbers currencyCode = batch.currencyCode priceListKeys = batch.priceListKeys window = 30 days } from prices entries select e => { productNumber = e.key price = e.value.currentPrice lowestPrice = e.value.lowestPrice } ``` ### EU Omnibus 30-day-low For storefront price displays that need to show the lowest price in the last 30 days (per the EU Omnibus Directive), pass `window = 30 days` and surface `lowestPrice` alongside `currentPrice`. The `history` field is available if you want to render a price-over-time chart or audit trail. ## Related * [Products — Graph reference](/official-apps/products/graph) * [Declaring App Dependencies](/resources/apps/declaring-dependencies) * [Filtrera Runtime Reference](/resources/components/runtimes/) * [HTTP Ingresses](/resources/ingresses/http/) * [Job Definitions](/resources/job-definitions) --- --- url: /official-apps/inventory-routing.md --- # Inventory Routing App The Inventory Routing app family automatically assigns order deliveries to warehouses based on **stock availability**, **country routing rules**, and **inventory date**. It turns "we have stock in three warehouses but orders always go to the wrong one" into a configuration problem instead of a coding problem. The family ships in two variants. They share configuration and stock-reservation behaviour but differ in **where the `inventoryKey` lives** — that single design choice determines whether a single delivery can mix warehouses or whether each delivery is tied to one warehouse. ## Variants | App ID | `inventoryKey` is set on | Resulting delivery shape | Status | | ----------------------------------- | ------------------------ | --------------------------------------------------------------------------------------------------------- | ------------------- | | **`inventory-routing.per-delivery`** | Each delivery | One warehouse per delivery. Multi-warehouse lines are **split into additional deliveries**, one per inventory. | Available | | `inventory-routing.per-orderline` | Each order line | One delivery can list lines from different warehouses; the source warehouse is recorded per line. | Not yet available | Both variants are interchangeable from a caller's point of view — the trigger is the same (`inventoryKey: "auto_assign"` on the new resource) and the same channel-country configuration drives the warehouse priorities. ## Picking a variant The constraint is rarely Hantera's — it's almost always set by the **surrounding software landscape**. Both the downstream side (WMS, picking station, couriers) and the upstream side (ERP invoicing, finance) can dictate whether a single fulfilment is allowed to mix warehouses. ::: tip Rule of thumb **Can the systems that consume your fulfilment data — your WMS, your ERP's invoicing, your label and courier flows — handle a single fulfilment that lists items from multiple warehouses?** * **No, they need exactly one fulfilment per warehouse** → `per-delivery` * **Yes, they can mix** → `per-orderline` If you're not sure, pick `per-delivery`. It's the safer default and works for any downstream system because each delivery is internally consistent. ::: ### Typical separations | Situation | Typical variant | | ---------------------------------------------------------------------------------------------------------------------------------- | ----------------- | | Legacy ERP still owns invoicing — each invoice is per fulfilment, so each invoice must come from one warehouse | `per-delivery` | | A WMS or 3PL portal consumes Hantera deliveries one at a time and expects every line to be picked at the same location | `per-delivery` | | Couriers / shipping integrations assign one label per delivery, and warehouses ship independently | `per-delivery` | | A modern WMS or 3PL with a per-line picking-order model that can mix source warehouses inside one outbound shipment | `per-orderline` | | You want one fulfilment as the source of truth for "what's been shipped" and you track source warehouse per item for reporting | `per-orderline` | In this model, when an order has 10 items where 7 are at warehouse A and 3 are at warehouse B: * Under `per-delivery`, you end up with **two deliveries**, picked and shipped independently. * Under `per-orderline`, you end up with **one delivery** carrying both lines, each labelled with its source warehouse. ::: warning Variant is install-time, not runtime You install one variant per Hantera instance. Switching variants is a re-install — existing orders keep the metadata they were created with, but new orders use the new model. ::: ## Where it fits ```mermaid sequenceDiagram participant Caller as Order source
(API / ERP / portal / import) participant Order as Order Actor participant IR as Inventory Routing participant SKU as SKU Actor Caller->>Order: Create or modify order
(deliveries flagged auto_assign) Order->>IR: OnOrderValidate hook Note over IR: Reads channel-country
priorities, queries stock
via Sku actor IR->>Order: Emit setDeliveryDynamicFields,
splitOrderLine, etc. IR->>SKU: Reserve / unreserve / set physical stock Order-->>Caller: Order projection updated ``` The app fires on `OnOrderValidate` after every order mutation, so the routing decision is automatically reapplied whenever the order changes — adding lines, changing quantities, cancelling a delivery, or even just clearing `inventoryKey` back to `auto_assign` on a delivery to "re-route" it. ## Shared behaviour across variants All variants share these characteristics: * **Channel-keyed, country-aware priorities.** Each channel can have a different ordered list of warehouses per country. A Swedish channel and a German channel can route Swedish customers through completely different priority chains. * **The `auto_assign` trigger key.** Anything that creates or edits an order — an HTTP ingress, an ERP integration, a portal agent, a CSV importer — can set `inventoryKey: "auto_assign"` to ask the app to resolve a real warehouse. * **Single-fulfiller preference.** When the highest-priority warehouse cannot cover a whole line but a lower-priority one can, the lower-priority warehouse wins. Picking from one place is operationally cheaper than splitting. * **Best-effort allocation.** Partial coverage produces partial reservations; unfulfilable units stay on the **country's primary warehouse** as back-order pressure. * **Automatic stock reservations** across the order lifecycle — reserve on open/processing, unreserve on cancel or inventory clear, unreserve + setPhysicalStock on completion. * **Inventory-date aware.** Each delivery's `inventoryDate` drives the stock lookup horizon, so pre-orders that include incoming shipments can coexist with urgent orders that only consider physical stock. ## What you do, what the app does | Activity | Who does it | | ----------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | | Configure channel-country inventory priorities | You (in the portal's channel editor, contributed by this app) | | Tag new deliveries with `inventoryKey: "auto_assign"` | Whatever creates the order (ingress, ERP integration, portal, import, etc.) | | Resolve `auto_assign` to a real warehouse based on stock and priorities | The app | | Split or move order lines when multiple warehouses are needed (per-delivery variant only) | The app | | Place / release stock reservations | The app | | Trigger a re-routing (e.g. after stock arrival) | Anyone — set `inventoryKey` back to `auto_assign` on the affected delivery and save. The app re-runs. | ## Available documentation * **[Per-Delivery Variant](/official-apps/inventory-routing/per-delivery/)** — the currently shipping variant. Includes configuration, the auto-assignment algorithm in operator terms, back-order behaviour, and reservation lifecycle. * *Per-Orderline Variant* — pending implementation. ## See also * [`asset.product`](/official-apps/products/graph) — the source of SKU stock that the routing algorithm queries. * [Activity log](/resources/registry/reference/) and the standard Hantera Order graph — the routing decisions surface as ordinary delivery / order-line mutations, visible alongside everything else on the order. --- --- url: /official-apps/inventory-routing/per-delivery.md --- # Per-Delivery Inventory Routing **App ID:** `inventory-routing.per-delivery` The per-delivery variant of the [Inventory Routing app family](/official-apps/inventory-routing/). Each delivery is fulfilled from **one warehouse** — the warehouse is stored in `delivery.dynamic.inventoryKey`. When an order line spans multiple warehouses, the app creates **additional deliveries** (one per inventory) so the per-delivery invariant always holds. ::: tip A delivery is a fulfilment for one warehouse Each delivery is fulfilled from a single warehouse. When the surrounding systems (WMS, ERP, picking station, courier integration) need exactly one fulfilment per warehouse, this is the variant for you. If your WMS / ERP can consume a single fulfilment that mixes warehouses, look at the per-orderline variant instead. ::: ## What the app contributes | Contribution | Purpose | | ------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------- | | `delivery.inventoryKey` graph field (`enum`) | Surfaces the resolved warehouse as a queryable / filterable field on deliveries in the portal and graph queries. | | `delivery.inventoryDate` graph field (`instant`) | The "as-of" date used for stock availability — controls whether incoming shipments count toward stock. | | Channel-country priority editor (portal) | A drag-and-drop UI inside the channel settings for ordering warehouses per country. | | Auto-assign delivery context menu item | Lets an agent right-click a delivery and re-trigger routing without manually toggling the `inventoryKey`. | | Rule: `autoAssign.hrl` (fires on `OnOrderValidate`) | The routing brain. Resolves `auto_assign` to real warehouses, splits lines across deliveries, manages reservations. | A separate optional companion rule for defaulting these fields on orders coming from the Commerce app also ships with the package — see [Companion rules](#companion-rules) below. ## The two dynamic fields These are the only routing knobs callers set directly on an order. Everything else is configuration of *the app*, not data on an order. ### `delivery.inventoryKey` | Value | Meaning | | -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | *(empty)* | Delivery is invisible to the routing app. No stock reservations placed, no routing decisions made. Useful for legacy or imported orders that should not be touched. | | `"auto_assign"` (literal string) | "Hantera, pick a warehouse for me." The app will replace this value with a real inventory key on the next `OnOrderValidate`. | | `"warehouse-x"` (any real key) | Pinned. The app respects the choice, places stock reservations against that warehouse, and does not re-route — unless someone sets it back to `auto_assign`. | The `enum` registry source for this field is the standard `inventories` enum, so the dropdown in the portal lists every configured warehouse. ### `delivery.inventoryDate` The instant at which stock availability is evaluated. Passed to `calculateAvailableStock` as the `asOf` parameter, so for example: * `inventoryDate = today` → only currently physically present stock counts. * `inventoryDate = today + 3 days` → physical stock plus any incoming shipments scheduled to arrive within 3 days counts. * `inventoryDate = today + 30 days` → suitable for pre-orders where the customer accepts a 30-day delivery window. If your order source does not set `inventoryDate` explicitly, you either need to provide a default yourself (a one-line rule fired when the order is created) or the routing will use the default zero instant — which means *no* incoming shipments count. For Commerce-sourced orders the optional companion rule (see below) does this automatically. ## Lifecycle decision flow ```mermaid flowchart TD Created([Delivery created or modified]) --> Hook{OnOrderValidate
fires} Hook --> Check{inventoryKey
value?} Check -- empty --> Skip([Skip — no reservations,
no routing]) Check -- "real key e.g.
warehouse-stockholm" --> Pinned([Use as-is.
Place reservations
against that warehouse]) Check -- "auto_assign" --> Resolve[Read channel
country priorities
+ query stock] Resolve --> Coverage{Can lines be
covered?} Coverage -- "single warehouse
covers all lines" --> Simple([Set inventoryKey
to that warehouse]) Coverage -- "needs multiple
warehouses" --> Split([Split lines into
additional deliveries,
one per warehouse]) Coverage -- "no warehouse
has any stock" --> Backorder([Pin to country's
#1 warehouse as
back-order]) Simple --> Reserve([Place reservations]) Split --> Reserve Pinned --> Reserve Backorder --> NoReserve([No reservations —
warehouse has no stock]) ``` The whole decision is re-evaluated every time the order changes — so adding a line, cancelling a delivery, or toggling `inventoryKey` back to `auto_assign` all cause the app to re-think the routing. ## Companion rules The app ships one optional companion rule that does not perform routing itself but improves the experience when used alongside specific other apps: | Rule | What it does | When it's relevant | | ----------------------------- | ------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------- | | `OnCommerceCartToOrder.hrl` | Sets `inventoryKey: "auto_assign"` and `inventoryDate: today` on the new delivery if missing. | Only fires for orders created by the Commerce app. Has no effect on orders from other sources. | If your tenant uses a different order source, simply ignore the companion rule — or write a similar one-line rule to do the same defaulting in your own integration. ## What to read next * **[Configuration](/official-apps/inventory-routing/per-delivery/configuration)** — set up channel-country warehouse priorities, understand where the data is stored, and how to seed it from your ERP. * **[Auto-Assignment](/official-apps/inventory-routing/per-delivery/auto-assignment)** — the routing algorithm in operator terms, with a case table showing what the order view looks like for each scenario. * **[Back-Orders](/official-apps/inventory-routing/per-delivery/back-orders)** — what happens when stock is insufficient, and how to clear back-orders once stock arrives. * **[Stock Reservations](/official-apps/inventory-routing/per-delivery/stock-reservations)** — when reservations are placed, released, and consumed across the order lifecycle. --- --- url: /official-apps/inventory-routing/per-delivery/configuration.md --- # Configuration The per-delivery routing app is configured almost entirely through one piece of data: an **ordered list of warehouse keys per (channel, country)**. Everything else — when to route, when to reserve, when to back-order — is automatic. ## Concepts to align on ::: tip Terminology check Before configuring, make sure these three concepts are clear in your tenant: * **Channel** — a market, sales surface, or external-integration scope (e.g. *Sverige*, *Hantera.io Webshop*, *Germany B2B*, *ERP-Import*). Channels are part of [Globalization](/resources/registry/reference/). * **Country** — the ISO-3166-1 alpha-2 code on a delivery address (e.g. `SE`, `DE`). * **Inventory** — a configured warehouse / 3PL location with a unique key (e.g. `warehouse-stockholm`, `3PL_DE_1`). ::: A given delivery is routed based on **its channel** and the **country on its delivery address**. So a Swedish channel selling to a German customer uses the priorities under "DE" in the Swedish channel's config — even if you also have a separate German channel with different priorities. ## Where the priority list lives Channel-country inventory priorities live in the channel registry, under each country entry: ``` /channels/ └── countries └── └── inventoryRouting_inventories: text[] ← ordered priority list ``` | Field | Type | Description | | ---------------------------------- | --------------- | ------------------------------------------------------------------------------------------------------------------------ | | `inventoryRouting_inventories` | `text[]` | Inventory keys in priority order. The first entry is the country's **primary** warehouse (also used as back-order home). | The keys in the list must exist in the `inventories` enum (i.e. be configured warehouses in your tenant). The app does not verify this — typos silently de-prioritise that slot. ## Where to configure it The app contributes a drag-and-drop UI to the channel editor in the portal. To configure: 1. Open **Settings → Channels** in the portal. 2. Pick a channel. 3. Expand the **Countries** section. 4. For each country you want to auto-route, drag inventories into the priority list in your desired order. 5. Save. ::: warning The country must already be enabled on the channel The app's priority editor renders inside an existing country row. If a country isn't listed for the channel, add it first via the standard country editor in the channel. ::: ## Worked example Imagine an outdoor-apparel retailer with two channels and three warehouses: | Channel key | Description | Country | Priority order | | ----------- | ----------------- | ------- | --------------------------------------------------------------- | | `se` | Swedish webshop | `SE` | `warehouse-stockholm`, `3PL_central`, `warehouse-overflow` | | `se` | Swedish webshop | `DE` | `3PL_DE_1`, `warehouse-stockholm` | | `se` | Swedish webshop | `NO` | `warehouse-stockholm`, `3PL_central` | | `de-b2b` | German B2B portal | `DE` | `warehouse-overflow`, `3PL_DE_1` | Reading this table: * A consumer in Stockholm checks out through the Swedish webshop — order goes to `warehouse-stockholm` first, falls back to `3PL_central`, then `warehouse-overflow` if needed. * The same Swedish webshop ships to Berlin — the local 3PL `3PL_DE_1` is preferred over the home warehouse to save customs costs and shipping time. * The B2B German portal has a completely different priority for `DE` — it prefers the lower-cost `warehouse-overflow` over `3PL_DE_1`, presumably because B2B customers tolerate longer delivery times. This level of decoupling is the whole point of the channel-keyed config: marketing, fulfilment, and customer experience can each be tuned independently. ## How the country code is determined The app reads `delivery.deliveryAddress.countryCode`. Specifically: * It must be a non-empty ISO 3166-1 alpha-2 code (e.g. `SE`, not `Sweden`). * It is **case-sensitive** in the registry lookup — the country code on the address must exactly match the key under `channels..countries.`. ::: warning Empty country code is the silent failure If `countryCode` is empty (e.g. on a partially-imported order), the app cannot look up a priority list and **skips routing entirely**. The delivery stays in `auto_assign` and never gets a real warehouse assigned. If you see this happening, check the delivery address on the affected order. ::: ## Seeding from your ERP The registry is just YAML-shaped JSON, so seeding from an ERP or CSV is a one-time job. A minimal seed for the table above looks like this: ```yaml # manifests/channels-seed.yaml uri: /registry/channels/se spec: value: countries: SE: inventoryRouting_inventories: - warehouse-stockholm - 3PL_central - warehouse-overflow DE: inventoryRouting_inventories: - 3PL_DE_1 - warehouse-stockholm NO: inventoryRouting_inventories: - warehouse-stockholm - 3PL_central --- uri: /registry/channels/de-b2b spec: value: countries: DE: inventoryRouting_inventories: - warehouse-overflow - 3PL_DE_1 ``` Push this manifest through your normal deployment pipeline (e.g. the Hantera CLI `apply` command), or have your ERP integration write directly via the standard registry API. ## Defaulting `inventoryKey` and `inventoryDate` For the routing app to act on a delivery, that delivery must have `inventoryKey: "auto_assign"` set and an appropriate `inventoryDate`. There are three ways to make sure this happens: | Option | When it's a good fit | | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | | **Have the order source set the fields explicitly.** Your HTTP ingress, ERP integration, or import script writes `inventoryKey: "auto_assign"` (and an `inventoryDate`) at order-creation time. | Anywhere the order source is something you control directly. | | **Write a small defaulting rule.** A one-line rule fired when an order is created can set the dynamic fields on any delivery that doesn't already have them. | When multiple order sources feed the same tenant and you want a single, central place to enforce defaults. | | **Use the optional companion rule that ships with this app.** `OnCommerceCartToOrder.hrl` sets `inventoryKey: "auto_assign"` and `inventoryDate: today` on deliveries created by the Commerce app. | When the Commerce app is one of your order sources and you want the routing to "just work" without writing anything yourself. | The companion rule only fires for Commerce-created orders and has no effect elsewhere; see the [`per-delivery/` overview](/official-apps/inventory-routing/per-delivery/#companion-rules) for details. ## See also * **[Auto-Assignment](/official-apps/inventory-routing/per-delivery/auto-assignment)** — what the app actually does with this configuration. * **[Back-Orders](/official-apps/inventory-routing/per-delivery/back-orders)** — what happens when none of the priority warehouses can cover the demand. * **[Stock Reservations](/official-apps/inventory-routing/per-delivery/stock-reservations)** — how the routed deliveries become real reservations against the SKU actor. --- --- url: /official-apps/inventory-routing/per-delivery/auto-assignment.md --- # Auto-Assignment Auto-assignment is the routing decision the app makes for each delivery marked with `inventoryKey: "auto_assign"`. It runs automatically on every order change. ## Inputs For each `auto_assign` delivery, the app looks at: 1. The **channel** of the order, and the **country code** on the delivery address. These pick a priority list from configuration. 2. The **inventory date** on the delivery. This becomes the `asOf` parameter for stock availability. 3. The order lines' **SKUs and quantities** — converted to stock requirements via each line's SKU composition. 4. The **available stock** at each candidate warehouse, as of the inventory date. This includes incoming shipments expected to arrive by that date. ## Per-line decision The app makes a decision **per order line**, and then aggregates those decisions into delivery-level actions. ### Step 1: Single-fulfiller preference For each line, the app first asks: *"is there a single warehouse — searched in priority order — that can cover the entire line by itself?"* If yes, that warehouse wins. This includes a lower-priority warehouse beating a higher-priority one that can only partially cover the line. ::: tip Why single-fulfiller is preferred A pick from one warehouse means one parcel, one shipping label, one box, one process. A two-warehouse split means twice the picking labour, two parcels, and an unhappier customer ("why did my t-shirt arrive in two boxes?"). Picking from one location even at a slightly higher cost-per-unit usually wins on operational cost. ::: ### Step 2: Greedy fill If no single warehouse covers the line, the app greedily fills the line in priority order — top warehouse takes as much as it can, next warehouse takes more, until either the line is satisfied or all warehouses are exhausted. ### Step 3: Back-order residual If after the greedy fill there are still uncovered units, the order line stays whole on the original delivery against the **country's #1 warehouse**. The full demand goes into a single reservation request — the SKU actor places reservations against any physical or incoming stock and records the uncovered units as an explicit **back-order entry** linked to that request. Back-orders are first-class data — operators, procurement systems, and replenishment jobs can query them directly. ## Case table — what the operator sees This is the per-line outcome translated into what shows up on the order view, plus stock reservations. | Scenario | Routing action | What the operator sees on the order | Stock reservations | | ------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | | A single warehouse can cover the full line | Whole line routed to that warehouse | One delivery with the resolved `inventoryKey`. No extra deliveries created. | Reserved against the chosen warehouse. | | Two warehouses needed; primary has *some* + secondary has *some*; together they cover | Line is split across two deliveries | Original delivery shrinks to the primary's share; a new delivery is created with the same shipping address, carrying the secondary's share. | Each portion reserved against its own warehouse. | | Stock partially missing — primary covers some, no other warehouse covers the rest | Allocated portion stays on the original delivery; missing portion remains on the same delivery as back-order | One delivery with the country's primary `inventoryKey`. The full original quantity is on it, even though some units can't be picked today. | Reservations against the primary up to its available stock; the uncovered quantity is recorded as a back-order entry on the same reservation request. | | Stock completely missing — no warehouse has any units | Whole line stays on the country's primary warehouse as back-order | One delivery with the country's primary `inventoryKey`. Warehouse will need to back-order from supplier. | No reservations against actual stock; the full demand becomes a back-order entry linked to the reservation request. | | Mixed warehouses + back-order residual | Splits per warehouse + leftover on the original delivery | Multiple deliveries, one per warehouse with stock. The original delivery carries the back-ordered units against the country's primary warehouse. | Each fulfilable portion reserved against its warehouse. | ## Operator workflow The above happens automatically when an order is created or modified. As an operator, you mostly observe the result: 1. Open the order in the portal. 2. Look at the deliveries panel — each delivery shows its resolved warehouse, the order lines, and its inventory date. 3. If a delivery has back-ordered units (visible as more demand than reserved), check the warehouse's incoming shipment schedule. If you need to re-route a delivery (e.g. because stock arrived at a different warehouse), you can: * Right-click the delivery → **Auto assign inventory** in the order view. This sets `inventoryKey` back to `auto_assign` and re-runs the rule. * Or open the delivery and manually pick an inventory from the dropdown. ## Inventory date behaviour The inventory date set on a delivery is preserved when the app creates additional deliveries from splits. So a customer's pre-order with `inventoryDate = today + 14 days` results in deliveries that all share that 14-day horizon — including the deliveries created by the routing app itself. This means stock that arrives within the 14-day window counts for all of them, even though the deliveries were technically created at slightly different moments by different mutations. ## See also * **[Configuration](/official-apps/inventory-routing/per-delivery/configuration)** — set the priority lists the algorithm reads. * **[Back-Orders](/official-apps/inventory-routing/per-delivery/back-orders)** — what happens when stock is insufficient, and how to handle it. * **[Stock Reservations](/official-apps/inventory-routing/per-delivery/stock-reservations)** — how reservations move through the order lifecycle. --- --- url: /official-apps/inventory-routing/per-delivery/back-orders.md --- # Back-Orders Back-orders are the app's answer to *"the customer ordered more than we currently have in stock"*. The default is to take the order anyway, route the unfulfilable portion to the country's primary warehouse, and trust that warehouse to handle the back-order — by ordering from a supplier, by waiting for an incoming shipment, or by escalating. This page describes the behaviour from an operator perspective. For the algorithm internals, see [Auto-Assignment](/official-apps/inventory-routing/per-delivery/auto-assignment). ## The back-order home rule When the app cannot fully cover an order line, the **uncovered residual stays on the country's #1 (primary) warehouse**. The primary warehouse is always the first entry in the channel-country priority list configured in [Configuration](/official-apps/inventory-routing/per-delivery/configuration). ::: tip Why pin to the primary Anchoring back-orders to a single warehouse per country is a deliberate choice: * **Clear accountability.** When stock arrives or is replenished, only one warehouse needs to act. Spreading back-orders across all warehouses creates ambiguity about whose problem it is. * **Predictable for operators.** When a customer asks "where is my order?", you know the answer is always "the country's primary warehouse". * **Matches how supply chains actually work.** Replenishment typically goes to one central warehouse, not all of them simultaneously. ::: ## Back-order scenarios | Stock situation | What the app does | What an operator sees | | -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Stock fully available | Routes normally. | Standard delivery view; reservations fully match demand. | | Partial coverage from multiple warehouses, with leftover | Splits the available portion across warehouses (creating extra deliveries), keeps the leftover on the original delivery against the primary warehouse. | Multiple deliveries — fulfilable ones can ship today; the original delivery has the back-ordered units and waits for the primary's stock. | | Partial coverage from primary only | Keeps everything on the original delivery against the primary. Reservations cover what's in stock; the remainder is recorded as a back-order entry on the same reservation request. | One delivery with the primary `inventoryKey`. The reservation request shows a back-order entry alongside the reservations. | | No stock anywhere | Pins the whole line to the country's primary warehouse. The full demand becomes a back-order entry on the reservation request. | One delivery with the primary `inventoryKey`. The reservation request consists entirely of a back-order entry — entirely waiting on warehouse procurement. | | Missing channel config (no priority list set) | Skips routing entirely. | Delivery stays at `inventoryKey: "auto_assign"` — a clear sign that configuration is missing. Add the country's priority list, save, and the delivery will route on next change. | | Empty delivery address country code | Skips routing entirely. | Same as above — delivery stays as `auto_assign`. Fix the address. | ## Clearing a back-order Most back-orders clear themselves. The SKU actor runs its reservation calculator after every relevant change to the warehouse's stock or reservations, and any back-order entries waiting on that SKU are promoted to reservations as soon as enough stock is available. ```mermaid flowchart LR A[Stock arrives,
incoming shipment receipted,
or another reservation released] --> B[SKU actor
recalculates] B --> C[Back-order entries
promoted to reservations
where stock now allows] ``` The operator only has to intervene when they want to **redirect the delivery to a different warehouse** — for example, if stock arrived at a warehouse that wasn't the one the back-order is pinned to. In that case: 1. The order changes (any field) and the routing rule re-runs, or 2. An operator manually re-triggers routing via right-click → **Auto assign inventory**, or 3. The delivery's `inventoryKey` is set back to `"auto_assign"` and saved. ::: tip A delivery's history is preserved Re-running auto-assignment on a delivery does not erase its history. The order activity log records every routing decision, so an auditor can trace how a delivery moved from back-order to fulfilable to picked. ::: ## When a back-order *cannot* be served by re-routing Some back-orders won't clear by waiting — the product is being discontinued, the supplier has dropped them, etc. In that case the operator should: 1. Cancel the back-ordered delivery (it has the residual quantity), or 2. Issue a refund / partial fulfilment via the standard order flow. The routing app itself doesn't model "abandoned back-order" — that's a business decision handled through ordinary order management. ## Reservations and back-orders Back-orders are **first-class data** in Hantera, not an implicit consequence of numbers going negative. When the routing app reserves stock against a warehouse, it sends a single reservation request for the full demand. The SKU actor processes that request as follows: 1. It places **reservations** against actual stock — physical units on hand plus expected incoming shipments. Reservations never exceed available stock, so `availableStock` floors at zero. 2. Anything still uncovered is recorded as a **back-order entry** linked to the same reservation request. So a reservation request for 10 units against a warehouse with 7 in stock produces: * Reservations totalling 7 (linked to the request). * One back-order of 3 (also linked to the request). * `availableStock` = 0. Because back-orders are explicit records, they're useful for: * **Generating procurement orders.** A job can query all open back-orders on a SKU and decide whether to raise a purchase order with the supplier. * **Replenishment dashboards.** Operators see at a glance which warehouses have outstanding back-orders. * **Inter-warehouse transfers.** When one warehouse has surplus and another has back-orders, a rule can move stock between them. **Back-orders auto-clear when stock becomes available.** Whenever the SKU actor processes a relevant change — stock arrives (physical or incoming), another order is cancelled and releases stock, a competing reservation shrinks — it runs its reservation calculator and promotes back-order entries to reservations against the freshly available stock. No human action is required, and no re-routing is needed unless you want to redirect the delivery to a *different* warehouse. The back-order signal is also preserved across re-routing initiated by the app: if a delivery's `inventoryKey` is set back to `auto_assign` and the rule picks a different warehouse, the old reservations and back-order entries on the old warehouse are released, and new ones are created at the new warehouse. ## See also * **[Stock Reservations](/official-apps/inventory-routing/per-delivery/stock-reservations)** — how reservations flow through the order lifecycle, and how they interact with completing or cancelling a delivery. * **[Auto-Assignment](/official-apps/inventory-routing/per-delivery/auto-assignment)** — the routing decision that produces back-orders. --- --- url: /official-apps/inventory-routing/per-delivery/stock-reservations.md --- # Stock Reservations In addition to routing, the per-delivery app is responsible for **placing and releasing stock reservations** against the SKU actor as deliveries progress through their lifecycle. This is what makes "the order is committed; the warehouse must hold the stock" a real, durable claim that other orders cannot accidentally over-allocate. ::: tip One source of truth Reservations live on the SKU actor — the same place that tracks physical stock. The routing app sends messages to the SKU actor whenever a reservation needs to change; the actor handles the bookkeeping. No reservation data lives on the order itself. ::: ## When reservations are placed Reservations are placed when a delivery is in `open` or `processing` state **and** has a real `inventoryKey` (not `"auto_assign"` and not empty). | Delivery state | Has real `inventoryKey`? | Reservation behaviour | | -------------- | ------------------------ | --------------------------------------------------------------------------- | | `open` | Yes | Reserve quantity × unit-quantity against that warehouse, per SKU on the line. | | `processing` | Yes | Same — reservation is preserved across the open → processing transition. | | `completed` | Yes | Unreserve **and** subtract from physical stock (warehouse picked the items). | | `cancelled` | (any) | Unreserve. No physical stock change. | | `open` / `processing` | No (`auto_assign` or empty) | No reservation — there's no real warehouse to reserve against. | These rules apply uniformly across the original delivery and any extra deliveries the routing app creates during a split. ## Lifecycle diagram ```mermaid stateDiagram-v2 [*] --> Open: Delivery created Open --> Open: Order line changes
(reservation adjusted) Open --> Processing: Marked for fulfilment Processing --> Processing: Order line changes
(reservation adjusted) Processing --> Completed: Warehouse picked + shipped Open --> Cancelled: Cancelled before fulfilment Processing --> Cancelled: Cancelled mid-fulfilment Completed --> [*]: Unreserve + reduce physical stock Cancelled --> [*]: Unreserve only ``` ## What triggers a reservation update The app diffs reservations after every order change: 1. **Add an order line, increase quantity** → reservation grows. 2. **Remove an order line, decrease quantity** → reservation shrinks. 3. **Change a delivery's `inventoryKey` from warehouse A to warehouse B** → unreserve A, reserve B (treating it as one atomic operation, so other orders see no transient drop). 4. **Set `inventoryKey` back to `auto_assign`** → unreserve everything for the delivery. The app then re-routes on the same hook firing and places fresh reservations against the resolved warehouse. 5. **Delete the order** (the rare destructive case) → fires the `OnOrderDeleted` hook variant; the app unreserves everything across all deliveries. 6. **Stock arrives at the back-ordered warehouse**, or another order releases stock → the SKU actor automatically promotes outstanding back-order entries to reservations against the freshly available stock. No routing app involvement needed. ## Reservations and split lines When the app splits an order line across warehouses, the reservations follow the split: * The **original** order line keeps reservations against the original delivery's warehouse for its remaining quantity. * Each **new** order line (with its own `orderLineId`) carries reservations against its own warehouse. This means a `splitOrderLine` operation has implicit reservation movement — units that were notionally reserved against the original delivery's warehouse get rebalanced. The SKU actor's view of "open reservations" stays accurate throughout. ## Reservations against insufficient stock The app always sends a reservation request for the **full demand** — it never caps the request at available stock. The SKU actor handles the shortfall by splitting that single request into two kinds of records: * **Reservations** for as much as physical and incoming stock can cover. Total reservations never exceed available stock, so `availableStock` floors at zero rather than going negative. * **A back-order entry** for the remainder, linked to the same reservation request. Both kinds of records hang off the same reservation request and are queryable on the SKU graph, so consumers can see exactly which portion of a request is fulfilable today and which portion is waiting on stock. For the operator implications and how back-orders feed procurement and replenishment, see [Back-Orders](/official-apps/inventory-routing/per-delivery/back-orders). ## Observability Reservations are queryable via the standard SKU actor graph: * `asset.product.sku` nodes carry `availableStock`, `physicalStock`, and `reservedStock` fields. * Each SKU has reservation entries that link back to the originating order line. So you can answer questions like "what orders are reserving stock at warehouse X for SKU Y?" with a normal graph query. ## Inventory date and reservations The inventory date on a delivery affects the **stock lookup** the app uses when deciding what to reserve. It does **not** change reservation behaviour itself — reservations are always placed *now* against the resolved warehouse. So if a delivery has `inventoryDate = today + 14 days`: 1. The app counts physical stock + incoming-by-day-14 shipments when deciding what to route. 2. It places reservations *today* against the resolved warehouse. 3. The reservation is held until the delivery is completed or cancelled. If the incoming shipment fails to arrive, the units originally reserved against that shipment are converted to back-order entries when the SKU actor next recalculates — surfacing the gap as explicit, queryable data instead of a silent failure. ## See also * **[Auto-Assignment](/official-apps/inventory-routing/per-delivery/auto-assignment)** — produces the routing decisions that drive reservation placement. * **[Back-Orders](/official-apps/inventory-routing/per-delivery/back-orders)** — what happens when reservations cannot fully match demand. * **[`asset.product.sku` graph node](/official-apps/products/graph)** — where reservations and stock live. --- --- url: /official-apps/psp.md --- # Payment Providers Payment Provider (PSP) apps integrate Hantera's payment system — and in particular the [Payment actor](/resources/actors/payment/) — with external Payment Service Providers. Each PSP app: * Translates Payment actor lifecycle events (authorize, capture, refund, void) into the provider's protocol * Handles incoming webhooks from the provider and applies the corresponding commands to the Payment actor * Exposes provider-specific configuration (API credentials, environments) via app settings Many PSP apps also ship with a [Commerce](/official-apps/commerce/) integration — ingresses, hooks, and a storefront flow — so the cart-to-payment-to-order pipeline works end-to-end without additional integration work. Commerce integration is a convenience, not a requirement: the Payment actor integration is the core of every PSP app. If you're building your own PSP app, start with the generic [Commerce PSP Integration](/official-apps/commerce/psp-integration) guide. It documents the standard pattern shared by all PSP integrations. ## Available PSP Apps ### [Kustom](./kustom/) Integrates the Payment actor with Kustom and ships with a complete Commerce cart integration. Kustom is an iframe-based checkout where the provider owns the entire checkout UI — address collection, shipping options, and payment method selection all happen inside an iframe rendered by Kustom. **Key features:** * Payment actor integration (authorize via checkout flow, capture, acknowledgement) * Idempotent checkout ingress with iframe `htmlSnippet` (Commerce) * Confirmation ingress and push-webhook fallback that both create the Payment, complete the cart, and acknowledge to Kustom (Commerce) * Hash-based cart synchronization driven by [`OnCartMutation`](/official-apps/commerce/hooks) (Commerce) * Capture flow via the `OnPaymentCapture` rule hook * [`OnKustomValidation`](./kustom/validation-hook) custom hook — let merchant rules veto checkout completion with a structured error returned to Kustom * [`OnKustomExternalPaymentMethods`](./kustom/external-payment-methods-hook) custom hook — let merchant rules add external payment methods (e.g. PayPal) to the Kustom checkout ### [PayPal](./paypal/) Integrates the Payment actor with PayPal — capturing and refunding PayPal payments and reconciling them onto the Hantera Payment via webhooks. It also ships with an optional redirect-based checkout that works well as a Kustom External Payment Method. **Key features:** * Payment-actor capture & refund via the `OnPaymentCapture` rule hook * Unified settlement: one rule both captures outstanding authorizations and refunds over-charged captures * Idempotent webhook receiver with PayPal signature verification * Optional redirect-driven checkout (`start` → PayPal approval → `return`) that creates the Payment, completes the cart, and lands on the channel confirmation page (`intent=AUTHORIZE`, captured later) --- --- url: /official-apps/psp/kustom.md --- # Kustom The Kustom app integrates [Kustom](https://kustom.co) as a Payment Service Provider in Hantera. Kustom is an iframe-based checkout where the provider owns the entire checkout UI — address collection, shipping options, and payment method selection all happen inside an iframe rendered by Kustom. This is fundamentally different from card-style PSPs where the merchant controls the checkout UI and the PSP only processes the payment. With Kustom, the order summary is rendered *inside* Kustom's iframe, so the server must keep the Kustom order synchronized with the Hantera cart whenever cart contents change. ## What It Provides ### HTTP Ingresses | Ingress | Route | Purpose | | ---------- | ------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | | `checkout` | `POST /commerce/carts/{cartId}/payment/kustom` | Idempotently creates (or returns) a Kustom order for the cart. Returns `htmlSnippet` for the storefront to render | | `confirm` | `POST /commerce/carts/{cartId}/payment/kustom/confirm` | Called by the storefront after Kustom redirects to the confirmation URL. Creates the Payment and completes the cart | | `webhook` | `POST /kustom/webhook/push` | Fallback push notification from Kustom (2–5 min after checkout). Completes the cart if the confirm path didn't | | `validate` | `POST /kustom/validate` | Pre-completion callback from Kustom. Fires the [`OnKustomValidation`](./validation-hook) hook | ### Jobs | Job | Purpose | | ----------------- | -------------------------------------------------------------------------------------- | | `syncKustomOrder` | Pushes an updated order payload to Kustom's API when the cart changes | | `captureOrder` | Captures an authorized Kustom order via the Order Management API | ### Rules | Rule | Listens on | Purpose | | ------------------- | ----------------- | ------------------------------------------------------------------------------------------------------------------ | | `cart-kustom-sync` | `OnCartMutation` | Builds the Kustom payload, hashes it, and schedules `syncKustomOrder` when the hash changes — see [Commerce Integration](./commerce-integration) | | `capture` | `OnPaymentCapture`| Schedules `captureOrder` for Kustom payments | ### Custom Hooks The Kustom app emits these custom hooks: * **[`OnKustomValidation`](./validation-hook)** — fires when Kustom calls back to validate an order before completing checkout. Merchant rules can return a `validationError` effect to block completion with a custom error code shown to the customer. * **[`OnKustomExternalPaymentMethods`](./external-payment-methods-hook)** — fires while building the Kustom order. Merchant rules return external payment methods (e.g. PayPal) to offer for the current cart and channel. * **[`OnKustomCheckboxes`](./checkboxes-hook)** — fires while building the Kustom order. Merchant rules return additional checkboxes (e.g. a marketing-consent opt-in) to render beneath the purchase button; the customer's selection is stored back on the created order. * **[`OnKustomCustomerData`](./customer-data-hook)** — fires whenever a Kustom address is applied to a cart. Merchant rules receive the customer data Kustom collected (including the given/family name split that the single-field Hantera address cannot hold) and choose what to stage for a CRM sync. Nothing is stored without a listener. ## App Settings Configured in the portal under the Kustom app: | Setting | Type | Description | | ------------- | ------- | -------------------------------------------------------------------------- | | `username` | text | Kustom API key ID | | `password` | secret | Kustom API password | | `environment` | select | `playground` (sandbox) or `production` | ## Architecture ```mermaid graph TB subgraph "Browser" Client[Storefront] KCO[Kustom Iframe] end subgraph "Kustom App" Checkout[checkout ingress] Confirm[confirm ingress] Webhook[webhook ingress] Validate[validate ingress] SyncRule[cart-kustom-sync rule
OnCartMutation] SyncJob[syncKustomOrder job] CaptureRule[capture rule
OnPaymentCapture] CaptureJob[captureOrder job] end subgraph "Hantera Platform" Cart[Cart / Ticket] Payment[Payment] end subgraph "Kustom API" KustomAPI[checkout & ordermanagement] end Client -->|Initiate checkout| Checkout Checkout -->|Create / fetch order| KustomAPI Checkout -->|htmlSnippet| Client Client -->|Render| KCO KCO -->|Customer modifies cart| Client Client -->|Cart mutation| Cart Cart -.OnCartMutation.-> SyncRule SyncRule -->|Schedule| SyncJob SyncJob -->|Update order| KustomAPI KCO -->|Pre-completion callback| Validate Validate -.OnKustomValidation.-> MerchantRules[Merchant rules] Validate -->|Validation result| KCO KCO -->|Redirect on success| Client Client -->|Confirm| Confirm Confirm --> Payment Confirm --> Cart Confirm --> KustomAPI KustomAPI -->|Push fallback| Webhook Webhook --> Payment Webhook --> Cart Webhook --> KustomAPI Payment -.OnPaymentCapture.-> CaptureRule CaptureRule --> CaptureJob CaptureJob --> KustomAPI ``` ## When to Use Kustom Choose Kustom when: * You want a fully-managed checkout UI (iframe) and don't want to build your own * You operate in markets where Kustom is a popular local payment provider * You want pay-later and installment options handled by the provider For card-only checkouts where you want to control the UI, use a card-style PSP instead. ## Getting Started 1. **Install** the Kustom app on your Hantera instance. 2. **Configure** API credentials and environment in the portal. 3. **Integrate the storefront.** Follow the end-to-end guide in the Storefront SDK docs: [Kustom Checkout](https://storefront.hantera.dev/checkout/kustom.html). 4. **(Optional)** Add merchant validation rules using the [`OnKustomValidation`](./validation-hook) hook. ## See Also * [Commerce Integration](./commerce-integration) — How the Kustom app integrates with the Commerce cart flow * [`OnKustomValidation` hook](./validation-hook) — Add custom validation logic * [Commerce PSP Integration](/official-apps/commerce/psp-integration) — Generic PSP integration pattern * [Storefront SDK — Kustom Checkout](https://storefront.hantera.dev/checkout/kustom.html) — End-to-end storefront integration guide --- --- url: /official-apps/psp/kustom/commerce-integration.md --- # Commerce Integration The Kustom app is designed to work with — but does **not** require — the [Commerce app](/official-apps/commerce/). The checkout, confirm, and webhook ingresses all operate on Commerce-style carts (tickets of type `cart`), and the cart synchronization flow listens on Commerce's [`OnCartMutation`](/official-apps/commerce/hooks) hook. If you're using the Commerce app, everything below works out of the box; if you're integrating Kustom into a custom checkout flow, the same patterns can be re-implemented against your own ticket type. ::: tip End-to-end storefront guide For a step-by-step walkthrough of integrating Kustom into a storefront — including the iframe lifecycle, suspend/resume handling, and the confirmation redirect — see the Storefront SDK documentation: **[Kustom Checkout](https://storefront.hantera.dev/checkout/kustom.html)**. ::: ## Checkout Flow The storefront initiates checkout by `POST`-ing to the Kustom `checkout` ingress with the cart ID. The ingress is **idempotent** — calling it again returns the existing Kustom order's snippet instead of creating a new one. ```mermaid sequenceDiagram participant Browser participant Checkout as checkout ingress participant Cart participant KustomAPI as Kustom API Browser->>Checkout: POST /commerce/carts/{cartId}/payment/kustom Checkout->>Cart: Query kustomOrderId alt cart has no kustomOrderId Checkout->>Cart: Preview cart (totals, lines) Checkout->>KustomAPI: POST /checkout/v3/orders KustomAPI-->>Checkout: kustomOrderId + htmlSnippet Checkout->>Cart: Store kustomOrderId else cart already has kustomOrderId Checkout->>KustomAPI: GET /checkout/v3/orders/{kustomOrderId} KustomAPI-->>Checkout: htmlSnippet end Checkout-->>Browser: { htmlSnippet } ``` The storefront injects the returned `htmlSnippet` into the page, which mounts the Kustom iframe. ## Cart Synchronization Because Kustom renders the order summary inside its own iframe, the Kustom-side order must be updated whenever the Hantera cart changes (item added, quantity changed, coupon applied, etc.). The Kustom app handles this automatically via a hash-based sync mechanism driven by Commerce's `OnCartMutation` hook. ### How It Works 1. The Commerce app fires [`OnCartMutation`](/official-apps/commerce/hooks) after every successful cart render with the full rendered cart payload. 2. The `cart-kustom-sync` rule listens on this hook. It builds the Kustom API update payload, hashes it, and compares the hash to the `kustomSyncHash` field stored on the cart. 3. If the hash differs, the rule: * Sets `kustomPendingHash` on the cart via a `ticketCommand` effect * Schedules the `syncKustomOrder` job with the pre-built payload via a `scheduleJob` effect 4. The `syncKustomOrder` job pushes the payload to Kustom's API, then updates `kustomSyncHash` to match `kustomPendingHash`. ```mermaid sequenceDiagram participant Browser participant CommerceIngress as Commerce ingress participant SyncRule as cart-kustom-sync rule participant Cart participant SyncJob as syncKustomOrder job participant KustomAPI as Kustom API participant SSE Browser->>CommerceIngress: Add item / change quantity CommerceIngress->>Cart: Apply cart mutation CommerceIngress->>SyncRule: triggerHook OnCartMutation (rendered cart) SyncRule->>SyncRule: Build payload + hash SyncRule-->>CommerceIngress: ticketCommand (set kustomPendingHash) + scheduleJob (syncKustomOrder) CommerceIngress->>Cart: Apply commands (set kustomPendingHash) Cart->>SSE: Cart updated SSE->>Browser: kustomPendingHash ≠ kustomSyncHash → suspend iframe CommerceIngress-->>Browser: Cart response SyncJob->>KustomAPI: POST /checkout/v3/orders/{id} SyncJob->>Cart: Set kustomSyncHash = kustomPendingHash Cart->>SSE: Cart updated SSE->>Browser: Hashes match → resume iframe ``` ### Why Hash-Based and Not a Dirty Flag Using `OnCartMutation` (a hook fired from the Commerce ingress runtime) instead of `OnTicketCommands` (which fires on *any* ticket mutation, including the sync rule's own writes) avoids an infinite loop: ``` cart command → OnTicketCommands → set dirty flag → OnTicketCommands → ... ``` The hash also lets the storefront tell, at a glance, whether the Kustom order is currently being synced: `kustomPendingHash !== kustomSyncHash` means a sync is in flight, so the iframe should be suspended. ### Cart Fields Used by Kustom | Field | Purpose | | -------------------- | ----------------------------------------------------------------------------- | | `kustomOrderId` | The Kustom-side order ID. Set by the `checkout` ingress. | | `kustomPendingHash` | Hash of the most-recently-built payload. Set by `cart-kustom-sync`. | | `kustomSyncHash` | Hash of the most-recently-synced payload. Set by `syncKustomOrder` job. | ## Confirmation Flow Kustom confirms successful checkouts via two paths. The Kustom app handles both — whichever fires first wins, the other becomes a no-op. ### Primary Path: Confirm Ingress (Browser-Initiated) When the customer completes payment, Kustom redirects the browser to the configured `confirmation_url`. The storefront detects this and calls the `confirm` ingress: ```mermaid sequenceDiagram participant Browser participant Confirm as confirm ingress participant Cart participant KustomAPI as Kustom API participant Payment Browser->>Confirm: POST /commerce/carts/{cartId}/payment/kustom/confirm Confirm->>Cart: Query kustomOrderId + state Confirm->>KustomAPI: GET /checkout/v3/orders/{kustomOrderId} alt status = checkout_complete and cart not completed Confirm->>Payment: Create payment + authorization Confirm->>Cart: Link payment + complete end Confirm->>KustomAPI: POST /ordermanagement/v1/orders/{id}/acknowledge Confirm-->>Browser: { status } ``` ### Fallback Path: Push Webhook If the browser-initiated path fails (network drop, customer closes the tab, etc.), Kustom sends a push notification 2–5 minutes later: ```mermaid sequenceDiagram participant KustomAPI as Kustom API participant Webhook as webhook ingress participant Cart participant Payment KustomAPI->>Webhook: POST /kustom/webhook/push Webhook->>KustomAPI: GET /checkout/v3/orders/{id} Webhook->>Cart: Query state alt cart already completed Webhook->>KustomAPI: POST /ordermanagement/v1/orders/{id}/acknowledge else Webhook->>Payment: Create payment + authorization Webhook->>Cart: Link payment + complete Webhook->>KustomAPI: POST /ordermanagement/v1/orders/{id}/acknowledge end ``` Both paths acknowledge the order with Kustom (`POST /ordermanagement/v1/orders/{id}/acknowledge`) so Kustom stops sending push retries. ## Pre-Completion Validation Before completing checkout, Kustom can call back to the merchant for final validation (e.g., to verify that stock is still available). The Kustom app exposes the `validate` ingress for this, which fires the [`OnKustomValidation`](./validation-hook) custom hook. Merchant-supplied rules can return a `validationError` effect to block completion with a structured error code that's displayed to the customer in the iframe. See [`OnKustomValidation` hook](./validation-hook) for full details. ## Capture Flow For Kustom payments, the authorization is created when the cart completes, but the funds are only captured later (typically when the order is fulfilled). The Kustom app subscribes to the platform's `OnPaymentCapture` hook: ```mermaid sequenceDiagram participant Platform participant CaptureRule as capture rule participant CaptureJob as captureOrder job participant KustomAPI as Kustom API Platform->>CaptureRule: OnPaymentCapture (providerKey=kustom) CaptureRule->>CaptureJob: scheduleJob(captureOrder) CaptureJob->>KustomAPI: POST /ordermanagement/v1/orders/{id}/captures ``` ## Storefront Integration See the Storefront SDK guide for the recommended client-side integration: * [Kustom Checkout (Storefront SDK)](https://storefront.hantera.dev/checkout/kustom.html) The guide covers iframe lifecycle, suspending and resuming the iframe based on the sync-hash comparison, handling the confirmation redirect, and a reusable Vue component (`KustomCheckout.vue`) that wires it all together. ## See Also * [Kustom App Overview](./) — Ingresses, jobs, rules, and settings * [`OnKustomValidation` hook](./validation-hook) — Custom validation logic * [Commerce — `OnCartMutation` hook](/official-apps/commerce/hooks) — The hook that drives cart sync * [Commerce PSP Integration](/official-apps/commerce/psp-integration) — Generic PSP pattern --- --- url: /official-apps/psp/kustom/validation-hook.md --- # `OnKustomValidation` Hook Kustom can be configured to call back to the merchant before completing a checkout. This is typically used for last-minute validations — verifying that stock is still available, that promotion conditions still hold, or any other business rule that should be re-checked once the customer is about to pay. The Kustom app exposes the `validate` ingress for this callback, and emits the `OnKustomValidation` custom hook so that merchant rules (or other apps) can decide whether to allow the checkout to complete. ## When It Fires Fires when Kustom sends a `POST /kustom/validate` request to your Hantera instance. The Kustom app: 1. Parses Kustom's order payload from the request body. 2. Resolves the cart from `merchant_reference1` (the cart ID). 3. Previews and queries the cart's resulting order (totals, lines, etc.). 4. Triggers `OnKustomValidation` with the rendered order data. 5. Collects `validationError` effects from the hook. 6. If any errors are present, responds to Kustom with the first one and `400 Bad Request`. Otherwise responds with `200 OK`. ## Hook Input The hook input is the rendered order with `hook` added by the runtime: ```filtrera { hook: 'OnKustomValidation' channelKey: text currencyCode: text locale: text | nothing taxIncluded: bool orderTotal: number orderTaxTotal: number orderLines: [{ productNumber: text description: text | nothing quantity: number reservedQuantity: number unitPrice: number salesTotal: number taxTotal: number }] } ``` ::: tip You only need to declare the fields your rule uses. Filtrera's type system automatically matches rules to the hook based on the declared input shape. ::: ## Returning a Validation Error To block the checkout, a rule emits one or more `validationError` effects. Only the **first** error is forwarded to Kustom; all others are ignored. ```filtrera from { effect = 'validationError' code = 'approval_failed' message = 'One or more items are no longer available.' } ``` | Field | Required | Mapped to Kustom | Notes | | --------- | -------- | ---------------- | ------------------------------------------------------------------------------ | | `effect` | yes | — | Must be `'validationError'` | | `code` | yes | `error_type` | Kustom-defined error type. Must be one of the values below. | | `message` | no | `error_text` | Human-readable message shown to the customer. Falls back to `code` if omitted. | ### Allowed `code` values `code` is forwarded as-is to Kustom in the `error_type` field, and Kustom only recognizes a fixed set of values. Other values will not render correctly in the iframe. | `code` | When to use | | ------------------------------- | ------------------------------------------------------------------------------------------ | | `'address_error'` | The customer-supplied address is invalid (malformed postal code, missing fields, etc.) | | `'unsupported_shipping_address'`| The address is valid but you don't ship to that location | | `'approval_failed'` | The order cannot be approved for any other reason (out of stock, fraud check, business rule) | If no rule emits a `validationError`, the ingress responds with `200 OK` and Kustom proceeds with the checkout. ## Example: Stock Availability Rule This rule blocks the checkout if any order line has more quantity than what's currently reserved: ```filtrera param input: { hook: 'OnKustomValidation' orderLines: [{ productNumber: text quantity: number reservedQuantity: number }] } import 'iterators' let unavailable = input.orderLines where line => line.quantity > line.reservedQuantity buffer from unavailable count > 0 match true |> { effect = 'validationError' code = 'approval_failed' message = let names = unavailable select line => line.productNumber join ', ' $'The following items are no longer available: {names}' } ``` When this rule emits the error, the customer sees the message inside the Kustom iframe and cannot complete the purchase. ## Example: Order Total Threshold This rule rejects orders below a minimum amount: ```filtrera param input: { hook: 'OnKustomValidation' currencyCode: text orderTotal: number } from input.orderTotal < 100 match true |> { effect = 'validationError' code = 'approval_failed' message = $'Minimum order total is 100 {input.currencyCode}.' } ``` ## Example: Unsupported Shipping Country This rule rejects orders whose channel doesn't permit a given destination — useful when shipping eligibility is configured outside of Kustom: ```filtrera param input: { hook: 'OnKustomValidation' channelKey: text } import 'iterators' let allowedChannels = ['retail-se', 'retail-no'] from allowedChannels where c => c == input.channelKey count == 0 match true |> { effect = 'validationError' code = 'unsupported_shipping_address' message = 'We don''t ship to this destination yet.' } ``` ## Effect Handling The `validate` ingress only processes `validationError` effects from the hook. Any other effects emitted by listening rules are silently ignored — this hook is intended purely for accepting or rejecting the checkout, not for triggering side effects. ## See Also * [Kustom App Overview](./) — Ingresses, jobs, and rules * [Commerce Integration](./commerce-integration) — How Kustom integrates with the cart flow * [Custom Hooks](/resources/rules/trigger-hook) — How `triggerHook` works * [Rule Effects](/resources/rules/effects) — Available effect types --- --- url: /official-apps/psp/kustom/external-payment-methods-hook.md --- # `OnKustomExternalPaymentMethods` hook Kustom Checkout can present **external payment methods** (EPM) — payment options not built into Kustom, such as PayPal. When the customer selects one, Kustom collects the address and then **redirects the browser to a merchant-owned URL** that owns the entire purchase flow. Kustom does **not** create or track an order for external payments and sends **no postback**. The Kustom app fires the custom hook `OnKustomExternalPaymentMethods` while building the Kustom order in the [`checkout`](./commerce-integration#checkout-flow) ingress. Merchant rules listen to it and return the external payment methods to add for the current cart and channel. ## Address Handoff The Kustom app wraps each effect's `redirect_url` in a Hantera-owned **handoff ingress** before sending the list to Kustom. When the customer picks an EPM, Kustom redirects to the handoff, which: 1. Fetches the Kustom order to read the address Kustom captured. 2. Applies that address (and the billing address / VAT id) to the cart. 3. 302s the browser to the merchant's original `redirect_url`. This means the EPM app (e.g. PayPal) sees a **fully addressed cart** from the moment its own start ingress runs — it does not need to collect or pass the address itself. The wrapping is transparent to the merchant rule: the `redirect_url` you emit is the URL the customer ultimately lands on. If the address handoff fails (Kustom order cannot be fetched, or the cart rejects the address commands), the handoff returns an error response instead of forwarding. An unaddressed cart cannot be fulfilled, so dead-ending here is preferable to a downstream order failure. ## When It Fires Once per Kustom order creation, from the `checkout` ingress, before the order is sent to Kustom. The effects returned by listeners are added to the Kustom order's `external_payment_methods` array. If no listener returns a method, the array is omitted. ## Hook Input ```filtrera { hook: 'OnKustomExternalPaymentMethods' cartId: uuid channelKey: text currencyCode: text orderTotal: number systemHost: text checkoutUrl: text confirmationUrl: text } ``` | Field | Notes | |---|---| | `cartId` | The cart being checked out. | | `channelKey` | Channel the cart belongs to — use this to scope which channels offer which methods. | | `currencyCode` | Order currency. | | `orderTotal` | Order total (in major units). | | `systemHost` | The tenant host, for building absolute redirect URLs. | | `checkoutUrl` | The storefront checkout URL (used as the EPM cancel/return-to-checkout target). | | `confirmationUrl` | The storefront confirmation URL the external provider should land on after completion. | ## Emitting a Method Listeners emit a [`custom`](/resources/components/runtimes/rule-effects/custom) effect with `type = 'externalPaymentMethod'`. The Kustom app maps these to KCO `external_payment_methods` entries. | Field | Required | Notes | |---|---|---| | `effect` | yes | Must be `'custom'`. | | `type` | yes | Must be `'externalPaymentMethod'`. | | `name` | yes | Method name. Must match a Kustom-supported name (e.g. `PayPal`), case-sensitive. | | `redirect_url` | yes | HTTPS page that owns purchase completion. | | `image_url` | no | HTTPS, exactly 69×24px. | | `description` | no | Up to 500 chars; Markdown links allowed. | | `fee` | no | Optional fee (minor units) added to the order. | ## Example: Offer PayPal on selected channels ```filtrera import 'iterators' param input: { hook: 'OnKustomExternalPaymentMethods' cartId: uuid channelKey: text currencyCode: text orderTotal: number systemHost: text checkoutUrl: text confirmationUrl: text } let paypalChannels = ['DE'] let isPaypalChannel = paypalChannels where c => c == input.channelKey count > 0 from isPaypalChannel match true |> [{ effect = 'custom' type = 'externalPaymentMethod' name = 'PayPal' redirect_url = $'https://{input.systemHost}/ingress/paypal/start?cartId={input.cartId}&confirmationUrl={input.confirmationUrl}&checkoutUrl={input.checkoutUrl}' }] |> [] ``` The `redirect_url` points at the [PayPal app](/official-apps/psp/paypal/)'s `start` ingress, which owns the rest of the flow. ## See Also * [PayPal app](/official-apps/psp/paypal/) * [`custom` effect](/resources/components/runtimes/rule-effects/custom) * [Kustom Commerce Integration](./commerce-integration) --- --- url: /official-apps/psp/kustom/checkboxes-hook.md --- # `OnKustomCheckboxes` hook Kustom Checkout can render **additional checkboxes** beneath the purchase button — for example a marketing-consent opt-in or a "create an account" toggle. The Kustom app fires the custom hook `OnKustomCheckboxes` while building the Kustom order in the [`checkout`](./commerce-integration#checkout-flow) ingress. Merchant rules listen to it and return the checkboxes to render for the current cart and channel. Unlike [external payment methods](./external-payment-methods-hook), checkboxes do not change the payment flow. They collect a boolean from the customer that is **read back on completion and stored on the created order** as a dynamic field, where downstream rules (e.g. a customer/CRM sync) can act on it. ## When It Fires Once per Kustom order creation, from the `checkout` ingress, before the order is sent to Kustom. The effects returned by listeners are added to the Kustom order's `merchant_requested.additional_checkboxes` array. If no listener returns a checkbox, the array is omitted. ## Hook Input ```filtrera { hook: 'OnKustomCheckboxes' cartId: uuid channelKey: text currencyCode: text orderTotal: number locale: text } ``` | Field | Notes | |---|---| | `cartId` | The cart being checked out. | | `channelKey` | Channel the cart belongs to — use this to scope which channels show which checkboxes. | | `currencyCode` | Order currency. | | `orderTotal` | Order total (in major units). | | `locale` | The cart locale (e.g. `sv-SE`), so the label can be localized. | ## Emitting a Checkbox Listeners emit a [`custom`](/resources/components/runtimes/rule-effects/custom) effect with `type = 'checkbox'`. The Kustom app maps these to KCO `additional_checkboxes` entries. | Field | Required | Notes | |---|---|---| | `effect` | yes | Must be `'custom'`. | | `type` | yes | Must be `'checkbox'`. | | `id` | yes | Stable identifier for the checkbox. Used to read the value back on completion. | | `name` | yes | The label shown to the customer. Localize this using the `locale` input. | | `checked` | no | Initial checked state. Defaults to `false`. | | `required` | no | Whether the customer must tick it to complete checkout. Defaults to `false`. | ## Reading the Value Back When the order completes, the Kustom app reads the customer's selection from the returned order's `merchant_requested.additional_checkboxes` (matched by `id`) and stores it on the created order as a dynamic field. A merchant rule listening on the order can then act on it — for example flipping marketing-consent flags on the customer record. ## Example: Marketing-consent opt-in ```filtrera import 'iterators' param input: { hook: 'OnKustomCheckboxes' cartId: uuid channelKey: text currencyCode: text orderTotal: number locale: text } let label = input.locale match 'da_DK' |> 'Ja tak, jeg vil gerne modtage nyheder og tilbud.' 'de_DE' |> 'Ja, ich möchte Neuigkeiten und Angebote erhalten.' 'fi_FI' |> 'Kyllä, haluan vastaanottaa uutisia ja tarjouksia.' 'fr_FR' |> 'Oui, je souhaite recevoir des actualités et des offres.' 'nb_NO' |> 'Ja takk, jeg vil motta nyheter og tilbud.' 'sv_SE' |> 'Ja tack, jag vill ta emot nyheter och erbjudanden.' |> 'Yes please, I would like to receive news and offers.' from [{ effect = 'custom' type = 'checkbox' id = 'marketingConsent' name = label checked = false required = false }] ``` On completion, the `marketingConsent` value is stored on the order. A separate order rule can read it and, when `true`, update the customer's marketing preferences. ## See Also * [`OnKustomExternalPaymentMethods` hook](./external-payment-methods-hook) * [`custom` effect](/resources/components/runtimes/rule-effects/custom) * [Kustom Commerce Integration](./commerce-integration) --- --- url: /official-apps/psp/kustom/customer-data-hook.md --- # `OnKustomCustomerData` hook Kustom Checkout collects more about the customer than a Hantera address can hold: the given/family name split, and an order-level `customer` block carrying type, gender, date of birth, organization registration id and VAT id. Hantera addresses carry a **single `name` field**. That is deliberate — it maps 1:1 onto company names, it is directly printable, and it means every consumer renders a name the same way. So when the Kustom app maps a Kustom address onto a Hantera address it joins `given_name` and `family_name` into that one field, and the rest of the customer block is dropped. That is the right call for the address. But some systems genuinely need the parts: CRM and marketing platforms model contacts as first/last, and some legacy carrier APIs demand a split. Re-deriving them later by splitting on whitespace is lossy — it mangles `van der Berg`, Spanish double surnames, and CJK name order. `OnKustomCustomerData` hands the original, unjoined data to merchant rules and lets them decide what — if anything — to keep. ::: tip Nothing is stored by default The Kustom app never writes this data on its own. With no listener, the hook returns nothing and no extra fields are written. This keeps personal data collection opt-in per tenant rather than a platform-wide default. ::: ## When It Fires Every time the Kustom app applies a Kustom address to a cart: | Path | Trigger | |---|---| | `confirm` ingress | Normal checkout completion | | `epm-handoff` ingress | Customer picked an external payment method (e.g. PayPal) | | `webhook` / reconcile | Push fallback and recovery/resync | | `address-update` ingress | Customer edits their address in the Kustom widget | The hook fires from the shared address-mapping helper, so all paths behave identically. The `address-update` path re-fires as the customer edits, so staged data cannot go stale mid-checkout. ## Hook Input ```filtrera { hook: 'OnKustomCustomerData' cartId: text customer: { type: 'person' | 'organization' gender?: 'male' | 'female' date_of_birth?: text organization_registration_id?: text vat_id?: text } billingParty: { given: text | nothing family: text | nothing title: text | nothing organizationName: text | nothing kind: text | nothing gender: text | nothing dateOfBirth: text | nothing organizationRegistrationId: text | nothing vatId: text | nothing } shippingParty: { /* same shape */ } } ``` | Field | Notes | |---|---| | `cartId` | The cart the address is being applied to. | | `customer` | Kustom's order-level customer block, verbatim. | | `billingParty` | Party attributes derived from the **billing** address. | | `shippingParty` | Party attributes derived from the **shipping** address. | The party record always has the same shape: anything Kustom did not return is `nothing` rather than absent, so listeners can match on it without guarding for missing keys. A `kind` of `organization` means `organizationName` is set and the name parts describe the contact person rather than the addressee. ### Check `kind` before using the name parts On a B2B order Kustom sends `organization_name` **and** the contact person's `given_name` / `family_name`. The Hantera address maps the company to `name` and the contact person to `attention`, which is correct — but it means the party's name parts describe *a different person than the addressee*. If your consumer treats name parts as the customer's own name — as the CRM app does, recomposing `name` from them — staging them for an organization would **replace the company name with the individual's**. Guard on `kind`: ```filtrera let isPerson = input.billingParty.kind match 'organization' |> false |> true ``` A company has no given/family name, so staging nothing is the honest answer. The contact person is still preserved on the address's `attention` field. ## Emitting Fields Listeners emit a [`custom`](/resources/components/runtimes/rule-effects/custom) effect with `type = 'orderField'`. Each effect stages **one dynamic field on the order** that the cart will become. | Field | Required | Notes | |---|---|---| | `effect` | yes | Must be `'custom'`. | | `type` | yes | Must be `'orderField'`. | | `key` | yes | Field name, **without** a prefix. Lands on the created order under this exact name. | | `value` | yes | The value to store. Any JSON-serializable Filtrera value. | ::: warning Return the key unprefixed The app adds commerce's `order:` [projection prefix](/official-apps/commerce/dynamic-fields) itself. Return `kustomParty`, not `order:kustomParty` — the latter becomes `order:order:kustomParty` and lands on the order under the wrong name. ::: The app owns the prefix deliberately. Only `order:`, `delivery:` and `field:` keys are projected onto the created order; anything else stays app-private cart state and is discarded at completion. Had listeners chosen the prefix, forgetting it would mean **silent data loss** — the value is written, looks correct on the cart, and vanishes when the order is created, with the symptom appearing in a different app entirely. It also means everything a listener writes is namespaced under `order:`, so it **cannot** reach the cart keys the app owns (`address`, `invoiceRecipient`, `email`, `phone`). ### Name your keys defensively Order dynamic fields are a namespace shared by every installed app, and keys arrive unprefixed. Use a vendor- or app-qualified name (`kustomParty`, `crm_nameParts`) rather than a generic one like `party` or `customer`, which another app may also write. ## Example: stage the name split for a CRM sync ```filtrera import 'iterators' param input: { hook: 'OnKustomCustomerData' cartId: text billingParty: { kind: text | nothing given: text | nothing family: text | nothing title: text | nothing } } // A company has no given/family name — see the note on `kind` above. let isPerson = input.billingParty.kind match 'organization' |> false |> true from isPerson match true |> [{ effect = 'custom' type = 'orderField' key = 'kustomParty' value = { given = input.billingParty.given family = input.billingParty.family title = input.billingParty.title } }] |> [] ``` Stage only the fields a consumer actually uses, as a single nested record. Keeping them in one record means that when the platform's order-locations model lands, the whole thing relocates onto the location's dynamic data unchanged. A CRM rule then reads it off the order and copies the parts onto the customer: ```filtrera let staged = order.dynamic->'kustomParty' let givenName = staged match { given: text } |> staged.given |> nothing ``` Parts are used **verbatim** — never derived by splitting a joined name. No general splitter is correct across compound surnames or CJK ordering, and for a company the concept does not apply. A customer with no parts simply keeps its `name`, which is already complete. A consumer that specifically requires a first/last split should derive one itself, using whatever rule suits its target system. ## See Also * [`OnKustomCheckboxes` hook](./checkboxes-hook) * [`OnKustomExternalPaymentMethods` hook](./external-payment-methods-hook) * [`OnKustomValidation` hook](./validation-hook) * [Cart Dynamic Fields](/official-apps/commerce/dynamic-fields) — Key prefixes and projection * [Kustom Commerce Integration](./commerce-integration) --- --- url: /official-apps/psp/paypal.md --- # PayPal Integrates the [Payment actor](/resources/actors/payment/) with PayPal. The core of the app is the **Payment-actor integration** — capturing and refunding PayPal payments and reconciling them onto the Hantera Payment via webhooks. The app also ships with a self-contained, redirect-based checkout flow (`start` → PayPal approval → `return`). It's a convenience entry point that any redirect-capable checkout can drive, and it works particularly well as a **Kustom External Payment Method (EPM)** — Kustom collects the address and redirects the customer to the `start` ingress, then the PayPal app owns the rest of the completion flow (there is **no postback from Kustom** for external payments). **Key features:** * Payment-actor capture & refund driven by the `OnPaymentCapture` rule hook * Unified settlement: a single `settle` rule both **captures** outstanding authorizations and **refunds** over-charged captures, based on the capture's remaining balance * Idempotent webhook receiver with PayPal signature verification that reconciles dashboard-initiated actions (manual captures, refunds, voids) back onto the Hantera Payment * Manual **Sync from PayPal** button in the portal order view as a fallback for missed or late webhook deliveries * Optional redirect-driven checkout (`start` → PayPal approval → `return`) that creates the Payment, links it to the cart, completes the cart, and returns the customer to the channel confirmation page — `intent=AUTHORIZE`, captured later ## Checkout Flow ```mermaid sequenceDiagram participant Browser participant Kustom participant Start as start ingress participant PayPal participant Return as return ingress participant Cart participant Payment Browser->>Kustom: Select "PayPal" (external payment method) Kustom-->>Browser: Redirect to start ingress Browser->>Start: GET /paypal/start?cartId&confirmationUrl Start->>PayPal: POST /v2/checkout/orders (intent=AUTHORIZE) PayPal-->>Start: order id + approval link Start-->>Browser: 302 to PayPal approval Browser->>PayPal: Approve payment PayPal-->>Browser: Redirect to return ingress Browser->>Return: GET /paypal/return?cartId&token&confirmationUrl Return->>PayPal: POST /v2/checkout/orders/{id}/authorize Return->>Payment: Create payment + authorization Return->>Cart: Link payment + complete Return-->>Browser: 302 to confirmation page ``` The confirmation page is responsible for detecting the order's payment type and rendering accordingly. ## Settlement (Capture & Refund) PayPal Orders v2 is a hierarchy of resources, each with its own id: * **Order** — the checkout session the customer approves. * **Authorization** — a hold on the funds (created at `return`). Needed to **capture**. * **Capture** — the actual movement of money. Needed to **refund**. * **Refund** — issued against a specific capture. The `settle` rule listens on [`OnPaymentCapture`](/resources/components/runtimes/rule-hooks/onPaymentCapture). When a capture is pending it inspects the remaining balance: ```mermaid sequenceDiagram participant Platform participant Settle as settle rule participant Capture as capturePayment job participant Refund as refundPayment job participant PayPal Platform->>Settle: OnPaymentCapture (providerKey=paypal) alt remaining > 0 (capture) Settle->>Capture: scheduleJob(capturePayment) Capture->>PayPal: POST /v2/payments/authorizations/{authId}/capture Capture->>Platform: record charge (capture id) else remaining < 0 (refund) Settle->>Refund: scheduleJob(refundPayment) Refund->>PayPal: POST /v2/payments/captures/{captureId}/refund Refund->>Platform: record refund (refund id) end ``` Because refunds are per-capture, the refund job walks the payment's `charge` journal entries newest-first to find the capture ids to refund against. ### PayPal ↔ Hantera mapping | PayPal | Hantera Payment | | --- | --- | | Order id | `externalReference` | | Authorization id | authorization `authorizationNumber` | | Capture id | charge journal `transactionReference` | | Refund id | refund journal `transactionReference` | ## Settings | Setting | Secret | Description | | --- | --- | --- | | `clientId` | no | PayPal REST app Client ID | | `clientSecret` | yes | PayPal REST app Client Secret | | `environment` | no | `sandbox` or `live` | | `webhookId` | yes | PayPal webhook ID. Required for signature verification on incoming webhooks; without it every webhook is rejected with `INVALID_SIGNATURE` and dashboard-initiated changes won't sync back. | ## Webhook configuration The webhook ingress is what keeps the Hantera Payment in sync when an action happens outside Hantera — most commonly when a backoffice agent captures or refunds the transaction directly in the PayPal dashboard. Configuring it is a one-time setup in two places. ### 1. In the PayPal Developer dashboard 1. Go to [PayPal Developer → Apps & Credentials](https://developer.paypal.com/dashboard/applications) and select the REST app whose Client ID / Secret you used for the `clientId` / `clientSecret` settings. Make sure the **Sandbox / Live** toggle matches the `environment` setting. 2. Scroll to **Webhooks** on the app's page and click **Add Webhook**. 3. Set the **Webhook URL** to: ``` https:///ingress/paypal/webhook ``` 4. Subscribe to the following event types (the ingress reconciles these by re-fetching the parent order from PayPal; other event types are accepted and logged but otherwise ignored): * `Checkout order approved` — `CHECKOUT.ORDER.APPROVED` * `Checkout order completed` — `CHECKOUT.ORDER.COMPLETED` * `Payment authorization created` — `PAYMENT.AUTHORIZATION.CREATED` * `Payment authorization voided` — `PAYMENT.AUTHORIZATION.VOIDED` * `Payment capture completed` — `PAYMENT.CAPTURE.COMPLETED` * `Payment capture denied` — `PAYMENT.CAPTURE.DENIED` * `Payment capture pending` — `PAYMENT.CAPTURE.PENDING` * `Payment capture refunded` — `PAYMENT.CAPTURE.REFUNDED` * `Payment capture reversed` — `PAYMENT.CAPTURE.REVERSED` 5. Save the webhook. PayPal generates a short opaque **Webhook ID** for the subscription — copy it. ### 2. In Hantera Paste the Webhook ID into the app's `webhookId` setting. On every incoming webhook the ingress POSTs the transmission headers and raw body to PayPal's `/v1/notifications/verify-webhook-signature` endpoint, which checks them against this ID; verified events are forwarded to the `syncPayment` job and unverified ones are rejected. ### What the webhook does ```mermaid sequenceDiagram participant PayPal participant Webhook as webhook ingress participant Sync as syncPayment job participant Payment PayPal->>Webhook: POST /paypal/webhook (signed event) Webhook->>PayPal: POST /v1/notifications/verify-webhook-signature PayPal-->>Webhook: SUCCESS Webhook->>Sync: scheduleJob({ externalReference: orderId }) Sync->>PayPal: GET /v2/checkout/orders/{id} Sync->>Payment: record missing captures / refunds
(charge / refund commands, idempotent) ``` The webhook extracts the parent PayPal Order id from the event resource and delegates to the `syncPayment` job. That job fetches the authoritative state from PayPal and reconciles it onto the Hantera Payment — a single code path that also powers the manual "Sync from PayPal" portal button. ## Manual sync The portal renders a **Sync from PayPal** button on every PayPal payment in the order view. It schedules the same `syncPayment` job the webhook uses and is the canonical recovery path when webhook delivery missed an event — for example a transient PayPal outage, an event raised before the webhook was configured, or an agent performing a refund inside the PayPal dashboard before signature verification was set up. Sync only ever **adds** missing journal entries and updates the authorization state. It never removes or reverses an existing entry, so running it repeatedly is safe. ## Using PayPal inside Kustom The Kustom app exposes the [`OnKustomExternalPaymentMethods`](/official-apps/psp/kustom/external-payment-methods-hook) hook. A merchant rule returns a `custom` effect whose `redirect_url` points at this app's `start` ingress for the channels that should offer PayPal. ## See Also * [Kustom — External Payment Methods hook](/official-apps/psp/kustom/external-payment-methods-hook) * [Commerce PSP Integration](/official-apps/commerce/psp-integration) * [Payment Actor](/resources/actors/payment/) --- --- url: /official-apps/shipping.md --- # Shipping Providers Shipping Provider apps integrate Hantera's delivery model with external carrier and checkout-options providers. Each shipping app: * Calls the provider's API to fetch the **shipping options** (carrier products, delivery methods, pickup points) available for a given delivery. * Implements the standard portal [`shippingProductsService`](/resources/apps/portal-extensions) so the **Select shipping product** flow in the order view returns provider-specific options. * Exposes a reusable Filtrera module so other apps — for example a Commerce ↔ checkout-iframe bridge — can fetch options from their own ingresses without duplicating the provider integration. Provider-specific configuration (API credentials, environments) is exposed via app settings, and per-channel configuration (e.g. an account or configuration id) is exposed as fields on the channel via a channel-editor extension. ## Available Shipping Apps ### [nShift Checkout](./nshift-checkout/) Fetches shipping options from [nShift Checkout](https://www.nshift.com/products/checkout). Owns OAuth token acquisition/caching and session creation; consumers only call the module's [`fetchShippingOptions`](./nshift-checkout/shipping-module) entry point. **Key features:** * Public Filtrera module ([`nshift.module.hrc`](./nshift-checkout/shipping-module)) other apps can import. * Portal [`shippingProductsService`](/resources/apps/portal-extensions) implementation backed by a private ingress. * Channel-editor extension for the per-channel `nshiftCheckoutConfigurationId`. * [`OnNShiftCheckoutVariables`](./nshift-checkout/variables-hook) custom hook — let merchant rules contribute the provider-specific `variables` record on every lookup. --- --- url: /official-apps/shipping/nshift-checkout.md --- # nShift Checkout Connects [nShift Checkout](https://www.nshift.com/products/checkout) to Hantera as a source of shipping options. Once installed and configured, portal users see real nShift carrier products when they click **Select shipping product** on a delivery. The app is a shipping-options provider only — it does not render a checkout UI and does not process payments. Combine it with a [Payment Provider](/official-apps/psp/) like [Kustom](/official-apps/psp/kustom/) for a complete cart → shipping → payment flow. ## Before you start You need a nShift Checkout account with: * A **client id** and **client secret** for OAuth (under nShift's API credentials). * One or more **checkout connection ids** — one per Hantera channel that should use nShift. If you don't have these yet, contact your nShift representative. ## Configure your credentials 1. Install the nShift Checkout app on your Hantera instance. 2. Open the app's settings in the portal. 3. Paste in your credentials: | Setting | Description | | -------------- | ------------------------------------------ | | `clientId` | nShift OAuth client id. | | `clientSecret` | nShift OAuth client secret (kept secret). | The app handles OAuth tokens automatically — there is no separate refresh step to schedule. ## Configure each channel Each Hantera channel that should use nShift needs its own nShift checkout connection id. 1. Open the channel editor in the portal for the channel you want to set up. 2. Set **nShift Checkout connection id** to the connection id from the nShift portal. 3. Save the channel. Repeat for every channel that uses nShift. Channels without a connection id won't return any nShift options. ## Try it out 1. Open any order in a channel that has nShift configured. 2. On any delivery, click **Select shipping product**. 3. nShift's options for that delivery's address appear in the picker. ## Customize the request with rules nShift Checkout configurations are driven by an open set of **variables** — provider-specific key/value pairs like `fromInventory` or `vipCustomer`. Which variables you need depends entirely on how your nShift checkout configuration is built. The app fires the [`OnNShiftCheckoutVariables`](./variables-hook) custom hook every time it builds a request to nShift. Add merchant rules to that hook to feed in whatever variables your configuration expects — including dynamic values from the delivery, order, or channel. See [`OnNShiftCheckoutVariables` Hook](./variables-hook) for the hook contract and worked examples. ## Set shipping tax with rules Each shipping option needs a tax rate so the delivery's `shippingTax` is correct once an option is selected. The app uses nShift's own tax rate when available and otherwise falls back to the highest order-line tax factor. When that isn't enough — for example a flat rate for a tax jurisdiction nShift doesn't price, or a carrier-specific override — add merchant rules to the [`OnNShiftCheckoutShippingOptionTax`](./shipping-option-tax-hook) custom hook to compute per-option shipping tax yourself. See [`OnNShiftCheckoutShippingOptionTax` Hook](./shipping-option-tax-hook) for the hook contract and worked examples. ## Integrate from another app If you're building another Hantera app that needs shipping quotes from nShift — for example a storefront-checkout bridge that renders nShift options inside an iframe — you can import the public `getOptions` function from this app's Filtrera module. See the [Shipping Module reference](./shipping-module) for the import path, types, and the [`requires.modules`](/resources/apps/declaring-dependencies) declaration you need to add to your app. ## See Also * [`OnNShiftCheckoutVariables` Hook](./variables-hook) — Customize the request with merchant rules. * [`OnNShiftCheckoutShippingOptionTax` Hook](./shipping-option-tax-hook) — Set per-option shipping tax with merchant rules. * [Shipping Module reference](./shipping-module) — Use the app from another Hantera app. --- --- url: /official-apps/shipping/nshift-checkout/shipping-module.md --- # nShift Checkout — Shipping Module If you're building a Hantera app that needs nShift shipping options on its own ingress — for example a storefront-checkout bridge that renders carrier products inside a third-party checkout iframe — you can import the public `getOptions` function from the nShift Checkout app's Filtrera module. OAuth tokens, nShift sessions, and per-session caching are handled for you behind a session ticket actor. You call one function, get back options, render them, and on selection write two delivery dynamic fields — the app's rules take it from there. ## Module path ``` apps/nshift-checkout/nshift.module.hrc ``` ## Declaring the dependency A consumer app cannot import this module by path alone. Declare a `requires.modules` entry in your `h_app.yaml`, naming the module path and the export type you use. The declaration is type-checked against the producer's real source when your app is activated. ```yaml requires: modules: apps/nshift-checkout/nshift.module.hrc: exports: getOptions: type: >- (sessionActorId: uuid | nothing, request: { totalPrice: number, totalVolumeCm3: number, totalWeightKg: number, localeId: text, currencyCode: text, languageCode: text, packages: [value], variables: { text -> value }, receiver: { name: text, address1: text, postalCode: text, city: text, country: text, phone: text | nothing, email: text | nothing } }, context: { delivery: { deliveryId: uuid | nothing, deliveryAddress: value, dynamic: { text -> value }, lines: [{ orderLineId: uuid | nothing, productNumber: text | nothing, quantity: number, dynamic: { text -> value }, taxFactor: number | nothing }], order: { orderId: uuid | nothing, channelKey: text, currencyCode: text, locale: text | nothing, dynamic: { text -> value } } } } | nothing) => { sessionActorId: uuid, options: [{ optionId: text, sessionActorId: uuid, shippingProductNumber: text, carrierId: text, carrierProductId: text, name: text, title: text | nothing, price: number, originalPrice: number | nothing, shippingTax: number | nothing, shippingTaxFactor: number | nothing, priceDescription: text | nothing, logoUrl: text | nothing, texts: [text] | nothing, valid: bool | nothing, additionalValues: { text -> value } | nothing, pickupPoints: [value] | nothing, raw: { text -> value } }] } | { error: { code: text, message: text, details: value } } ``` ::: tip Inline the record shapes Cross-app module contracts don't support named-type aliasing. Inline the request, context, and option record shapes in the `requires.modules` block — as above — rather than referencing `NShiftCheckoutShippingRequest` or `NShiftCheckoutVariablesContext` by name. The producer's real source is the source of truth and is type-checked against your declaration at activation. ::: See [Declaring App Dependencies](/resources/apps/declaring-dependencies) for the surrounding context. ## Importing ```filtrera import { getOptions } from 'apps/nshift-checkout/nshift.module.hrc' ``` ## `getOptions(sessionActorId, request, context)` The single function you call. ```filtrera getOptions( sessionActorId: uuid | nothing, request: NShiftCheckoutShippingRequest, context: NShiftCheckoutVariablesContext | nothing ) => NShiftCheckoutOptionsResult | { error: { code: text, message: text, details: value } } ``` | Parameter | Type | Description | | ---------------- | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `sessionActorId` | `uuid \| nothing` | The id of an existing `nShiftCheckoutSession` ticket, if any. Pass `nothing` on the first call and the value returned by a previous call on subsequent calls. | | `request` | `NShiftCheckoutShippingRequest` | The lookup request — totals, locale, receiver, and provider-specific variables. Must include `channelKey` (or `nshiftCheckoutConnectionId`) in `variables`. See below. | | `context` | `NShiftCheckoutVariablesContext \| nothing` | The delivery-rooted context the hooks operate on. See [Context](#context). | ### Context `context` is the same delivery-rooted record consumed by the [`OnNShiftCheckoutVariables`](./variables-hook) hook (delivery + its order + order lines, each with `dynamic`). It is used internally for two things: 1. **Variable resolution.** `getOptions` fires `OnNShiftCheckoutVariables` with this context, collects listener-emitted variables, and seeds `channelKey`. Any keys you pass in `request.variables` win over hook-resolved values for the same key. 2. **Shipping-tax resolution.** `getOptions` fires the [`OnNShiftCheckoutShippingOptionTax`](./shipping-option-tax-hook) hook with this context plus the freshly fetched options, then stamps each option with the resolved `shippingTax` / `shippingTaxFactor`. `context` is **optional** to keep the surface forward-compatible. Pass it whenever you have one. If you pass `nothing`: * The variables hook does **not** fire (only `request.variables` you supply directly are used). * The shipping-tax hook does **not** fire, and options come back with `shippingTax` and `shippingTaxFactor` both `nothing`. Both `delivery.deliveryId` and `delivery.order.orderId` are `uuid | nothing`, to allow fetching options before an order is creation, for example for a cart/checkout scenario. Returns either an `NShiftCheckoutOptionsResult` on success or an error record on failure. See [Errors](#errors). The function decides whether to create a new nShift session, refresh an existing one in place, or simply return previously cached options based on the supplied `sessionActorId` and a fingerprint of `request`. Consumers don't need to think about the underlying session-ticket model. ## Resolving the connection The module resolves the nShift checkout connection id from one of two places, in order: 1. **Direct override**: `request.variables.nshiftCheckoutConnectionId` (text). 2. **Channel registry lookup**: if the request includes `request.variables.channelKey`, the module reads `channels/{channelKey}.nshiftCheckoutConnectionId`. A storefront-facing consumer typically passes `channelKey` and lets the channel registry resolve the connection. A test or admin caller may pass `nshiftCheckoutConnectionId` directly. If neither resolves to a connection id, the function returns the `NSHIFT_NO_CHECKOUT_CONNECTION` error. ## Request ```filtrera { totalPrice: number totalVolumeCm3: number totalWeightKg: number localeId: text currencyCode: text languageCode: text packages: [value] variables: { text -> value } receiver: { name: text address1: text postalCode: text city: text country: text phone: text | nothing email: text | nothing } } ``` | Field | Description | | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `totalPrice` | Cart/delivery total used by nShift's freemium / threshold rules. Pass `0` if not applicable. | | `totalVolumeCm3` | Total volume in cm³. Pass `0` if you don't track volume. | | `totalWeightKg` | Total weight in kg. Pass `0` if you don't track weight. | | `localeId` | Locale identifier (e.g. `sv-SE`). nShift uses it for translated option names and currency/decimal formatting. | | `currencyCode` | ISO 4217 currency code. | | `languageCode` | ISO 639-1 language code (e.g. `sv`). Usually the first segment of `localeId`. | | `packages` | Per-package details (dimensions, content classification). Provider-defined; pass `[]` if not used. | | `variables` | Provider-specific variables (e.g. `fromwarehouse`). Must include `channelKey` or `nshiftCheckoutConnectionId`. See [`OnNShiftCheckoutVariables`](./variables-hook). | | `receiver` | Destination address. `phone` and `email` are optional. | ## Successful result ```filtrera { sessionActorId: uuid options: [NShiftCheckoutOption] } ``` | Field | Description | | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `sessionActorId` | The id of the `nShiftCheckoutSession` ticket actor that backs this set of options. Pass it on the next call to `getOptions` to reuse or refresh the session efficiently. | | `options` | The available options, in the order returned by nShift. Each option also carries `sessionActorId` so a UI can pass the two selection keys forward atomically. | ### `NShiftCheckoutOption` ```filtrera { optionId: text sessionActorId: uuid shippingProductNumber: text carrierId: text carrierProductId: text name: text title: text | nothing price: number originalPrice: number | nothing shippingTax: number | nothing shippingTaxFactor: number | nothing priceDescription: text | nothing logoUrl: text | nothing texts: [text] | nothing valid: bool | nothing additionalValues: { text -> value } | nothing pickupPoints: [value] | nothing raw: { text -> value } } ``` | Field | Description | | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | | `optionId` | nShift's own unique id for this option **within the session**. This is the selection key — pair it with `sessionActorId` to select. | | `sessionActorId` | Repeats the result-level `sessionActorId` per option so it can ride along in a single UI blob. | | `shippingProductNumber` | Stable id formed as `-`. Suitable for display or as a public-facing product number. | | `carrierId` | nShift carrier id. | | `carrierProductId` | nShift carrier product id within the carrier. | | `name` | Display name (localized to `localeId`). | | `title` | Alternate display title, when nShift provides one. | | `price` | Effective price. | | `originalPrice` | Pre-discount price, when the option is on offer. | | `shippingTax` | Absolute shipping tax amount in the request's currency, when resolved. Takes precedence over `shippingTaxFactor`. See below. | | `shippingTaxFactor` | Shipping tax as a factor (e.g. `0.25` for 25%), when resolved. See below. | | `priceDescription` | Short price annotation (e.g. *Free over 500 SEK*). | | `logoUrl` | Carrier logo URL. | | `texts` | Additional descriptive lines. | | `valid` | Whether nShift considers the option currently valid for this session. | | `additionalValues` | Provider-specific extra fields. | | `pickupPoints` | Pickup points for parcel-shop options. Pass through verbatim; surface a picker if your carrier requires one. | | `raw` | The unmodified option record from nShift. Useful when you need a field this type doesn't expose. | #### Shipping tax resolution `shippingTax` and `shippingTaxFactor` are mutually exclusive: at most one is set on a given option, and both may be `nothing` when no rate or amount could be determined. `shippingTax` is an absolute amount and takes precedence over `shippingTaxFactor` (a factor in `[0, 1]`). For each option, `getOptions` resolves the effective tax in this order: 1. **nShift's own `taxRate`** (when present in the option payload) — used as `shippingTaxFactor`. 2. **`OnNShiftCheckoutShippingOptionTax` hook** emission for this `optionId` — an emitted `shippingTax` wins over a `shippingTaxFactor`. 3. **Line-derived default** — `shippingTaxFactor = max(taxFactor)` across the context's order lines. Skipped when no line carries a positive `taxFactor`. 4. **Otherwise** — both fields stay `nothing`. See [`OnNShiftCheckoutShippingOptionTax` Hook](./shipping-option-tax-hook) for how to compute custom rates. Because the hook only fires when a `context` is supplied, options come back with both fields `nothing` if you call `getOptions` with `context = nothing`. ## Selection Selection is **not** a separate module function. To select an option, write two fields onto the relevant delivery's dynamic fields (the field names are the contract): ```filtrera { nShiftCheckoutSessionActorId = nShiftCheckoutOptionId = } ``` When those two fields land on a delivery (typically because the portal's select-product flow ran, or because commerce projected them from the cart's `delivery:`-prefixed [dynamic fields](/official-apps/commerce/dynamic-fields) during cart-to-order conversion), the app's bridge rule: 1. Records the selection on the session ticket and completes it. 2. The session's `OnTicketComplete` rule then writes the rich option fields onto the delivery (`shippingProductNumber`, `shippingPrice`, `shippingDescription`, carrier metadata) and clears the two selection keys. 3. The same rule schedules the `createPartialShipment` job, which dispatches the shipment to nShift after commit. Optional fields the bridge will forward to the session ticket if present alongside the two selection keys: | Field | Type | Used for | | ------------------------------ | ------ | ------------------------------------------------------------------------------------- | | `nShiftCheckoutPickupPointId` | `text` | Carriers that require a pickup point. If omitted, the shipment job falls back to the first pickup point on the selected option, if any. | | `nShiftCheckoutTimeSlotId` | `text` | Carriers that support time slots. | ## Errors On any failure, the function returns an error record of shape `{ error: { code, message, details } }`. Handle it in your caller — don't assume the result is always a successful one. | `code` | When it happens | | ------------------------------- | -------------------------------------------------------------------------------------------- | | `NSHIFT_NO_CLIENT_ID` | The app's `clientId` is unset — finish setup before calling. | | `NSHIFT_NO_CLIENT_SECRET` | The app's `clientSecret` is unset. | | `NSHIFT_NO_CHECKOUT_CONNECTION` | No checkout connection id could be resolved from `request.variables` or the channel registry. | | `NSHIFT_TOKEN_FAILED` | OAuth token request failed (network error or non-200 from nShift). | | `NSHIFT_SESSION_FAILED` | Session creation returned a non-200 or an unexpected payload from nShift. | | `NSHIFT_OPTIONS_FAILED` | Shipping-options request returned a non-200 or an unexpected payload from nShift. | | `NSHIFT_TICKET_WRITE_FAILED` | Failed to persist the session ticket (e.g. transient platform error). | `details` carries the underlying response when available — useful when surfacing or logging the failure. ## Example: a storefront-facing ingress A typical pattern is to expose nShift as a small HTTP ingress your storefront posts to. With **structured body parsing** the host parses the JSON body and binds each top-level field to a matching `param` of the declared type — so you don't need to deserialize manually. See [HTTP Ingress Parameters](/resources/ingresses/http/parameters) for the full mechanism. ```filtrera import 'text' import { getOptions } from 'apps/nshift-checkout/nshift.module.hrc' param channelKey: text param sessionActorId: uuid | nothing param currencyCode: text param localeId: text param totalPrice: number param receiver: { name: text address1: text postalCode: text city: text country: text phone: text | nothing email: text | nothing } let languageCode = localeId explode '-' first match (l: text) |> l |> localeId // Inject `channelKey` so the module resolves the checkout connection id // from the channel registry. let request = { totalPrice = totalPrice totalVolumeCm3 = 0 totalWeightKg = 0 localeId = localeId currencyCode = currencyCode languageCode = languageCode packages = [] variables = { channelKey = channelKey } receiver = receiver } // A pre-order storefront flow has no delivery yet, so pass `nothing` for // context. The OnNShiftCheckoutVariables and OnNShiftCheckoutShippingOptionTax // hooks don't fire, and options come back without shipping tax. If you can // build a delivery-rooted context (see the variables-hook page), pass it here // to enable merchant variable rules and per-option shipping tax. let result = getOptions(sessionActorId, request, nothing) from result match { error: { code: text } } |> { statusCode = 502 content = result.error } { sessionActorId: uuid, options: [value] } |> { statusCode = 200 content = { sessionActorId = result.sessionActorId options = result.options select o => { id = o.optionId sessionActorId = o.sessionActorId name = o.name price = o.price carrierId = o.carrierId productId = o.carrierProductId } } } ``` To select an option, your storefront writes the two `nShiftCheckout*` keys onto the cart as separate `delivery:`-prefixed [dynamic fields](/official-apps/commerce/dynamic-fields) — so they ride along to the delivery at cart-to-order conversion — or directly onto a portal-style delivery's `dynamic`. No separate module call is required. ```filtrera from [{ type = 'setDynamicFields' fields = { 'delivery:nShiftCheckoutSessionActorId' -> sessionActorId 'delivery:nShiftCheckoutOptionId' -> optionId } }] ``` ## See Also * [nShift Checkout overview](./) — Set up the app and configure channels. * [`OnNShiftCheckoutVariables` Hook](./variables-hook) — Customize the request via merchant rules. Fires on every call to `getOptions` that supplies a context. * [`OnNShiftCheckoutShippingOptionTax` Hook](./shipping-option-tax-hook) — Compute per-option shipping tax. * [Declaring App Dependencies](/resources/apps/declaring-dependencies) --- --- url: /official-apps/shipping/nshift-checkout/variables-hook.md --- # `OnNShiftCheckoutVariables` Hook nShift Checkout configurations are driven by an open set of **variables** — provider-specific key/value pairs interpreted by your checkout configuration on nShift's side. Every nShift account uses a different set: `fromwarehouse`, `vipCustomer`, `isSplit`, and so on. Rather than hard-coding any of this, the nShift Checkout app fires the `OnNShiftCheckoutVariables` hook every time it builds a request to nShift. Add merchant rules to that hook to feed in whatever variables your configuration expects. The hook fires on every shipping-product lookup the app makes for a delivery — including the **Select shipping product** action in the order view. Write your rules once and they apply to every lookup. The resolved variables are stored on the `nShiftCheckoutSession` ticket actor that backs the lookup, so you can inspect "what variables were in play when this session was created" from the portal — useful for debugging merchant-rule behaviour. ## Listening to the hook Declare a rule with a `param input` that matches `OnNShiftCheckoutVariables`. The hook is fired with a delivery-rooted outline of the data the app already loaded — delivery + its order + its order lines, with `dynamic` on each. Only declare the fields your rule reads; Filtrera's type system narrows hook matching to rules whose declared shape is satisfied. ```filtrera param input: { hook: 'OnNShiftCheckoutVariables' delivery: { deliveryId: uuid | nothing deliveryAddress: { name: text | nothing careOf: text | nothing attention: text | nothing addressLine1: text | nothing addressLine2: text | nothing city: text | nothing state: text | nothing postalCode: text | nothing countryCode: text | nothing email: text | nothing phone: text | nothing } dynamic: { text -> value } lines: [{ orderLineId: uuid | nothing productNumber: text | nothing quantity: number dynamic: { text -> value } taxFactor: number | nothing }] order: { orderId: uuid | nothing channelKey: text currencyCode: text locale: text | nothing dynamic: { text -> value } } } } ``` | Field | Description | | ----------------------------- | ------------------------------------------------------------------------------------------------------------ | | `delivery.deliveryId` | The delivery being quoted, or `nothing` in a cart-rooted (pre-order) flow. | | `delivery.deliveryAddress` | The destination address (graph `Address` shape). | | `delivery.dynamic` | Dynamic fields on the delivery (e.g. `inventoryKey`, route tags). Read whatever your apps have stored there. | | `delivery.lines` | The delivery's order lines, carrying Hantera-native system fields only. Read product attributes (weight, dimensions, item class) from `line.dynamic` after their owning app has projected them there. | | `delivery.lines[].taxFactor` | The line's tax factor (e.g. `0.25`), when known. Drives the default shipping-tax behaviour — see [`OnNShiftCheckoutShippingOptionTax`](./shipping-option-tax-hook). | | `delivery.order.orderId` | The order the delivery belongs to, or `nothing` in a cart-rooted (pre-order) flow. | | `delivery.order.channelKey` | Channel of the order. | | `delivery.order.currencyCode` | Currency of the order. | | `delivery.order.locale` | Locale of the order (may be `nothing`). | | `delivery.order.dynamic` | Dynamic fields on the order. | ::: warning Ids can be `nothing` Both `delivery.deliveryId` and `delivery.order.orderId` are `uuid | nothing`. A portal-rooted lookup (the **Select shipping product** action) has both ids; a cart-rooted (pre-order) lookup has neither, because no delivery or order exists yet. Guard for `nothing` before querying the graph from these ids. ::: ## Emitting a variable A rule contributes one variable per `custom/variable` effect it emits: ```filtrera from { effect = 'custom' type = 'variable' key = 'fromwarehouse' value = 'HQ' } ``` | Field | Description | | -------- | -------------------------------------------------------------------------------------- | | `effect` | Must be `'custom'`. | | `type` | Must be `'variable'`. The hook helper only collects variables; other effects are ignored. | | `key` | Variable key as defined by your nShift checkout configuration. | | `value` | Variable value. Usually `text`, but any JSON-serializable Filtrera value works. | If multiple rules contribute the same key, the **last** effect emitted wins. This makes it easy to layer a default rule with a more specific override. The app also pre-seeds `channelKey` from the order, which your rules can override. ## Example: per-channel warehouse A merchant operating two storefronts wants nShift to ship from a different warehouse per channel. ```filtrera param input: { hook: 'OnNShiftCheckoutVariables' delivery: { order: { channelKey: text } } } let warehouseByChannel = { 'retail-se' -> 'WH-SE' 'retail-no' -> 'WH-NO' } from warehouseByChannel->input.delivery.order.channelKey match (warehouse: text) |> { effect = 'custom' type = 'variable' key = 'fromwarehouse' value = warehouse } ``` ## Example: VIP customers get free-shipping variants Assume the tenant has added a custom `customer` edge from `order` (a common pattern). Read the customer's segment and set a `vipCustomer` variable that your nShift configuration uses to switch on free-shipping carrier products. ```filtrera param input: { hook: 'OnNShiftCheckoutVariables' delivery: { order: { orderId: uuid | nothing } } } from input.delivery.order.orderId match (orderId: uuid) |> let q = query orders(orderId) navigate customer(customerSegment) filter $'orderId == {orderId}' let segment = q first match { customer: { customerSegment: text } } |> q first.customer.customerSegment from segment match 'vip' |> { effect = 'custom' type = 'variable' key = 'vipCustomer' value = '1' } ``` ## Example: warehouse from inventory routing When the [inventory-routing](/official-apps/inventory-routing/) app splits an order across warehouses, the delivery's `inventoryKey` dynamic field carries the actual fulfillment warehouse. Forward it to nShift as `fromwarehouse` — no graph query needed since `delivery.dynamic` is already in the hook input. ```filtrera param input: { hook: 'OnNShiftCheckoutVariables' delivery: { dynamic: { text -> value } } } from input.delivery.dynamic->'inventoryKey' match (k: text) |> { effect = 'custom' type = 'variable' key = 'fromwarehouse' value = k } ``` ## See Also * [nShift Checkout overview](./) — Set up the app and configure channels. * [Shipping Module reference](./shipping-module) — `getOptions`, the request shape, and the `context` parameter. * [`OnNShiftCheckoutShippingOptionTax` Hook](./shipping-option-tax-hook) — Compute per-option shipping tax from the same context. * [Custom Hooks](/resources/rules/trigger-hook) — How custom hooks work. --- --- url: /official-apps/tax.md --- # Tax Providers A family of apps that calculate transaction tax on Hantera orders using an external tax engine, and — where the provider supports it — record the resulting documents for filing and audit. Tax providers plug into the order pipeline rather than replacing it. Hantera's native tax behaviour (a `taxFactor` percentage per order line and delivery) keeps working for everything a tax app doesn't cover: destinations outside its enabled countries, channels it isn't enabled for, and any period where the app is switched off. ## Available tax apps * **[Avalara AvaTax](./avatax/)** — US sales tax and Canadian GST/HST/PST/QST via Avalara's AvaTax service. Calculates tax on carts and orders, records `SalesInvoice` and `ReturnInvoice` documents from Hantera invoices, voids them on cancellation, and ships portal tooling for tax-code lookup, entity/use codes, and address validation. ## How a tax app participates in an order Every tax app in this family follows the same three conventions. They are deliberately vendor-neutral — nothing in Hantera core or in the Commerce app knows about a specific tax provider. ### 1. Absolute tax amounts, not rates A tax app writes the exact amounts the tax engine returned: * `setOrderLineTax { orderLineId, salesTax }` per order line * `setShippingTax { deliveryId, tax }` per delivery These are absolute currency amounts. Setting `salesTax` clears any `taxFactor` on the line, so the engine's figure is what appears on invoices — the app never re-derives tax from a percentage. This matters for jurisdictions where tax isn't a clean percentage of the line: US sales tax combines state, county, city and special-district rates, and rounding is per jurisdiction. ### 2. The `taxChecksum` order field External tax engines charge per call and rate-limit aggressively, so a tax app must not call out on every order render. The convention is a single order dynamic field: | Field | Type | Meaning | |---|---|---| | `taxChecksum` | `text` | An opaque digest of the tax-relevant inputs as they stood when tax was last calculated. | On each order calculation the app recomputes the digest from the current inputs (lines, quantities, amounts, tax codes, addresses, customer, origin) and compares: * **Same** — the tax already on the order is up to date. No call. * **Different** — something tax-relevant changed. One call, then store the new digest. Only the tax app computes or interprets the value. Everything else treats it as opaque and simply copies it along. ### 3. Cart tax persistence (Commerce) The [Commerce app](/official-apps/commerce/) stores calculated tax on the cart and replays it into every render, so a storefront can render a cart repeatedly for free: 1. Commerce persists `taxData` on the cart — per-item tax, shipping tax, and the `taxChecksum`. 2. Cart-to-order replays it onto the new order's lines and deliveries, along with the checksum. 3. The tax app recomputes the checksum, finds it unchanged, and makes no call. 4. When something tax-relevant *does* change, the app recalculates and Commerce writes the fresh values back onto the cart. The net effect is **one call per tax-relevant mutation and zero per render**. See [Cart Dynamic Fields](/official-apps/commerce/dynamic-fields) for the stored shape. ::: tip Without Commerce The checksum gate works on its own — it's a property of the tax app and the order, not of Commerce. Without the cart persistence layer you simply lose the free-render optimisation. ::: ## Failure behaviour Tax apps in this family never block an order. If the tax engine is unreachable or rejects a request: * The order keeps whatever tax it already had (or falls back to native `taxFactor` behaviour). * The order is tagged so operators can find affected orders. * The provider's exact error code and message are written to the order timeline. * The checksum is **not** updated, so the next mutation retries. An order that can't be taxed is still an order. Reconciling it is an operations problem, not a reason to fail a customer's checkout. ## Related * [Commerce app](/official-apps/commerce/) — cart tax persistence and the cart-to-order replay * [Order actor](/resources/actors/order/) — `setOrderLineTax` and `setShippingTax` commands * [Official Apps](/official-apps/) — the full app catalogue --- --- url: /official-apps/tax/avatax.md --- # Avalara AvaTax Avalara AvaTax is a cloud-based service that automates transaction tax calculation and the tax filing process, using tax content from more than 12,000 US taxing jurisdictions and over 200 countries. This app connects AvaTax to Hantera so your transaction tax is calculated against the most current tax rules, and your sales and credit documents are recorded in Avalara for filing. ## What the app does | Capability | Description | |---|---| | **Tax calculation** | Calculates US sales tax and Canadian GST/HST/PST/QST on carts and orders. The amounts Avalara returns are written directly onto order lines and shipping — the app never applies a rate itself. | | **Document recording** | When Hantera creates an invoice, the app commits it to Avalara as a `SalesInvoice`. Credit invoices from returns are committed as `ReturnInvoice` documents, taxed at the original sale's rates. | | **Voiding** | Cancelling a Hantera invoice voids the corresponding AvaTax document. | | **Tax codes** | Look up Avalara tax codes from the portal and assign them per order line or per shipping line. | | **Exemptions** | Assign a document-level entity/use code per order, picked from the codes your own account exposes. | | **Address validation** | Validate and normalise a delivery address against Avalara's address service. | | **Logging** | The outcome of every AvaTax call is recorded on the order timeline, and the calls themselves — request and response — are captured by Hantera's traffic logging, one click away from the order. | The app is scoped to the destinations you enable — **US and Canada by default**. Orders shipping anywhere else keep Hantera's native tax behaviour untouched. ## Before you start You need an Avalara AvaTax account. From your AvaTax Admin Console, gather: | What | Where to find it | |---|---| | **Account number** | Provided during your AvaTax account activation. | | **License key** | Provided during your AvaTax account activation. If you've lost it, reset it in the Admin Console under **Settings → License key**. | | **Company code** | The company profile identifier in the AvaTax Admin Console. You can also look this up from inside Hantera once your credentials are saved. | | **Environment** | Whether you're connecting to Avalara's sandbox or production service. | You also need to know your **ship-from address** for each Hantera channel that will use AvaTax. Tax cannot be calculated without both an origin and a destination — see [Configuration](./configuration#configure-each-channel). ::: tip Start in sandbox Set **Environment** to *Sandbox* while you set things up. Sandbox transactions never affect your filing data, so you can create, commit and void freely. Switch to *Production* only when you're ready to record real documents. ::: ## Set it up 1. **Install the app** on your Hantera instance through the standard app installation. 2. **Enter your credentials** and verify them with **Test connection** — see [Configuration](./configuration#connect-to-avatax). 3. **Set your company code**, either by typing it or using the built-in company lookup. 4. **Enable and configure each channel** that should use AvaTax, including its ship-from address — see [Configure each channel](./configuration#configure-each-channel). 5. **Set your default tax codes** for products and shipping — see [Tax Codes](./tax-codes). 6. **Place a test order** to a US address in an enabled channel. The order's sidebar shows a *Tax calculated by Avalara AvaTax* badge and the order timeline records the call. ::: warning Nothing happens until a channel is enabled AvaTax is opt-in per channel. Entering valid credentials is not enough — a channel with no AvaTax configuration, or with the toggle off, is left entirely alone. This lets you roll AvaTax out one channel at a time. ::: ## Documentation | Page | Contents | |---|---| | [Configuration](./configuration) | Credentials, environment, company code, behaviour switches, country scope, per-channel setup, and exactly when tax gets calculated. | | [Tax Codes](./tax-codes) | Assigning Avalara tax codes to order lines and shipping lines, the resolution order, and how a product catalogue feeds them. | | [Working with Orders](./orders) | The AvaTax order panel, entity/use codes, exemption certificates, address validation, forced recalculation, and troubleshooting. | | [Documents & Returns](./documents) | How Hantera invoices become recorded AvaTax documents, how returns are credited, voiding, and running with an ERP as system of record. | ## Related * [Tax Providers](../) — what all tax apps in this family have in common * [Commerce app](/official-apps/commerce/) — cart tax persistence, which makes cart renders free * [Avalara tax code reference](https://taxcode.avatax.avalara.com) — searchable list of Avalara system tax codes --- --- url: /official-apps/tax/avatax/configuration.md --- # Configuration AvaTax is configured in two places: * **App settings** — your Avalara credentials and the behaviour that applies everywhere. * **Channel settings** — which channels use AvaTax, and the ship-from address each one calculates from. Both are needed. Credentials alone don't switch anything on. ## Connect to AvaTax 1. In the portal, go to **Settings → Apps → Avalara AvaTax**. 2. Fill in the **Connection** fields. 3. Click **Test connection**. 4. Click **Save**. ### Connection settings | Setting | Description | |---|---| | **Account Number** | Your Avalara account number, provided during account activation. | | **License Key** | Your Avalara software license key. Stored as a secret — once saved it is never displayed back to you or to any app. | | **Environment** | **Sandbox** connects to `sandbox-rest.avatax.com`; **Production** connects to `rest.avatax.com`. Defaults to Sandbox. | | **Company Code** | The company profile within your Avalara account that transactions are reported under. | ### Test connection **Test connection** calls Avalara's ping endpoint with your saved credentials and reports back either the authenticated account or the exact error Avalara returned. Use it whenever you change credentials, switch environment, or suspect a permissions problem. A failure here means every calculation would fail the same way, so it's the first thing to check. ::: warning Test connection uses saved settings The test runs against what's currently stored, not what's typed into the form. Save first, then test. ::: ### Find your company code Rather than copying the code out of the Admin Console, click **Lookup companies**. The app queries your Avalara account and lists the companies it contains; click **Use** on one to fill in the Company Code field. The lookup needs working credentials, so do this after a successful Test connection. Selecting a company stages the value in the settings form — remember to **Save**. ## Behaviour | Setting | Effect when on | |---|---| | **Disable AvaTax** | Switches the integration off completely. No calls of any kind are made — no calculation, no committing, no voiding. Orders fall back to Hantera's native `taxFactor` tax behaviour. Existing tax already written to orders is left as-is. | | **Disable document recording** | Tax is still calculated, but nothing is ever recorded in Avalara. No documents are committed and none are voided. Use this when another system — typically your ERP — is the system of record for filing. See [Documents & Returns](./documents#erp-as-system-of-record). | ::: tip Turning AvaTax off is safe **Disable AvaTax** is independent of every other setting and takes effect immediately on the next order calculation. It's the switch to reach for if AvaTax is misbehaving and you need orders flowing again. ::: ## Tax codes and country scope | Setting | Description | |---|---| | **Enabled Countries** | Comma-separated destination country codes that AvaTax calculates tax for. Defaults to `US,CA`. Deliveries to any other country are ignored by the app and keep their native tax. | | **Default Product Tax Code** | The Avalara tax code applied to order lines that have no code of their own, e.g. `P0000000` for general tangible personal property. | | **Default Shipping Tax Code** | The Avalara tax code applied to shipping (freight) lines, e.g. `FR020100`. Shipping taxability varies by state, so this matters. | Both defaults can be overridden per line — see [Tax Codes](./tax-codes). ::: info Widening the country list AvaTax supports far more jurisdictions than the US and Canada, and you can add their codes here. Be aware that only US and Canada are verified in this release; other destinations may need tax-code and address-format work specific to that country. ::: ## Configure each channel Each Hantera channel decides for itself whether it uses AvaTax, and supplies the ship-from address that its tax is calculated from. 1. Go to **Settings → Channels** and open the channel. 2. Expand the **AvaTax** section and turn its toggle **on**. 3. Fill in the **Default Origin (ship-from)** address. 4. Optionally set a reporting location code or a company code override. 5. **Save** the channel. Repeat for every channel that should use AvaTax. | Field | Description | |---|---| | **AvaTax toggle** | Explicit opt-in. With this off, the channel behaves as though the app weren't installed. | | **Default Origin (ship-from)** | The address goods ship from for this channel. **Required.** At minimum a postal code and country — without them, no tax is calculated for the channel. | | **Reporting Location Code** | Optional. An Avalara reporting location code, sent as the document's `reportingLocationCode`. Only relevant if you do location-based tax reporting in Avalara. | | **Company Code override** | Optional. Reports this channel's transactions under a different Avalara company than the app-level Company Code. Useful when one Hantera instance serves several legal entities. | Turning the toggle off preserves the other values, so you can switch a channel off and back on without re-entering its address. ::: warning An origin is not optional US sales tax is determined by the jurisdiction pair — where goods ship *from* and *to*. Avalara rejects any transaction without both. The channel editor warns you while the origin is missing a postal code or country, and calculation is skipped for that channel until it's complete. ::: ## When tax is calculated The app checks a series of conditions before contacting Avalara, and stops at the first one that fails. Understanding these explains both why a call happened and why one didn't. | # | Condition | |---|---| | 1 | **Disable AvaTax** is off. | | 2 | The order's channel has AvaTax enabled. | | 3 | The order isn't cancelled. | | 4 | At least one delivery has an address whose country is in **Enabled Countries** *and* which has a postal code. | | 5 | The channel has a usable ship-from origin. | | 6 | Something tax-relevant has changed since tax was last calculated. | Only deliveries that pass condition 4 are sent to Avalara. On a mixed order — say one delivery to Texas and one to Germany — the Texas delivery is taxed by AvaTax and the German one keeps its native tax. ### Why nothing recalculates on every save Condition 6 is the important one for your Avalara call volume. The app keeps a digest of the tax-relevant inputs on the order and compares it before each calculation. Changing a customer's phone number or adding an internal note doesn't touch tax, so no call is made. The inputs that *do* trigger a recalculation are: * Order lines — added, removed, quantity or amount changed * A line's or delivery's tax code * Any delivery address field * Shipping cost * The customer code, entity/use code, or exemption certificate number * Whether the order is tax-inclusive * The channel's ship-from origin With the [Commerce app](/official-apps/commerce/) installed, this extends to carts: calculated tax is stored on the cart and replayed on every render, so **rendering a cart costs no AvaTax calls at all**. A typical checkout makes one call when the shipping address is entered, one more for each subsequent change, and one when the invoice is committed. ## Configuring without the portal Every app setting is also reachable through the app settings endpoints in the [HTTP API](/api/), and channel configuration through the [registry](/resources/registry/) at `channels/` under the record's `avatax` field. This is the route for scripted or environment-promoted setups. Settings keys, for reference: `accountNumber`, `licenseKey`, `environment`, `companyCode`, `disabled`, `disableDocumentRecording`, `enabledCountries`, `defaultTaxCode`, `defaultShippingTaxCode`. ## Related * [Tax Codes](./tax-codes) — overriding the default codes per line * [Working with Orders](./orders) — verifying a calculation and reading the timeline * [Documents & Returns](./documents) — what gets recorded in Avalara --- --- url: /official-apps/tax/avatax/tax-codes.md --- # Tax Codes Avalara tax codes tell AvaTax *what kind of thing* is being sold, which determines how each jurisdiction taxes it. Clothing, groceries, digital goods, software licences and shipping are all taxed differently — often differently within the same state. Getting these right is what makes AvaTax accurate. Everything else is plumbing. ## Where codes live in Hantera In Hantera, tax codes are assigned to the **order line** and the **shipping line**, not to a central product record inside this app. Every line sent to Avalara carries a code resolved at calculation time. ### Order lines | Priority | Source | |---|---| | 1 | The order line's own tax code, if one is set | | 2 | The **Default Product Tax Code** app setting | | 3 | Nothing sent — Avalara then treats the line as `P0000000`, general tangible personal property | ### Shipping lines Each delivery with a shipping cost is sent to Avalara as its own freight line. | Priority | Source | |---|---| | 1 | The delivery's own tax code, if one is set | | 2 | The **Default Shipping Tax Code** app setting | | 3 | Nothing sent | ::: tip Set the defaults first Most catalogues are dominated by one kind of item. Set **Default Product Tax Code** to whatever covers the bulk of what you sell and only override the exceptions. Same for shipping: `FR020100` covers ordinary shipping charges in most states. ::: ## Assign a tax code to an order line 1. Open the order in the portal. 2. Expand the order line you want to change. 3. Next to **Tax Code**, click **Edit**. 4. Search for the code in the **Tax Code Lookup** dialog and select it. 5. Save the order. The lookup searches Avalara's live tax code catalogue through your own account, so it includes any custom codes your account defines. You can also browse the full list at [taxcode.avatax.avalara.com](https://taxcode.avatax.avalara.com). The **Tax Code** row shows `Default` when the line has no code of its own, meaning the app-level default applies. Once a code is set, **Reset** removes it and returns the line to the default. Changing a tax code changes the tax, so saving triggers a recalculation of the order. ::: info Only visible on AvaTax channels The Tax Code row only appears on orders in a channel that has [AvaTax enabled](./configuration#configure-each-channel). Orders in other channels don't show it, because the code wouldn't be used. ::: ## Assign a tax code to a shipping line Shipping works identically. Expand the delivery's shipping line, click **Edit** next to **Tax Code**, and pick a code. This overrides **Default Shipping Tax Code** for that delivery only. This is worth using when a single order mixes shipment types that are taxed differently — for example a standard parcel alongside a delivery-and-installation service. ## Setting codes from an integration Tax codes are ordinary dynamic fields, so any integration, import, or rule can set them: | Target | Command | Field | |---|---|---| | Order line | `setOrderLineDynamicFields` | `avatax_taxCode` | | Delivery (shipping) | `setDeliveryDynamicFields` | `avatax_taxCode` | Setting the field to `null` clears the override. This is the intended integration point. The AvaTax app deliberately reads the code from the line and nowhere else, which keeps it independent of how — or whether — you model a product catalogue. ::: tip Where tax codes usually come from On a real project you don't set tax codes order by order. The product's tax code is catalogue data, maintained once per product in a PIM or in a product-management app such as [Products](/official-apps/products/), and copied onto each order line as the order is enriched. A small rule on the `OnOrderCommands` hook does the copying: ```filtrera param input: OnOrderCommands // Product tax codes are catalogue data. Copy each line's product-level code onto // the line so AvaTax picks it up, leaving lines with an explicit override alone. from input.order.deliveries select d => d.orderLines where ol => ol.dynamic->'avatax_taxCode' is not text select ol => { effect = 'orderCommand' type = 'setOrderLineDynamicFields' orderLineId = ol.orderLineId fields = { avatax_taxCode = lookupProductTaxCode(ol.productNumber) } } flatten buffer ``` The rule that owns this lives in the app that owns the catalogue, not in the AvaTax app — the coupling to product data belongs where the product data is. The portal editors then act as a per-order override on top of whatever the catalogue supplied. ::: ## Checking what Avalara actually used The tax code sent for each line is part of the AvaTax request. To see it, click **View AvaTax calls** on the order's AvaTax panel and select the calculation — its request payload lists each line with the `taxCode` that was actually sent. See [Diagnosing AvaTax calls](./orders#diagnosing-avatax-calls) for how to enable payload capture first. Switch it back off once you're done; it's a diagnostic aid, not something to leave running. ## Related * [Configuration](./configuration#tax-codes-and-country-scope) — setting the default codes * [Working with Orders](./orders) — the order panel and the timeline * [Avalara tax code reference](https://taxcode.avatax.avalara.com) — the full searchable catalogue --- --- url: /official-apps/tax/avatax/orders.md --- # Working with Orders Once AvaTax is configured, tax appears on orders without anyone doing anything. This page covers the tooling for the cases that do need attention: exemptions, questionable addresses, and orders where something went wrong. ## The AvaTax panel Every order in an AvaTax-enabled channel gets an **Avalara AvaTax** panel in its sidebar. | Element | Meaning | |---|---| | **Tax calculated by Avalara AvaTax** | Avalara has successfully calculated tax for this order. | | **⚠ AvaTax error** | The last AvaTax interaction failed. The order also carries an `avatax-error` tag so you can find these in order lists. | | **Total tax** | The total tax Avalara returned for the order. | | **Calculated** | When tax was last successfully calculated. | | **Last error** | The code and message from the most recent failure, if there is one. | | **View AvaTax calls** | Opens the trace viewer filtered to this order's AvaTax calls. Only shown if you have permission to read traces. | Per-line tax amounts aren't repeated here — they're in each order line's own breakdown. ## Exemptions There are two independent pieces of exemption data, and they do different jobs. ### Entity/use code An entity/use code says *why* a sale is exempt — the buyer is a reseller, a government body, a charity, and so on. It applies to the whole order. Pick one from the **Entity Use Code** dropdown in the AvaTax panel. The list is fetched live from your own Avalara account, so it includes any custom codes you've defined. Each entry shows the code, its name and description, and the countries it's valid in — codes that aren't valid for this order's destination are greyed out but still selectable, since you may know better than the default validity list. Selecting a code changes the tax, so the order recalculates on save. Integrations can set this directly with `setOrderDynamicFields` on the `avatax_entityUseCode` field — useful for a B2B storefront that already knows the customer is exempt. ### Exemption certificate number The certificate number is the buyer's actual tax registration or exemption certificate identifier. This is **not** an AvaTax-specific field in Hantera: it comes from the order's **invoice recipient tax ID**, which you edit in the order's invoice recipient details like any other invoicing data. The app sends it to Avalara as the document's `exemptionNo`. Because it affects tax, changing it triggers a recalculation. Committed documents use the tax ID as it stood on the **invoice** when that invoice was raised, not the order's current value. Correcting a customer's tax ID afterwards doesn't retroactively alter documents already filed — which is the behaviour you want for an audit trail. ## Validate an address US tax is decided at street level, and an address that's *nearly* right can land in the wrong jurisdiction. **Validate address** in the AvaTax panel sends the order's delivery address to Avalara's address service and shows you the normalised version — corrected spelling, standardised abbreviations, and the full ZIP+4. If Avalara can't resolve the address it tells you why. That's worth acting on before the order ships, because an unresolvable address usually means the tax is being calculated from a broader jurisdiction than the real one. ## Force a recalculation **Recalculate tax** clears the order's stored tax digest, which makes the app treat the next save as though everything had changed and call Avalara again. You need this rarely — normal edits recalculate on their own. Use it when: * You changed a default tax code and want an existing order to pick it up. * You corrected the channel's ship-from address. * A previous call failed and you want to retry immediately rather than wait for the next edit. ## The order timeline Every AvaTax call — successful or not — writes an entry to the order timeline. This is the audit trail and the first place to look when something's off. | Entry | When | |---|---| | *Tax calculated by AvaTax: {total} {currency}* | A calculation succeeded | | *AvaTax calculation failed: {code} {message}* | A calculation failed, with Avalara's own error text | | *Invoice {number} committed to AvaTax ({type})* | A document was recorded | | *AvaTax commit failed for invoice {number}: {code} {message}* | Recording failed | | *AvaTax commit skipped for invoice {number}: {reason}* | The document couldn't be built — usually a missing origin or delivery address | | *Invoice {number} voided in AvaTax* | A document was voided | The timeline records what the app *decided*. What it actually sent Avalara is kept separately — see below. ## Diagnosing AvaTax calls When you need the exact request and response — which is what Avalara support will ask for — click **View AvaTax calls** on the AvaTax panel. This opens **Monitoring → Traces** filtered to that order's AvaTax calls; select a call to see its request and response. (The order's own context menu has **View activity traces**, which shows everything the order touched — useful when the problem may not be AvaTax at all.) Every AvaTax call is covered — calculation, invoice commits, voids, and the tax-code and company lookups — not just the calculation. Sensitive headers such as `Authorization` are redacted automatically, and oversized bodies are truncated with a marker rather than stored whole. ::: warning Payloads are opt-in and short-lived By default Hantera records **which** calls happened, not their bodies. To capture request and response payloads, raise the log level to `full` for the `apps/avatax/*` egresses under **Monitoring → Settings**. Payloads are also kept for a much shorter period than the order itself — 24 hours by default, and your plan may cap it lower still. Practically: switch the level up, reproduce the problem, then collect the payloads while they're fresh. You can't reach back to a call from last month. The **Monitoring → Settings** page shows the limits that apply to your tenant. ::: ## When a calculation fails AvaTax failures never block an order. If Avalara is unreachable or rejects the request: * The order **keeps its previous tax**, or falls back to Hantera's native tax behaviour if it had none. * The order is tagged `avatax-error` and the panel shows an error badge. * Avalara's exact error is written to the timeline and to the panel's **Last error** row. * The stored digest is **not** updated, so the next edit retries automatically. To find affected orders, filter your order list by the `avatax-error` tag. ### Common causes | Symptom | Likely cause | |---|---| | No tax at all, no timeline entries | The channel isn't AvaTax-enabled, or the destination country isn't in **Enabled Countries** | | Timeline says *commit skipped: no origin* | The channel has no ship-from address, or it's missing a postal code or country | | Authentication errors | Wrong credentials, or credentials for the other environment. Run **Test connection** | | Tax looks wrong for the product type | The line is falling back to the default tax code. See [Tax Codes](./tax-codes) | | Tax looks wrong for the location | Validate the delivery address | ## Fields reference For integrators, the fields the app reads and writes on an order: | Field | On | Written by | Purpose | |---|---|---|---| | `taxChecksum` | Order | The app | Digest of the tax-relevant inputs. Treat as opaque. | | `avatax_calculatedAt` | Order | The app | When tax was last successfully calculated | | `avatax_totalTax` | Order | The app | Total tax from the last response | | `avatax_error` | Order | The app | Last error `{ code, message }`; cleared on success | | `avatax_entityUseCode` | Order | Portal / integrations | Document-level entity/use code | | `avatax_entityUseCodeName` | Order | Portal | Display cache for the panel label. Presentation only — never read when calculating. | | `avatax_taxCode` | Order line | Portal / integrations | Per-line tax code override | | `avatax_taxCode` | Delivery | Portal / integrations | Per-delivery shipping tax code override | | `avatax-error` | Order tag | The app | Present while the last interaction failed | The exemption certificate number is the native `invoiceRecipient.taxId`, not a dynamic field. ## Related * [Tax Codes](./tax-codes) — controlling how each line is taxed * [Documents & Returns](./documents) — what happens when you invoice * [Configuration](./configuration#when-tax-is-calculated) — the conditions for a calculation --- --- url: /official-apps/tax/avatax/documents.md --- # Documents & Returns Calculating tax and *recording* it are separate things in Avalara. Calculations are throwaway quotes; recorded documents are what Avalara files returns from. This page covers when Hantera records documents, and what they look like. ## Calculations are never recorded Every tax calculation the app makes — for a cart, a draft order, or a portal edit — is sent to Avalara as a **`SalesOrder`** document. Avalara treats these as quotes and never stores them, so they can be repeated freely without polluting your filing data. Nothing is recorded until Hantera creates an invoice. ## Invoices become recorded documents When an invoice is created in Hantera, the app records it in Avalara. ``` Hantera invoice created → SalesInvoice committed to AvaTax Credit invoice created → ReturnInvoice committed to AvaTax Hantera invoice cancelled → AvaTax document voided ``` Recording happens in the background, just after the invoice exists, and is retried automatically if Avalara is temporarily unavailable. The outcome is written to the order timeline either way. ### What Avalara receives | Avalara field | From | |---|---| | `code` | The Hantera invoice number — unique and stable, so retries update the same document rather than duplicating it | | `referenceCode`, `purchaseOrderNo` | The order number | | `date` | The invoice's creation date | | `customerCode` | The order's customer number, falling back to the invoice recipient's email | | `companyCode` | The channel's company code override, or the app-level Company Code | | `commit` | Always `true` — documents are recorded as final | | Lines | One per invoice line, with its item code, description, quantity, net amount and tax code | | Addresses | Ship-from from the channel origin; ship-to from the delivery each line belongs to | Because each line carries the address of *its own* delivery, an order shipped to several addresses is reported correctly against each jurisdiction from a single document. ## Partial invoicing Hantera invoices are deltas — invoicing part of an order now and the rest later produces two separate invoices. Each becomes its own AvaTax document with its own invoice number, so there's a one-to-one relationship between Hantera invoices and Avalara documents. No document is ever amended after the fact. ## Returns and credit invoices A return in Hantera produces a credit invoice. The app recognises it by its negative net and records it as a **`ReturnInvoice`**, following Avalara's rules for refunds: | Aspect | Value | |---|---| | Document type | `ReturnInvoice` | | Amounts | Negative | | Quantities | Positive | | Document date | Today — the return is reported in the period it was processed | | Tax date | The **original invoice's** date | That last row is what makes returns correct. The refund has to be credited at the rate the customer originally paid, not today's rate. The app finds the original invoice by following the credit line back to the line it reverses, and sends its date as a tax-date override so Avalara re-applies the historical rate. ::: info Partial returns Refunding part of a line works the same way. The credit invoice carries the returned portion, and Avalara credits tax proportionally at the original rate. ::: ## Voiding Cancelling an invoice in Hantera voids the corresponding AvaTax document, marking it `DocVoided` in Avalara so it drops out of your filing. This also covers **rewinding** an order. Rewinding to a checkpoint from before an invoice existed cancels that invoice, which voids the AvaTax document through exactly the same path. Cancelling an order that was never invoiced needs no Avalara action, since nothing was ever recorded for it. ## ERP as system of record Many businesses already commit transactions to Avalara from their ERP. Recording them again from Hantera would double-count everything. Switch on **Disable document recording** in the [app settings](./configuration#behaviour). With it on: * Tax is still calculated on carts and orders exactly as before. * No documents are committed, and none are voided. * Your ERP remains the sole source of Avalara documents. This is a supported, first-class configuration — not a degraded mode. Hantera gives you accurate tax at checkout while your ERP owns the filing record. ## Verifying documents Two places to check: * **The order timeline** in Hantera records every commit and void, including failures with Avalara's own error text. See [the order timeline](./orders#the-order-timeline). * **The AvaTax Admin Console** shows the documents themselves. Search by the Hantera invoice number, which is the document code. If a document is missing, the timeline will say why. The most common reason is a *commit skipped* entry — the app couldn't resolve an origin or destination address, so it declined to send a document Avalara would have rejected. ::: warning Sandbox and production are separate Documents committed while **Environment** was *Sandbox* exist only in Avalara's sandbox. Switching to production does not migrate them. Do your end-to-end testing in sandbox, then switch over before real trading starts. ::: ## Related * [Working with Orders](./orders) — the timeline and troubleshooting * [Configuration](./configuration#behaviour) — the document recording switch * [Order actor](/resources/actors/order/) — invoicing and cancellation in Hantera --- --- url: /official-apps/returns.md --- # Returns App The Returns app provides customer-claim and warehouse-inspection workflows for Hantera. It models two related but independent [ticket actor](/resources/actors/custom/ticket/) types — `claim` and `rma` — and exposes resolution-time effects through the [`OnClaimResolve`](/official-apps/returns/hooks) custom hook. ## What It Provides ### HTTP Ingresses Public endpoints for filing and listing claims: | Endpoint | Purpose | |---|---| | `POST /ingress/returns/claims/create` | File a new claim for an order, with one or more claim lines | | `GET /ingress/returns/claims/get-by-order` | List all claims attached to a given order | See [API Reference](/official-apps/returns/api) for full request/response schemas. ### Graph Nodes & Edges The app contributes four ticket-derived node types and their sets/edges: * `ticket.claim` / set `claims` / edge `claim` * `ticket.claim.line` / set `claimLines` / edge `claimLines` * `ticket.rma` / set `rmas` / edge `rma` * `ticket.rma.line` / set `rmaLines` / edge `rmaLines` Together with enum value sets for tags, reject reasons, claim-line state, and resolution. See [Graph](/official-apps/returns/graph). ### Custom Resolution Types Resolution types — `refund`, `replace`, `compensate`, or anything else an app or merchant wants to define — are entirely registry-driven. Each resolution is a value-set entry under `enums/graph/ticket.claim.line/resolution/values/` and may declare a set of per-resolution custom fields under `apps/returns/claims/fieldDefinitions/line/`. Field values are surfaced on the [`OnClaimResolve`](/official-apps/returns/hooks) hook for the hook listener to act on. See [Resolution Types](/official-apps/returns/resolutions). ### Portal Extension Points Slots and services exposed for other apps to extend the claim and RMA views: * Four [slots](/official-apps/returns/portal#slots) for adding sections to the claim and RMA summary and line panels * Two [services](/official-apps/returns/portal#services) for contributing warnings to the claim and RMA headers See [Portal Extensions](/official-apps/returns/portal). ## Lifecycle ```mermaid graph TB Customer[Customer / Agent] Inspector[Warehouse Inspector] subgraph "Claim" Claim[Claim Ticket] ClaimLine[Claim Line
resolution + requireInspection] end subgraph "RMA
(optional)" RMA[RMA Ticket] RMALine[RMA Line
acceptedQuantity + refundFactor] end Hook[(OnClaimResolve)] Order[Order Actor] Customer -->|createClaims ingress
or portal| Claim Claim --> ClaimLine ClaimLine -->|requireInspection = false
at claim completion| Hook ClaimLine -->|requireInspection = true
at claim completion| RMA RMA --> RMALine Inspector -->|inspect & complete| RMA RMALine -->|at RMA completion| Hook RMALine -->|createReturn| Order Hook -->|orderCommand effects| Order ``` ::: tip RMAs can stand alone The app creates an RMA automatically when a claim line's resolution requires inspection, but RMAs are designed to track a returned product on their own and do not require an originating claim. Other apps or flows may create RMA tickets directly. ::: ## Key Features * **Public HTTP ingresses** for creating and listing claims, with automatic order-line matching by product number and remaining claimable quantity * **Four ticket-backed graph node types** (`ticket.claim`, `ticket.claim.line`, `ticket.rma`, `ticket.rma.line`) with their fields, edges, and customizable enum value sets — see [Graph](/official-apps/returns/graph) * **`OnClaimResolve` custom hook** — fired for non-inspected lines at claim completion and for inspected lines at RMA completion. `orderCommand` effects are applied to the connected order in a single batch — see [Hooks](/official-apps/returns/hooks) * **Registry-driven resolution types** — define new resolution types by adding `enums/graph/ticket.claim.line/resolution/values/` entries with `requireInspection` / `requireInspectionEditable` properties, and attach per-resolution custom fields via `apps/returns/claims/fieldDefinitions/line/` entries — see [Resolution Types](/official-apps/returns/resolutions) * **Stand-alone RMAs** — RMA tickets can track returns without an originating claim, allowing other flows (e.g. inventory or recall workflows) to drive the inspection-and-refund loop * **Portal extension points** — four slots and two warning services on the claim and RMA views — see [Portal Extensions](/official-apps/returns/portal) --- --- url: /official-apps/returns/graph.md --- # Returns Graph Reference The Returns app contributes two top-level ticket types via the `actors/ticket/types/*` registry namespace: `claim` and `rma`. Each defines its own item type (a "line") and a set of relations to other graph nodes. This page is a reference for the resulting graph surface — node types, fields, edges, and enum value sets — that other apps, rules, and ingresses can query against. ## Ticket Types ### `claim` Registry: `/actors/ticket/types/claim` | Property | Value | |---|---| | `graphSetName` | `claims` | | `itemEdgeName` | `claim` | | `defaultNumberPrefix` | `CLM` | #### Relations on `ticket.claim` | Relation | Node type | Cardinality | |---|---|---| | `order` | `order` | single | | `pausedBy` | `identity` | single (via `pausedById` field) | #### Items: `ticket.claim.line` | Property | Value | |---|---| | `graphSetName` | `claimLines` | | Item edge name | `claimLines` | Relations: | Relation | Node type | Cardinality | |---|---|---| | `pictures` | `file` | many | | `orderLine` | `orderLine` | single | ### `rma` Registry: `/actors/ticket/types/rma` | Property | Value | |---|---| | `graphSetName` | `rmas` | | `itemEdgeName` | `rma` | | `defaultNumberPrefix` | `RMA` | #### Relations on `ticket.rma` | Relation | Node type | Cardinality | |---|---|---| | `order` | `order` | single | | `claim` | `ticket.claim` | single | ::: tip Stand-alone RMAs The `claim` relation is optional. An RMA can exist without a connected claim; [`OnClaimResolve`](/official-apps/returns/hooks) is only fired for RMAs that do have a connected claim. ::: #### Items: `ticket.rma.line` | Property | Value | |---|---| | `graphSetName` | `rmaLines` | | Item edge name | `rmaLines` | Relations: | Relation | Node type | Cardinality | |---|---|---| | `claimLine` | `ticket.claim.line` | single | | `orderLine` | `orderLine` | single | | `pictures` | `file` | many | ## Graph Fields All custom fields are sourced from each node's dynamic-field bag. The standard `ticketId`, `ticketNumber`, `ticketState`, `tags`, `dynamic`, `createdAt`, `closedAt`, `channelKey` fields are inherited from the [ticket actor](/resources/actors/custom/ticket/). ### `ticket.claim` | Field | Type | Source | |---|---|---| | `rejectReason` | `enum` | `dynamic->'rejectReason'` | | `rejectMessage` | `text` | `dynamic->'rejectMessage'` | | `isPaused` | `boolean` | `dynamic->'isPaused'` | | `pausedById` | `uuid` | `dynamic->'pausedBy'` | ### `ticket.claim.line` | Field | Type | Source | |---|---|---| | `productNumber` | `text` | `dynamic->'productNumber'` | | `resolution` | `enum` | `dynamic->'resolution'` | | `quantity` | `number` | `dynamic->'quantity'` | | `claimLineState` | `enum` | `dynamic->'claimLineState'` | | `tags` | `[enum]` | `dynamic->'tags'` | | `rejectReason` | `enum` | `dynamic->'rejectReason'` | | `rejectMessage` | `text` | `dynamic->'rejectMessage'` | | `requireInspection` | `boolean` | `dynamic->'requireInspection'` | The `proposedResolution` and `description` values written by the `createClaims` ingress are also stored on the dynamic bag but are not exposed as graph fields by default — query them via `dynamic->'proposedResolution'` / `dynamic->'description'`. ### `ticket.rma.line` | Field | Type | Source | |---|---|---| | `productNumber` | `text` | `dynamic->'productNumber'` | | `expectedQuantity` | `number` | `dynamic->'expectedQuantity'` | | `receivedQuantity` | `number` | `dynamic->'receivedQuantity'` | | `acceptedQuantity` | `number` | `dynamic->'acceptedQuantity'` | | `refundFactor` | `number` | `dynamic->'refundFactor'` | | `tags` | `[enum]` | `dynamic->'tags'` | | `inspectionNotes` | `text` | `dynamic->'inspectionNotes'` | | `comment` | `text` | `dynamic->'comment'` | `refundFactor` is a decimal in the range `0..1`. When the inspector accepts a partial refund (e.g. for wear), this is the multiplier the app applies when creating the return on the connected order. ## Enum Value Sets The Returns app declares the following enum value sets. The values themselves are **not** shipped by the app — they are intended to be populated by other apps (the demo apps include sample values) or by the merchant in the portal. | Value set | Purpose | |---|---| | `ticket.claim/tags` | Claim-level tags | | `ticket.claim.line/tags` | Line-level tags | | `ticket.claim.line/resolution` | Resolution types. Entries carry `requireInspection` / `requireInspectionEditable` properties — see [Resolution Types](/official-apps/returns/resolutions). | | `ticket.rma.line/tags` | RMA line tags | | `ticket.rma/tags` | RMA ticket tags | ### Reject reasons — global enum definition `rejectReason` on both `ticket.claim` and `ticket.claim.line` is sourced from a single [global enum definition](/resources/graph/custom-fields#global-enum-definitions) rather than a per-node value set. Both graph fields declare `enumDefinition: claimRejectReason` and pick up their values from: | Registry path | Purpose | |---|---| | `/enums/definitions/claimRejectReason/values/` | A selectable reject-reason value. | | `/enums/definitions/claimRejectReason/categories/` | Optional grouping for the portal UI. | The same reason keys apply whether the entire claim is rejected or only individual lines. See [Resolution Types → Reject Reasons](/official-apps/returns/resolutions#reject-reasons-and-templates) for the full schema and paired message templates. ### `ticket.claim.line/claimLineState` This value set is **populated** by the Returns app. The standard values are: | Value | Description | |---|---| | `open` | The line is still being decided on. | | `rejected` | The line was rejected — no resolution effect is emitted. | | `completed` | The line was accepted. `OnClaimResolve` is fired for this line (immediately if no inspection is required, or after the RMA is completed otherwise). | Only lines in `completed` state are passed to the [`OnClaimResolve`](/official-apps/returns/hooks) hook. --- --- url: /official-apps/returns/hooks.md --- # Returns Hooks The Returns app fires a single custom hook, `OnClaimResolve`, that other apps and the merchant's own rules can listen to in order to apply the actual side effects of a resolved claim — refunds, replacements, compensations, emails, and so on. ## OnClaimResolve Fired with the lines of a claim that have just been resolved. The hook is the integration point between the Returns app's UI/state model and any business-specific behavior that should happen when a claim line is accepted. ### When It Fires `OnClaimResolve` is fired from two places: * **At claim completion** — for every completed claim line where `requireInspection` is **not** `true`. These lines are resolved immediately, with `acceptedQuantity` equal to the claim line's `quantity`. * **At RMA completion** — for every RMA line where `acceptedQuantity > 0`. The `resolution` is taken from the originating claim line, and `acceptedQuantity` reflects the inspected value. If an RMA has no `claim` relation, `OnClaimResolve` is not fired for that RMA. ### Hook Input The hook payload has the shape: ```filtrera { hook: 'OnClaimResolve' claimId: uuid orderId: uuid | nothing lines: [{ claimLineId: uuid resolution: text orderLineId: uuid | nothing acceptedQuantity: number productNumber: text | nothing metadata: { text -> value } }] } ``` | Field | Notes | |---|---| | `claimId` | ID of the resolving claim. | | `orderId` | ID of the connected order, or `nothing` if the claim has no order relation (rare). | | `lines[].resolution` | Resolution key from `enums/graph/ticket.claim.line/resolution/values/*` — see [Resolution Types](/official-apps/returns/resolutions). | | `lines[].orderLineId` | Matched order line, or `nothing` for lines that were not (or could not be) matched. Many resolution types only make sense when this is set. | | `lines[].acceptedQuantity` | The quantity actually being resolved. For non-inspected lines this equals the claim line's `quantity`; for inspected lines it's whatever the warehouse accepted. | | `lines[].productNumber` | The product number on the claim line. May differ from the order line's product number if the line was created without one. | | `lines[].metadata` | All dynamic fields on the claim line whose key matches `metadata_*`, including the prefix. Resolution-specific custom-field values live here — see [Resolution Types](/official-apps/returns/resolutions). | ::: warning `refundFactor` is not on the hook For lines that required inspection, the inspected `refundFactor` is already applied to the resulting return on the connected order. For lines that didn't require inspection, the hook listener is free to emit `createReturn` with whatever factor is appropriate for that resolution. Either way, `refundFactor` is not carried on the hook payload. ::: ### Listening Rule Rules listen by declaring `param input` with `hook: 'OnClaimResolve'` and the fields they need: ```filtrera param input: { hook: 'OnClaimResolve' claimId: uuid orderId: uuid | nothing lines: [{ claimLineId: uuid resolution: text orderLineId: uuid | nothing acceptedQuantity: number productNumber: text | nothing metadata: { text -> value } }] } import 'iterators' // Emit a 100%-discount per claim line resolved as 'compensateFull' from input.lines where l => l.resolution == 'compensateFull' and l.orderLineId is uuid select l => { effect = 'orderCommand' type = 'createStaticOrderLineDiscount' orderLineId = l.orderLineId isPercentage = true value = 1 description = 'Claim compensation' } ``` ::: tip You only need to declare the fields your rule actually uses. Filtrera's structural type system matches the rule's input shape against the hook payload. ::: ### Supported Effects The Returns app dispatches the following effect types emitted by hook listeners: | Effect | How it's applied | |---|---| | `orderCommand` | Applied to the connected order in a single transactional batch with any other `orderCommand` effects from the same hook invocation. | | `messageActor` | Emitted as-is. Useful for creating replacement orders, posting messages to other tickets, etc. | | `sendEmail` | Emitted as-is. | | `scheduleJob` | Emitted as-is. | | `validationError` | Emitted as-is. | Any other effect type is emitted as-is. ### Idempotency `OnClaimResolve` is delivered at most once per claim line: at claim completion (if no inspection is required) **or** at RMA completion (if inspection was required and the inspector accepted some quantity). Rules listening to the hook do not need to deduplicate. ## See Also * [Resolution Types](/official-apps/returns/resolutions) — Defining custom resolution types and their per-resolution custom fields. * [Returns Graph](/official-apps/returns/graph) — Ticket types and fields involved. * [`triggerHook` Reference](/resources/rules/trigger-hook) — How custom hooks work. * [Rule Effects](/resources/rules/effects) — Available effect types. --- --- url: /official-apps/returns/resolutions.md --- # Resolution Types A **resolution type** is the decision an agent makes about a claim line — refund, replace, compensate, send a manual reply, and so on. The Returns app has no built-in resolution logic. Instead, resolutions are entirely registry-driven, and their side effects are produced by rules listening to the [`OnClaimResolve`](/official-apps/returns/hooks) hook. A resolution type consists of three pieces, all expressed as registry entries: 1. An **enum value-set entry** under `enums/graph/ticket.claim.line/resolution/values/` that makes the resolution selectable on a claim line. 2. Zero or more **per-resolution custom-field definitions** under `apps/returns/claims/fieldDefinitions/line/` that show up in the claim-line panel when that resolution is selected. 3. A **listening rule** for [`OnClaimResolve`](/official-apps/returns/hooks) that reads the resolution and any field values from the hook payload and emits effects (typically `orderCommand` effects). ## 1. Resolution Enum Value Each resolution is a value in the `ticket.claim.line/resolution` enum value set. **Registry path:** `/enums/graph/ticket.claim.line/resolution/values/` | Property | Type | Required | Description | |---|---|:-:|---| | `label` | `{ default: text, : text, … }` | ✓ | The label shown in the resolution dropdown. The `default` entry is the fallback; additional locale-keyed entries provide translations. | | `hue` | `number` | | Optional hue used to tint the resolution chip in the portal. | | `requireInspection` | `boolean` | | Default (or forced) value for the claim line's `requireInspection` field when this resolution is selected. Defaults to `false`. | | `requireInspectionEditable` | `boolean` | | Whether the agent can toggle `requireInspection` after selecting this resolution. Defaults to `false`. | ### Inspection Behavior Matrix The combination of `requireInspection` and `requireInspectionEditable` controls whether the app creates an RMA for the line when the claim is completed: | `requireInspection` | `requireInspectionEditable` | Behavior | |---|---|---| | `true` | `false` | Always creates an RMA. The agent cannot skip inspection. | | `true` | `true` | Defaults to creating an RMA; the agent can toggle it off. | | `false` | `false` | Never creates an RMA. The line is resolved immediately at claim completion via `OnClaimResolve`. | | `false` | `true` | Defaults to no RMA; the agent can opt into inspection per line. | ### Example ```yaml - path: /enums/graph/ticket.claim.line/resolution/values/refund value: label: default: Refund upon accepted return requireInspection: true requireInspectionEditable: false - path: /enums/graph/ticket.claim.line/resolution/values/replace value: label: default: Replace item requireInspection: true requireInspectionEditable: true - path: /enums/graph/ticket.claim.line/resolution/values/compensateAmount value: label: default: Compensate with fixed amount requireInspection: false requireInspectionEditable: false - path: /enums/graph/ticket.claim.line/resolution/values/compensatePercentage value: label: default: Compensate by percent requireInspection: false requireInspectionEditable: false - path: /enums/graph/ticket.claim.line/resolution/values/manual value: label: default: Manual action requireInspection: false requireInspectionEditable: false ``` ## 2. Custom Field Definitions Resolution types often need extra inputs from the agent — a refund amount, a replacement product, a discount percentage, a message to the customer. These are declared as registry entries that the portal reads and renders inline in the claim-line panel. ### Per-Resolution Fields (Line Level) **Registry path:** `/apps/returns/claims/fieldDefinitions/line/` A field with a `resolution` property is only shown when that resolution is selected on the line. The value is stored as a dynamic field on the claim line under the key `metadata_`. | Property | Type | Required | Description | |---|---|:-:|---| | `type` | `'text' \| 'multiline' \| 'number' \| 'product'` | ✓ | Editor type. `text` is single-line; `multiline` is a textarea; `number` uses a `DecimalInput`; `product` uses a product-picker. | | `label` | `text` | ✓ | Label shown above the editor. | | `resolution` | `text` | | If set, the field is only shown when this resolution key is selected. If omitted, the field is shown for **all** resolutions (a claim-line-level field). | | `default` | `value` | | Default value used when no value has been set yet. | | `min` | `number` | | Minimum value for `type: 'number'`. | | `max` | `number` | | Maximum value for `type: 'number'`. | | `isReadOnly` | `boolean` | | If `true`, the editor is rendered disabled even when the claim is open. | ### Claim-Level Fields **Registry path:** `/apps/returns/claims/fieldDefinitions/claim/` Same shape as line fields, but rendered in the claim summary instead of the line panel, and stored on the claim's dynamic fields (still with the `metadata_` prefix). Claim-level fields do not support the `resolution` property — they're always shown. ### Storage and Hook Payload All custom-field values are stored as dynamic fields named `metadata_`. This means: * The same prefix is used by the `createClaims` ingress and stripped by the `getClaimByOrder` ingress. Passing `metadata.replaceProductNumber` on the ingress request is equivalent to setting `metadata_replaceProductNumber` on the line. * The keys appear **as-is, including the prefix** on the `metadata` field of the `OnClaimResolve` hook payload. The listening rule reads them via `metadata->'metadata_'`. ### Example ```yaml - path: /apps/returns/claims/fieldDefinitions/line/compensateAmount value: type: number min: 0 resolution: compensateAmount label: Refund Amount - path: /apps/returns/claims/fieldDefinitions/line/compensatePercentage value: type: number default: 0 max: 100 min: 0 resolution: compensatePercentage label: Refund Percent - path: /apps/returns/claims/fieldDefinitions/line/replaceProductNumber value: type: product resolution: replace label: Replace with Product - path: /apps/returns/claims/fieldDefinitions/line/manualResolution value: type: multiline resolution: manual label: Message for customer ``` ## 3. Hook Listener The third piece is a rule that listens to [`OnClaimResolve`](/official-apps/returns/hooks) and turns the resolution and its metadata into actual effects — typically `orderCommand` effects targeting the connected order. The standard listener pattern: ```filtrera param input: { hook: 'OnClaimResolve' claimId: uuid orderId: uuid | nothing lines: [{ claimLineId: uuid resolution: text orderLineId: uuid | nothing acceptedQuantity: number productNumber: text | nothing metadata: { text -> value } }] } import 'iterators' import 'maps' // === compensatePercentage: emit a percentage order-line discount === from input.lines where is { orderLineId: uuid } where l => l.resolution == 'compensatePercentage' and l.acceptedQuantity > 0 select l => let percentage = l.metadata->'metadata_compensatePercentage' match (n: number) |> n |> 0 from percentage > 0 match true |> { effect = 'orderCommand' type = 'createStaticOrderLineDiscount' orderLineId = l.orderLineId isPercentage = true value = percentage / 100 description = 'Claim compensation' } where is not nothing // === compensateAmount: emit a fixed-amount order-line discount === from input.lines where is { orderLineId: uuid } where l => l.resolution == 'compensateAmount' and l.acceptedQuantity > 0 select l => let amount = l.metadata->'metadata_compensateAmount' match (n: number) |> n |> 0 from amount > 0 match true |> { effect = 'orderCommand' type = 'createStaticOrderLineDiscount' orderLineId = l.orderLineId isPercentage = false value = amount description = 'Claim compensation' } where is not nothing // === replace: create a new delivery + order line for the replacement product === let replaceLines = input.lines where is { orderLineId: uuid } where l => l.resolution == 'replace' and l.acceptedQuantity > 0 from replaceLines select l => let replaceProductNumber = l.metadata->'metadata_replaceProductNumber' match (v: text) |> v |> l.productNumber from { effect = 'orderCommand' type = 'createOrderLine' productNumber = replaceProductNumber quantity = l.acceptedQuantity unitPrice = 0 } ``` The condensed example above ignores some details that a real implementation needs (delivery routing for the replacement product, address handling, idempotency). ::: tip Order-bound `orderCommand`s `orderCommand` effects produced by `OnClaimResolve` listeners are applied to the connected order in a single transactional batch — your listener should not message the order directly. ::: ## Reject Reasons and Templates Claims and claim lines can also be **rejected** rather than resolved. Reject reasons are declared as a [global enum definition](/resources/graph/custom-fields#global-enum-definitions) so the same set of reasons is available whether the entire claim is rejected or only individual lines, and each reason can be paired with a localized message template that pre-fills the message-to-customer field in the portal. ### Reject reason value **Registry path:** `/enums/definitions/claimRejectReason/values/` | Property | Type | Required | Description | |---|---|:-:|---| | `label` | `{ default: text, : text, … }` | ✓ | Label shown in the reject-reason dropdown. | | `hue` | `number` | | Optional hue. | | `setKey` | `text` | | Optional category key — references a category at `/enums/definitions/claimRejectReason/categories/`. | The values are surfaced on the `rejectReason` graph field of both `ticket.claim` (whole-claim rejection) and `ticket.claim.line` (per-line rejection), which both declare `enumDefinition: claimRejectReason`. ### Reject message template **Registry path:** `/apps/returns/claims/rejectMessageTemplates/` The value is an i18n map of message strings keyed by locale, with `default` as the fallback: ```yaml - path: /enums/definitions/claimRejectReason/values/duplicate value: label: default: Duplicate claim - path: /apps/returns/claims/rejectMessageTemplates/duplicate value: default: 'Our records show that a previous claim has already been filed for the same issue.' sv: 'Vi har redan tagit emot en reklamation för samma fråga.' ``` When the agent selects a reject reason in the portal, the template for that reason in the order's locale is loaded into the message field, which the agent can then edit before sending. ## See Also * [Returns Hooks](/official-apps/returns/hooks) — `OnClaimResolve` reference. * [Returns Graph](/official-apps/returns/graph) — Where `resolution` and the `metadata_*` fields live. * [Custom Hooks](/resources/rules/trigger-hook) — Underlying `triggerHook` runtime function. --- --- url: /official-apps/returns/portal.md --- # Returns Portal Extensions The Returns app exposes portal extension points so other apps can contribute UI into the claim and RMA views. Two kinds of extension points are available: * **Slots** — passive insertion points for Vue components. Defined via `apps.defineSlot(id)`. * **Services** — typed contracts that providers can implement. Defined via `apps.defineService(id)`. Other apps consume them with `portal.registerView(...)`, `portal.registerService(...)`, etc. — see [Portal Extensions](/resources/apps/portal-extensions) for the general mechanism. This page is a reference for the IDs and contracts the Returns app declares. ## Slots Slots are rendered by the Returns app's views in fixed locations. A slot component receives **no host-provided context** — any other component context, props, or injected values are an internal implementation detail and may change without notice. ### `apps/returns/claim/summary` Rendered inside the claim summary panel, beneath the standard claim fields. ```typescript const claimSummary = apps.defineSlot('apps/returns/claim/summary') ``` Typical use: an app contributing additional metadata about the claim (e.g. linked external references) into the summary sidebar. ### `apps/returns/claim/line/resolution` Rendered inside the selected claim line's resolution section, immediately below the resolution dropdown and above the standard custom-fields block. ```typescript const claimLineResolution = apps.defineSlot('apps/returns/claim/line/resolution') ``` Typical use: an app contributing UI specific to its own resolution types — preview widgets, helper buttons, validation badges — that complement the registry-driven [custom field editors](/official-apps/returns/resolutions#custom-field-definitions). ### `apps/returns/rma/summary` Rendered inside the RMA summary panel, beneath the standard RMA fields. ```typescript const rmaSummary = apps.defineSlot('apps/returns/rma/summary') ``` ### `apps/returns/rma/line/inspection` Rendered inside the selected RMA line's inspection section. ```typescript const rmaLineInspection = apps.defineSlot('apps/returns/rma/line/inspection') ``` Typical use: an app contributing UI used during physical inspection — barcode scanners, condition checklists, photo-capture widgets — that augments the standard quantity-and-factor inputs. ## Services Services are typed contracts. A provider registers an implementation of the contract, and the consuming view calls the contract's methods. The Returns app declares two services, one per ticket type, both providing **inline warnings** that appear in the view header. Each provider returns a list of warnings, each consisting of a Vue component to render and a reactive `isActive` flag the host watches to show/hide the warning. ### `apps/returns/claimWarningProvider` ```typescript import { apps } from '@hantera/portal-app' import { ComputedRef } from 'vue' interface ClaimWarningProvider { build(context: ClaimViewContext): { isActive: ComputedRef component: any // Vue component } } const warningProvider = apps.defineService( 'apps/returns/claimWarningProvider' ) ``` The provider's `build` method is invoked once when the claim view is mounted. The returned `component` is rendered inside the claim header whenever `isActive.value` is `true`, and unmounted when it transitions back to `false`. The component receives no props — render any details you need from the same `ClaimViewContext` you captured in `build`. ### `apps/returns/rmaWarningProvider` ```typescript import { apps } from '@hantera/portal-app' import { ComputedRef } from 'vue' interface RmaWarningProvider { build(context: RmaViewContext): { isActive: ComputedRef component: any // Vue component } } const rmaWarningProvider = apps.defineService( 'apps/returns/rmaWarningProvider' ) ``` Identical shape, applied to the RMA view. ## View Contexts The two warning providers receive a view context that exposes the loaded claim/RMA state and a few mutation helpers. The contexts implement the same general pattern: a reactive `state` representing the current (possibly previewed) ticket, a buffered-change history with undo/redo, and explicit `save` / `complete` / `reject` / `refresh` operations that the host invokes from its toolbar. ### `ClaimViewContext` ```typescript import { Ref } from 'vue' import type { Commands, Models } from '@hantera/portal-app' interface ClaimViewContext { // === Current ticket state (reactive) === // Reflects the latest preview of the claim, including unsaved changes. readonly state: Claim // === Identity === readonly isNew: boolean readonly selectedLineId: Ref // === Reactive history flags (for toolbar) === readonly historyState: { hasPending: boolean canUndo: boolean canRedo: boolean } readonly workState: { isSaving: boolean isRefreshing: boolean } // === Buffered mutations === pushPendingChange( changeId: string | undefined, commands: Commands.Ticket.TicketCommand[], messageTemplate: string, messageDynamic?: Record ): void undo(): boolean redo(): boolean // === Server roundtrips === refresh(): Promise save(): Promise complete(): Promise reject(rejectReason?: string, rejectMessage?: string): Promise } interface Claim { ticketId: string ticketNumber: string channelKey: string tags: string[] ticketState: 'open' | 'completed' | 'rejected' dynamic: Record createdAt: Date closedAt: Date createdBy?: { identityId: string; type: string; name?: string } closedBy?: { identityId: string; type: string; name?: string } order: { orderId: string orderNumber: string currencyCode: string orderLines: OrderLine[] locale?: string notes?: string customerNumber?: string orderTotal: Decimal } claimLines: ClaimLine[] activityLogs: Models.Graph.ActivityLog[] rejectReason?: string rejectMessage?: string isPaused?: boolean } ``` `ClaimLine`, `OrderLine`, and related types follow the field surface documented in [Graph](/official-apps/returns/graph). ### `RmaViewContext` ```typescript interface RmaViewContext { readonly state: Rma readonly isNew: boolean readonly selectedLineId: Ref readonly historyState: { hasPending: boolean canUndo: boolean canRedo: boolean } readonly workState: { isSaving: boolean isRefreshing: boolean } pushPendingChange( changeId: string | undefined, commands: Commands.Ticket.TicketCommand[], messageTemplate: string, messageDynamic?: Record ): void undo(): boolean redo(): boolean refresh(): Promise save(): Promise complete(): Promise } interface Rma { ticketId: string ticketNumber: string channelKey: string tags: string[] ticketState: 'open' | 'completed' | 'rejected' dynamic: Record createdAt: Date closedAt: Date order: { orderId: string orderNumber: string orderLines: OrderLine[] currencyCode: string } claim?: { ticketId: string ticketNumber: string ticketState: 'open' | 'completed' | 'rejected' } rmaLines: RmaLine[] activityLogs: Models.Graph.ActivityLog[] } ``` ### Buffered-change model Both contexts use the same pattern for mutating the underlying ticket: 1. Code calls `pushPendingChange(changeId, commands, messageTemplate, messageDynamic)`. The change is appended to a local history and emitted as an `applyCommands` preview against the actor. 2. `state` is refreshed with the previewed result. No data has been written to the actor yet. 3. The user can `undo` / `redo` through the history, which re-previews against the actor on each step. 4. The host's toolbar eventually invokes `save()` (writes the buffered commands to the actor and re-queries), `complete()` (writes plus transitions the ticket to `completed`), or `reject()` (writes plus transitions to `rejected`). The optional `changeId` deduplicates rapid successive edits of the same field — a change pushed with an existing `changeId` replaces the previous one in the history, keeping the undo stack shallow. Pass `undefined` for one-off changes that should always create their own undo step. `messageTemplate` and `messageDynamic` together form the human-readable description that's written to the ticket's activity log when `save` / `complete` / `reject` flushes the buffered commands. They follow the standard [activity-log template format](/resources/graph/nodes/activity-log). ## See Also * [Portal Extensions](/resources/apps/portal-extensions) — `apps.defineSlot` / `apps.defineService` / `portal.registerService` mechanics. * [Returns Graph](/official-apps/returns/graph) — Underlying ticket and line graph schema reflected in the view contexts. * [Ticket commands](/resources/actors/custom/ticket/commands/) — The `Commands.Ticket.TicketCommand` types that `pushPendingChange` accepts. --- --- url: /official-apps/returns/api.md --- # Returns API Reference The Returns app exposes public HTTP ingresses for claim management. All endpoints are prefixed with `/ingress/returns/`. ::: tip Metadata convention Both endpoints follow the `metadata_` prefix convention: keys you pass under `metadata` on `createClaims` are stored as dynamic fields named `metadata_`, and `getClaimByOrder` strips the prefix back off when returning them. The same prefixed keys are surfaced on the [`OnClaimResolve`](/official-apps/returns/hooks) hook. ::: ## Order-Line Matching `createClaims` accepts an optional `orderLineId` per line. When omitted, the ingress auto-matches lines against the connected order's order lines using the following predicate: ```text ol.productNumber == line.productNumber AND ol.quantity >= (approvedClaimedQuantity + returnedQuantity + lineQuantity) ``` Where `approvedClaimedQuantity` is the sum of quantities from claim lines on this order line whose claim is already in the `completed` state. Lines that cannot be matched are still created — they just lack an `orderLine` relation, which is also valid for some resolution types (see [Resolution Types](/official-apps/returns/resolutions)). ## Error Codes | Code | Endpoint | Meaning | |---|---|---| | `NOT_FOUND` | both | The referenced `orderId` does not exist or the caller lacks access to it. | | `QUERY_ERROR` | `getClaimByOrder` | The underlying graph query failed. The original error is included in `message`. | --- --- url: /official-apps/tracking.md --- # Conversion Tracking A family of apps that report conversions to advertising and analytics platforms **server-side**, from the order rather than from the browser. Each app listens for the same order lifecycle events, maps them to its platform's event format, and sends them from a reactor. Server-side reporting exists because browser tags are unreliable: ad blockers, tracking prevention, and shoppers who close the tab before the thank-you page all lose conversions. The order actor, by contrast, always knows a purchase happened. Where a platform de-duplicates on a transaction identifier, these apps send the **order number**, so a browser tag firing for the same order collapses into one conversion instead of double-counting. ## Available tracking apps * **[Google Analytics 4](/official-apps/tracking/google-analytics/)** — GA4 Measurement Protocol. Sends `purchase` on order confirmation and `refund` as units are returned, attributed via the GA client id captured at the storefront. Reports to *several* properties per order — a master property plus the channel's own — for multi-site setups. * **[Meta Conversions](/official-apps/tracking/meta/)** — Meta Conversions API. Sends a `Purchase` event with hashed customer data, de-duplicated against the browser Pixel on the order number. * **[Awin](/official-apps/tracking/awin/)** — Awin server-to-server conversion tracking. Reports a sale when an order is confirmed, attributed via the Awin click value, with per-channel and per-country advertiser resolution. ## The tracking-id convention Every one of these apps needs something the *browser* knows and the server does not: a cookie value, a click id, a session id. The storefront captures it and puts it on the cart; Commerce carries it onto the order; the tracking app reads it off the order graph. The shared namespace for this is **`field:tracking:*`**, which [Commerce projects onto the order](/official-apps/commerce/dynamic-fields) as **`cart:tracking:*`**: | Meaning | Source | Cart field | Order field | Used by | |---|---|---|---|---| | GA client id | `_ga` cookie | `field:tracking:gaClientId` | `cart:tracking:gaClientId` | Google Analytics | | GA session id | `_ga_` cookie | `field:tracking:gaSessionId:G-XXXXXXXXXX` | `cart:tracking:gaSessionId:G-XXXXXXXXXX` | Google Analytics | | Meta browser id | `_fbp` cookie | `field:tracking:fbp` | `cart:tracking:fbp` | Meta | | Meta click id | `_fbc` cookie / `fbclid` | `field:tracking:fbc` | `cart:tracking:fbc` | Meta | | Awin click value | `awc` query parameter | `field:tracking:awc` | `cart:tracking:awc` | Awin | Commerce also captures a small amount of client context automatically when the cart is created, from the request headers — no storefront code required: | Meaning | Captured from | Cart field | Order field | |---|---|---|---| | Client user-agent | `User-Agent` header | `field:client:userAgent` | `cart:client:userAgent` | | Client IP | Resolved shopper IP | `field:client:ip` | `cart:client:ip` | | Storefront origin | `Origin`, falling back to `Referer` | `field:storefrontUrl` | `cart:storefrontUrl` | The client IP is captured at **creation** rather than completion because a cart is often completed by a PSP callback, whose IP is the payment provider's, not the shopper's. ::: tip Adding a new platform needs no Commerce change The namespace is vendor-neutral. A new tracking app picks a key under `field:tracking:`, the storefront stamps it, and it arrives on the order — no change to `apps.commerce` at all. ::: ::: warning The GA session id is keyed by measurement id `_ga` (the client id) is one cookie for the whole domain, but GA keeps session state in `_ga_` — **one cookie per measurement id**. A site tagged with several GA properties has several unrelated session ids, so the key carries the measurement id it belongs to. Sending one property's session id to another attributes the event to a session that does not exist there. ::: ### Capturing them at the storefront Reading these values and getting them onto the cart is a storefront concern, and it has real subtleties: the ids are available on the landing page before a cart exists, `awc` appears only on the first page view, and tag-manager tags usually run before your bundle loads. **→ [Conversion Tracking in the Storefront SDK docs](https://storefront.hantera.dev/tracking/)** covers the capture-and-buffer pattern, a `` queue stub for Google Tag Manager, and per-platform recipes. Storefronts not using the SDK write the same fields through the [`set-field`](/official-apps/commerce/cart-lifecycle#custom-fields) ingress directly: ```bash POST /ingress/commerce/carts/{cartId}/set-field/tracking:gaClientId { "value": "1234567890.1712345678" } ``` ### Why `field:` and not `order:` Commerce projects [three prefixes](/official-apps/commerce/dynamic-fields) onto the order, and the choice matters here: * **`field:` → `cart:`** — for values that originate at the **storefront**, i.e. from an untrusted client. The extra `cart:` namespace keeps them clearly separated from app-authored order fields, and they are echoed back in the rendered cart's `fields`. * **`order:` → ``** — for values an **app** authored server-side. These land unprefixed in the order's shared dynamic map, so they must be vendor-prefixed to avoid collisions. Tracking ids come from the browser, so they belong in `field:`. An app that derives its own attribution data server-side should use `order:` with a vendor-prefixed key instead. ## Reporting the conversion value The apps deliberately do **not** agree on what a conversion is worth, because the platforms don't: | App | Value basis | |---|---| | Google Analytics | Goods excl. tax **and excl. shipping**; tax and shipping sent as their own parameters | | Meta | Excl. tax, **incl. shipping** — the standard for the Purchase event | | Awin | Goods excl. tax **and excl. shipping** — commissionable value only | Each app follows its platform's convention rather than a house style, so the numbers line up with what the platform's own reporting expects. ## One target or many The apps also differ in how many destinations a single order reaches: | App | Destinations per order | |---|---| | Google Analytics | **Many.** Every [property](/official-apps/tracking/google-analytics/#properties-and-channel-routing) configured for the order's channel — typically a master property plus the channel's own | | Meta | One pixel | | Awin | One advertiser, resolved country → channel → app default | The multi-property case is specific to analytics, where a group-level roll-up alongside per-market properties is a standard reporting setup rather than an edge case. ## See Also * [Conversion Tracking (Storefront SDK)](https://storefront.hantera.dev/tracking/) — capturing and stamping the tracking ids * [Cart Dynamic Fields](/official-apps/commerce/dynamic-fields) — the underlying prefix mechanism * [Cart Lifecycle](/official-apps/commerce/cart-lifecycle) — where `set-field` fits in the flow --- --- url: /official-apps/tracking/google-analytics.md --- # Google Analytics 4 Server-side [GA4 Measurement Protocol](https://developers.google.com/analytics/devguides/collection/protocol/ga4) integration. The `tracking-ga` app sends a **`purchase`** event when an order is confirmed and a **`refund`** event as units are returned, so revenue is recorded even when the browser tag is blocked — and stays correct after returns. ## How it works | Trigger | Rule | Reactor | Event | |---|---|---|---| | Order becomes `confirmed` | `OnOrderConfirmed` | `ga.purchase.create` | `purchase` | | Order's returned quantity grows | `OnOrderReturned` | `ga.refund.create` | `refund` | Both reactors load the order, map it to the Measurement Protocol payload, and POST it to every [GA property](#properties-and-channel-routing) configured for the order's channel. ## Properties and channel routing Properties are managed in the portal under **GA Properties**, in the system section of the navigation hub. | Field | Meaning | |---|---| | Measurement ID | `G-XXXXXXXXXX` of the data stream. Required. | | API Secret | Measurement Protocol API secret. Required, stored as a secret. | | Channels | The channel keys this property receives. Leave empty to use for all channels | | Region | Optional. `region1` keeps *this property's* collection in the EU. | | Enabled | Off ⇒ configured but not sending. | | Label | Optional display name. | It's possible to have overlapping channels, resulting in the same order reporting to multiple properties. ### Worked example A tenant with Swedish and Norwegian storefronts and a group-level roll-up: | Property | Routing | Receives | |---|---|---| | `master` | Receive all channels | Every order | | `se` | Channels: `b2c_se` | Swedish orders | | `no` | Channels: `b2c_no` | Norwegian orders | A Swedish order is reported twice — to `master` and to `se`. A Norwegian order goes to `master` and `no`. Adding a third market means adding one property; the master keeps working untouched, because it names no channels. ### Resolution rules * An order goes to **every enabled property** that either receives all channels or names the order's channel. * A property missing its measurement id or API secret is **skipped**, so one half-configured property never stops the others from being reported. * If **no** property matches the order's channel, nothing is sent and the job result says so. ::: warning There is no default property The app reports nothing until at least one property exists — installing it is not enough. A single-site tenant creates one property with **Receive all channels** switched on. ::: ### Registry layout The portal view is a front-end for registry entries, so properties can equally be provisioned from a manifest or the API. Each property is a group of entries under `apps/tracking-ga/properties//`, one entry per field: ``` apps/tracking-ga/properties/master/measurementId = 'G-AAAAAAAAAA' apps/tracking-ga/properties/master/apiSecret = •••• (secret) apps/tracking-ga/properties/master/allChannels = true apps/tracking-ga/properties/se/measurementId = 'G-BBBBBBBBBB' apps/tracking-ga/properties/se/apiSecret = •••• (secret) apps/tracking-ga/properties/se/channels = ['b2c_se'] apps/tracking-ga/properties/se/region = 'region1' ``` One entry per field, rather than one object per property, is what lets `apiSecret` be flagged as a secret in its own right. The app discovers properties by enumerating the `measurementId` entries, so a property exists as soon as it has one. The property key is used in the per-property refund marker, so it is restricted to lowercase letters, digits and underscores. ## Attribution GA4 ties a server-side event to a shopper through the `client_id` its browser tag stores in the `_ga` cookie. The storefront captures it and stamps it on the cart; Commerce forwards it onto the order: | Meaning | Scope | Cookie | Order field | |---|---|---|---| | GA client id | Whole domain | `_ga` | `cart:tracking:gaClientId` | | GA session id | **One per property** | `_ga_` | `cart:tracking:gaSessionId:G-XXXXXXXXXX` | See [Conversion Tracking in the Storefront SDK docs](https://storefront.hantera.dev/tracking/) for how to capture and send them. ### The session id is per property The client id identifies the shopper across the whole domain, so one value serves every property. Session state does not: GA keeps it in a `_ga_` cookie, **one per measurement id**. A site tagged with a master property and a channel property therefore has two unrelated session ids, and each property's event must carry its own: ``` cart:tracking:gaSessionId:G-AAAAAAAAAA → sent to the master property cart:tracking:gaSessionId:G-BBBBBBBBBB → sent to the channel property ``` Sending one property's session id to another is **worse than sending none**: it asks GA to attach the event to a session that does not exist in the receiving property, so the purchase lands outside the shopper's real session and its campaign, device and geography attribution is wrong. Omitting it merely falls back to the client's latest session. ::: info Backwards compatibility The unsuffixed `cart:tracking:gaSessionId` is still read, but **only when exactly one property receives the order** — the one case where it is unambiguous. ::: ::: warning An order with no client id is not tracked No job is scheduled for such orders at all — the rules check the client id before scheduling. Sending one under a made-up client id would report the sale as a brand-new user with no campaign or session attribution, quietly corrupting acquisition reports, so the app declines to guess. The reactors keep the same check, since a job can also be scheduled by hand; in that case the job result reads `Order … has no GA client id; skipping`. ::: The session id is optional but recommended: with it, the event is attributed to the session the purchase actually happened in, so campaign, geography and device dimensions come from that visit rather than the client's latest state. GA accepts it within 24 hours of the session starting. ## De-duplication `transaction_id` is the **order number**. GA4 de-duplicates purchases on it, so a browser tag firing its own `purchase` for the same order collapses into one transaction rather than double-counting revenue. No coordination between the tag and the server is needed beyond both using the order number. ## The purchase event | Parameter | From | |---|---| | `transaction_id` | Order number | | `currency` | Order currency | | `value` | Goods total, **excl. tax and excl. shipping** | | `tax` | `orderTaxTotal` | | `shipping` | Net shipping across the order's deliveries | | `coupon` | First `commerce_couponCodes` value, if any | | `items[]` | One per order line: `item_id`, `item_name`, `price`, `quantity` | | `user_id` | Order's customer number, when set | `value` follows GA4's spec — the sum of `price × quantity` across `items`. Tax and shipping travel in their own parameters so reporting shows them separately instead of inflating revenue. Line sales totals already carry any discount, so the value reflects what the shopper actually paid. ## The refund event Returns are reported as they happen, from any source — the portal, an ERP integration, or [Returns](/official-apps/returns/) resolving a claim — because they all land as `return` on the order. The event carries `items`, which GA4 treats as a **partial** refund. That is what a per-line return always is, even when every line happens to be returned. `value` and `tax` come from the returns' own refunded amounts, on the same net basis as the purchase. ### Idempotency, per property GA4 does **not** de-duplicate refunds — two identical refund events are two deductions from revenue. The app therefore tracks what it has already reported, and because the same refund goes to several properties, it tracks that **per property**. Each return carries one marker per property that received it: ``` return.dynamic: gaRefundSentAt_master = 2026-08-10T09:15:00Z gaRefundSentAt_se = 2026-08-10T09:15:00Z ``` A shared marker would not survive a partial failure. If `master` accepts the event and `se` times out, a single marker either records the refund as done — losing it from `se` forever — or leaves it undone and double-deducts from `master` on retry. With per-property markers, each run computes each property's own unsent set, so a retry sends exactly what is still missing, to exactly the properties still missing it. A property's marker is written only after **that property** accepts the event, so a failed send is retried in full for that property alone. Nothing is marked in [Debug Mode](#verifying), since those events never reach the reports. Separate top-level field names also make the markers collision-safe, because `setDynamicFields` merges per top-level key. ## Settings Measurement ids, API secrets, regions and channel routing belong to individual [properties](#properties-and-channel-routing). Only genuinely global switches are app settings: | Setting | Description | |---|---| | `enabled` | Master switch. When off, the reactors do nothing. | | `debugMode` | Routes events to Google's validation server instead of collecting them. | | `trackRefunds` | When off, only purchases are reported. | The app is inert until `enabled` is on **and** at least one property is configured, so installing it has no effect until it is set up. ## Verifying Turn on **Debug Mode**. Events then go to Google's validation server instead of your reports, and the job result carries the `validationMessages` array — an empty array means the payload is valid. Events sent in this mode also carry `debug_mode`, so they appear in **Admin → DebugView**. With Debug Mode off, events are collected normally and show up under **Reports → Realtime** within seconds. The job result names every property it reached, so a fan-out is visible at a glance: ``` Sent purchase event for order LS123456 to 2 GA property/properties: master sent; se sent ``` If one property fails the job fails and is retried, but the result still reports each property's own outcome — so a broken API secret on one property is immediately distinguishable from a systemic problem. ## See Also * [Conversion Tracking](/official-apps/tracking/) — the app family and shared conventions * [Conversion Tracking (Storefront SDK)](https://storefront.hantera.dev/tracking/) — capturing the GA client id and per-property session ids * [Cart Dynamic Fields](/official-apps/commerce/dynamic-fields) — how the ids reach the order --- --- url: /official-apps/tracking/meta.md --- # Meta Conversions Server-side [Meta Conversions API](https://developers.facebook.com/docs/marketing-api/conversions-api) integration. The `tracking-meta` app sends a **`Purchase`** event when an order is confirmed, so conversions are attributed even when the browser Pixel is blocked. ## How it works 1. `OnOrderConfirmed` observes orders and, on the `not confirmed → confirmed` transition, schedules the reactor with the order number. 2. `meta.purchase.create` loads the order and builds the event, which the `meta.events` [egress](#outbound-traffic) POSTs to `https://graph.facebook.com/{apiVersion}/{pixelId}/events`. ## De-duplication The server event's `event_id` is the **order number**, the same value the browser Pixel sends for its own Purchase. Meta collapses the two into a single conversion, so running both the Pixel and this app does not double-count. ## Customer data & privacy Customer parameters (`em`, `ph`, `fn`, `ln`, `ct`, `zp`, `country`, `external_id`) are normalized (lowercased and trimmed) and **SHA-256 hashed** to lowercase hex per Meta's spec before being sent. `external_id` is the order's customer number. `fbp`, `fbc`, the client user-agent and the client IP are sent raw, as Meta expects. Any field that is missing is omitted rather than sent empty — that only affects Meta's *event match quality*, never whether the event is accepted. ### Tracking ids The identifiers come off the order under the shared `cart:` namespace, having been [stamped on the cart by the storefront](https://storefront.hantera.dev/tracking/): | Meaning | Order field | |---|---| | Meta browser id (`_fbp`) | `cart:tracking:fbp` | | Meta click id (`_fbc`) | `cart:tracking:fbc` | | Client user-agent | `cart:client:userAgent` | | Client IP | `cart:client:ip` | | Storefront origin | `cart:storefrontUrl` | The user-agent, client IP and storefront origin are captured automatically when the cart is created; only `fbp` and `fbc` need storefront code. Without them the event still works off the hashed customer data, at lower match quality. `event_source_url` is taken from `cart:storefrontUrl`, falling back to the channel registry's `storefrontBaseUrl`. ## Conversion value `custom_data.value` is reported **excl. tax**, but **incl. shipping** — the standard for Meta's Purchase event. For tax-inclusive orders the tax is stripped (`orderTotal − orderTaxTotal`); tax-exclusive totals are already net. Per-item `contents[].item_price` stays at the catalog listing price, since those feed product matching rather than bidding optimization. ::: info Different platforms, different values The [Google Analytics app](/official-apps/tracking/google-analytics/) excludes shipping from its conversion value and reports it separately, following GA4's own convention. ::: ## Settings | Setting | Secret | Description | |---|---|---| | `enabled` | no | Master switch. When off, the reactor does nothing. | | `pixelId` | no | Meta Pixel / Dataset ID that receives the events. | | `accessToken` | yes | Conversions API access token from Events Manager. | | `apiVersion` | no | Graph API version, e.g. `v19.0` (default). | | `testEventCode` | no | Optional. Routes events to the Test Events view. | The app is inert until `enabled` is on and both `pixelId` and `accessToken` are set, so installing it has no effect until it is configured. ## Verifying Set `testEventCode` and place a test order, or schedule the reactor directly for a known order number. The event then appears under **Events Manager → Test Events**. ## See Also * [Conversion Tracking](/official-apps/tracking/) — the app family and shared conventions * [Conversion Tracking (Storefront SDK)](https://storefront.hantera.dev/tracking/) — capturing `fbp` and `fbc` * [Cart Dynamic Fields](/official-apps/commerce/dynamic-fields) — how the ids reach the order --- --- url: /official-apps/tracking/awin.md --- # Awin Server-to-server [Awin](https://www.awin.com/) conversion tracking. The `tracking-awin` app reports a **sale** when an order is confirmed, so affiliate conversions are recorded without relying on a browser tag. ## How it works 1. `OnOrderConfirmed` observes orders and, on the `not confirmed → confirmed` transition, schedules the reactor with the order number — but only if the app is enabled and the order carries an Awin click value, so an order that could never produce a sale never becomes a queued job. 2. `awin.sale.create` loads the order and, **only if it carries an Awin click value**, hands the sale to the `awin.sale` [egress](#outbound-traffic), which sends a GET to Awin's `sread.php` endpoint. ## Attribution: the Awin click value Awin appends `awc=…` to the storefront landing URL when a shopper arrives through an affiliate link. The storefront [captures and stamps it](https://storefront.hantera.dev/tracking/); Commerce forwards it onto the order: | Meaning | Order field | |---|---| | Awin click value | `cart:tracking:awc` | An order without an `awc` did not originate from an Awin affiliate link, so no sale is reported for it — and no job is scheduled either, since the rule checks the click value before scheduling. ::: tip Capture it on the landing page `awc` only appears on the first page view of the affiliate visit. Store it (for example in session storage) as soon as the shopper lands, and stamp it once a cart exists. ::: ## Per-channel and per-country advertiser The advertiser id — and optionally the commission group — resolves from the order's channel and billing country, so one tenant can route each storefront channel, and each country within it, to its own Awin advertiser account. Both are configured in the **Channel editor**, under the collapsible "Awin conversion tracking" section: at the channel level, and per country. Resolution order, most specific first: 1. **Country override** — `awinAdvertiserId` on `channels/{channelKey}/countries/{cc}` 2. **Channel value** — `awinAdvertiserId` on the channel 3. **Global default** — the `advertiserId` app setting `commissionGroup` resolves the same way, finally falling back to `DEFAULT`. The country used is the order's **invoice recipient** country. If no advertiser id resolves for an order, it is not tracked — the reactor skips it quietly. ## The sale request The sale is reported as a single GET (`tt=ss` normal sale, `ch=aw` Awin channel): ``` https://www.awin1.com/sread.php ?tt=ss &tv=2 &merchant={advertiserId} &amount={amount} &ch=aw &parts={commissionGroup}:{amount} &vc={voucher} &cr={currencyCode} &ref={orderNumber} &testmode={0|1} &cks={awc} ``` * **amount** is the goods value **excl. tax and excl. shipping**, rounded to two decimals. Tax-inclusive orders have their tax stripped; net shipping is then subtracted per delivery. * **parts** assigns the full amount to a single commission group. * **vc** is the first `commerce_couponCodes` value, if any. * **ref** is the order number — Awin de-duplicates on it. ## Settings | Setting | Description | |---|---| | `enabled` | Master switch. When off, the reactor does nothing. | | `advertiserId` | Default Awin advertiser (merchant) id, used when a channel or country has no override. | | `commissionGroup` | Default commission group code for `parts` (default `DEFAULT`). Overridable per channel and country. | | `testMode` | When on, sends `testmode=1` so the sale is excluded from reporting. | ## Verifying Enable `testMode` and schedule the reactor for a known order number that carries `cart:tracking:awc`. The sale then shows up in Awin's interface as a test transaction. ## See Also * [Conversion Tracking](/official-apps/tracking/) — the app family and shared conventions * [Conversion Tracking (Storefront SDK)](https://storefront.hantera.dev/tracking/) — capturing `awc` * [Cart Dynamic Fields](/official-apps/commerce/dynamic-fields) — how the id reaches the order --- --- url: /api.md --- # API Reference Hantera's API is organized into four surfaces. The HTTP and WebSocket surfaces are the *transports* — the **messages** and **commands** catalogs describe the operational surface available through those transports. ## HTTP REST-style endpoints for resources such as actors, apps, files, ingresses, jobs, registry entries and rules. A small set of these endpoints — most importantly `POST /resources/actors/{type}/{externalId}` — is what you use to dispatch all actor messages. The catalog of those messages is documented under [Messages](/api/messages), not under HTTP. [Browse HTTP endpoints →](/api/http/) ## Messages Messages are how you interact with **actors**, the business entities in Hantera (orders, payments, SKUs, custom actors). Every mutation and every actor query goes through a message. Each actor type defines its own set of messages. [Browse all actor messages →](/api/messages) ## Commands Commands are atomic mutations applied to a single actor, batched inside a message such as `applyCommands`. A batch either succeeds as a whole or is rejected with no changes. [Browse all actor commands →](/api/commands) ## WebSocket Streaming endpoint for live queries and event subscriptions. [WebSocket reference →](/api/websocket) ## How they fit together ```http POST /resources/actors/order/ORD12345 Content-Type: application/json [{ "type": "applyCommands", "body": { "commands": [ { "type": "addTag", "key": "blocked" } ] } }] ``` In this single HTTP request: * The **HTTP** surface is the `POST /resources/actors/{type}/{externalId}` endpoint. * The **message** is `order.applyCommands`. * The **command** is `order.addTag`. The actor model means that most of Hantera's API surface lives in the message and command catalogs, not in the list of HTTP endpoints. If you came here looking for "what can I do with an Order?", start with [Messages](/api/messages) and [Commands](/api/commands). See also: [Actors overview](/resources/actors/) for the conceptual model. --- --- url: /api/http.md --- # HTTP API Reference Browse the full HTTP API documentation. Use the sidebar to navigate to individual operations. --- --- url: /api/messages.md --- # Actor Messages Messages are how you interact with actors. Each actor type defines its own set of messages for mutations and queries; in addition, every actor accepts a small set of [common messages](#common) (`delete`, `getCheckpoints`, `rewind`). All messages are dispatched through a single HTTP endpoint: ```http POST /resources/actors/{type}/{externalId} ``` The request body is an array of messages, processed sequentially. Append `?preview` to run them in a transient transaction without persisting changes. See the [Actors overview](/resources/actors/) for the conceptual model and the full [Send message(s) to actor](/api/http/post-resources-actors-%7Btype%7D-%7BexternalId%7D.html) endpoint reference for the wire format. --- --- url: /api/commands.md --- # Actor Commands Commands are atomic mutations applied to a single actor. They are not dispatched directly — instead they are batched inside a message (most commonly `applyCommands`, but some other messages accept commands as well). All commands in a batch execute as one atomic operation: if any command fails, the entire batch is rejected and no changes are applied. Commands only mutate state within the actor that receives them; they never have side effects on other actors. For cross-actor effects, use messages. ```http POST /resources/actors/order/ORD12345 Content-Type: application/json [{ "type": "applyCommands", "body": { "commands": [ { "type": "addTag", "key": "blocked" } ] } }] ``` See the [Actors overview](/resources/actors/) for the conceptual model. --- --- url: /api/websocket.md description: >- Complete reference for all WebSocket message types — client-to-server and server-to-client --- # WebSocket API Reference This page documents every message type in the Hantera WebSocket protocol. For a conceptual overview and usage guide, see [WebSocket](/learn/websocket). **Endpoint:** `wss://{hostname}/ws` All messages are JSON text frames. Binary frames are not supported. ## Base Structure Every message has a `type` field identifying its kind. ```typescript interface BaseMessage { type: string requestId?: string // Optional correlation ID (requests only) } ``` Clients may include `requestId` in request messages. The server echoes it back in the response. *** ## Client → Server Messages ### `auth` Authenticate the connection. Must be the first message sent after connecting. ```typescript interface AuthMessage { type: 'auth' token: string } ``` | Field | Type | Required | Description | |---------|--------|----------|---------------------------------------| | `type` | string | Yes | `"auth"` | | `token` | string | Yes | Bearer token without `"Bearer "` prefix | **Example:** ```json { "type": "auth", "token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..." } ``` **Response:** [`authenticated`](#authenticated) or [`error`](#error) (`AUTH_FAILED`) *** ### `pong` Response to a server [`ping`](#ping). Must be sent within 30 seconds of receiving a ping. ```typescript interface PongMessage { type: 'pong' } ``` **Example:** ```json { "type": "pong" } ``` *** ### `subscribeEvents` Subscribe to one or more event streams. Requires an authenticated connection. ```typescript interface SubscribeEventsMessage { type: 'subscribeEvents' requestId?: string subscriptions: EventSubscription[] } interface EventSubscription { id: string // Client-assigned unique identifier path: string // Resource path (e.g. "jobs", "actors/orders") events: string[] // Event types to receive } ``` | Field | Type | Required | Description | |-----------------|-----------------------|----------|-----------------------------| | `type` | string | Yes | `"subscribeEvents"` | | `requestId` | string | No | Correlation ID for response | | `subscriptions` | EventSubscription\[] | Yes | Subscriptions to add | **Subscription fields:** | Field | Type | Required | Description | |----------|----------|----------|--------------------------------------------------------| | `id` | string | Yes | Client-assigned unique ID for this subscription | | `path` | string | Yes | Resource path (see [Event Streaming](/learn/websocket#event-streaming)) | | `events` | string\[] | Yes | Event types to receive | **Example:** ```json { "type": "subscribeEvents", "requestId": "req-1", "subscriptions": [ { "id": "all-jobs", "path": "jobs", "events": ["jobScheduled", "jobStarted", "jobCompleted", "jobFailed"] } ] } ``` **Response:** [`subscribedEvents`](#subscribedevents) or [`error`](#error) *** ### `unsubscribeEvents` Remove one or more event subscriptions. ```typescript interface UnsubscribeEventsMessage { type: 'unsubscribeEvents' requestId?: string ids: string[] } ``` | Field | Type | Required | Description | |-------------|----------|----------|-------------------------------------| | `type` | string | Yes | `"unsubscribeEvents"` | | `requestId` | string | No | Correlation ID for response | | `ids` | string\[] | Yes | Subscription IDs to remove | **Example:** ```json { "type": "unsubscribeEvents", "ids": ["all-jobs"] } ``` **Response:** [`unsubscribedEvents`](#unsubscribedevents) or [`error`](#error) *** ### `createLiveQuery` *(Experimental)* Create a server-side reactive query. The server streams the initial result set and sends incremental updates as data changes. ```typescript interface CreateLiveQueryMessage { type: 'createLiveQuery' id: string // Client-assigned unique identifier format?: string // Response format. Default: "default" query: GraphQuery } interface GraphQuery { edge: string // Graph edge name (e.g. "orders", "skus") filter?: string // Filtrera filter expression orderBy?: string // Order-by string in standard Graph format (e.g. "createdAt desc") } ``` | Field | Type | Required | Description | |----------|-------------|----------|-------------------------------------------------| | `type` | string | Yes | `"createLiveQuery"` | | `id` | string | Yes | Client-assigned unique ID for this live query | | `format` | string | No | Response format. Default: `"default"` | | `query` | GraphQuery | Yes | The graph query to run and watch | **`query` fields:** | Field | Type | Required | Description | |-----------|--------|----------|---------------------------------------------------------------| | `edge` | string | Yes | Graph edge to query (e.g. `"orders"`) | | `filter` | string | No | [Filtrera](/learn/fundamentals) filter expression | | `orderBy` | string | No | Sort order in standard Graph format (e.g. `"createdAt desc"`) | **Example:** ```json { "type": "createLiveQuery", "id": "lq-active-orders", "query": { "edge": "orders", "filter": "status == 'processing'", "orderBy": "createdAt desc" } } ``` **Response:** [`liveQueryCreated`](#livequerycreated) followed by [`liveQueryData`](#livequerydata) messages, or [`error`](#error) *** ### `destroyLiveQuery` *(Experimental)* Destroy a live query and release its server-side resources. ```typescript interface DestroyLiveQueryMessage { type: 'destroyLiveQuery' id: string } ``` | Field | Type | Required | Description | |--------|--------|----------|---------------------------| | `type` | string | Yes | `"destroyLiveQuery"` | | `id` | string | Yes | Live query ID to destroy | **Example:** ```json { "type": "destroyLiveQuery", "id": "lq-active-orders" } ``` **Response:** [`liveQueryDestroyed`](#livequerydestroyed) or [`error`](#error) *** ## Server → Client Messages ### `authenticated` Successful authentication acknowledgment. ```typescript interface AuthenticatedMessage { type: 'authenticated' } ``` **Example:** ```json { "type": "authenticated" } ``` *** ### `ping` Server-initiated keep-alive. Respond with [`pong`](#pong) within 30 seconds. ```typescript interface PingMessage { type: 'ping' timestamp: string // ISO 8601 } ``` **Example:** ```json { "type": "ping", "timestamp": "2025-12-07T21:30:00.000Z" } ``` *** ### `error` An error response to a client request, or a fatal connection error. ```typescript interface ErrorMessage { type: 'error' code: string message: string requestId?: string // Echoed from the request that caused the error details?: unknown } ``` | Field | Type | Description | |-------------|---------|------------------------------------------| | `type` | string | `"error"` | | `code` | string | Machine-readable error code | | `message` | string | Human-readable description | | `requestId` | string | Correlation ID from the request, if any | | `details` | unknown | Additional context | **Error codes:** | Code | Description | Connection closed? | |--------------------------|--------------------------------------------------|--------------------| | `AUTH_REQUIRED` | First message was not `auth` | Yes | | `AUTH_FAILED` | Invalid or expired token | Yes | | `MESSAGE_TOO_LARGE` | Message exceeds the size limit | No | | `INVALID_MESSAGE` | Malformed JSON or missing required field | No | | `UNKNOWN_MESSAGE_TYPE` | Unrecognised message type | No | | `INVALID_PATH` | Subscription path not recognised | No | | `INVALID_SCOPE` | Invalid event types for the given path | No | | `SUBSCRIPTION_NOT_FOUND` | `unsubscribeEvents` referenced an unknown ID | No | | `TOO_MANY_SUBSCRIPTIONS` | Per-connection event subscription limit reached | No | | `TOO_MANY_LIVE_QUERIES` | Per-connection live query limit reached | No | | `LIVE_QUERY_NOT_FOUND` | `destroyLiveQuery` referenced an unknown ID | No | | `FORBIDDEN` | Insufficient permissions for the requested data | No | | `INTERNAL_ERROR` | Unexpected server error | No | **Example:** ```json { "type": "error", "code": "INVALID_PATH", "message": "Unknown resource path: widgets", "requestId": "req-5" } ``` *** ### `warning` A non-fatal notification, typically indicating backpressure. ```typescript interface WarningMessage { type: 'warning' code: string message: string subscriptionId?: string } ``` | Field | Type | Description | |------------------|--------|------------------------------------------| | `type` | string | `"warning"` | | `code` | string | Warning code | | `message` | string | Human-readable description | | `subscriptionId` | string | Affected subscription ID, if applicable | **Warning codes:** | Code | Description | |------------------|--------------------------------------------------------| | `QUEUE_OVERFLOW` | Events were dropped due to slow client consumption | **Example:** ```json { "type": "warning", "code": "QUEUE_OVERFLOW", "message": "5 events dropped for subscription 'all-jobs' due to slow consumption", "subscriptionId": "all-jobs" } ``` *** ### `subscribedEvents` Confirmation that event subscriptions have been registered. ```typescript interface SubscribedEventsMessage { type: 'subscribedEvents' requestId?: string subscriptions: SubscriptionConfirmation[] } interface SubscriptionConfirmation { id: string path: string events: string[] } ``` **Example:** ```json { "type": "subscribedEvents", "requestId": "req-1", "subscriptions": [ { "id": "all-jobs", "path": "jobs", "events": ["jobScheduled", "jobStarted", "jobCompleted", "jobFailed"] } ] } ``` *** ### `unsubscribedEvents` Confirmation that event subscriptions have been removed. ```typescript interface UnsubscribedEventsMessage { type: 'unsubscribedEvents' requestId?: string ids: string[] } ``` **Example:** ```json { "type": "unsubscribedEvents", "ids": ["all-jobs"] } ``` *** ### `event` An event notification for registered subscriptions. ```typescript interface EventMessage { type: 'event' subscriptionIds: string[] eventType: string path: string data: unknown timestamp: string // ISO 8601 } ``` | Field | Type | Description | |-------------------|----------|-------------------------------------------| | `type` | string | `"event"` | | `subscriptionIds` | string\[] | IDs of the subscriptions that matched | | `eventType` | string | Specific event type (e.g. `jobStarted`) | | `path` | string | Full path of the affected resource | | `data` | unknown | Event payload (varies by event type) | | `timestamp` | string | ISO 8601 timestamp of the event | **Job event data shapes:** ```json { "jobId": "550e8400-e29b-41d4-a716-446655440000", "jobDefinitionId": "sync-inventory", "scheduledAt": "2025-12-07T21:45:00.000Z", "parameters": { "source": "api" } } ``` ```json { "jobId": "550e8400-e29b-41d4-a716-446655440000", "jobDefinitionId": "sync-inventory", "startedAt": "2025-12-07T21:45:01.000Z" } ``` ```json { "jobId": "550e8400-e29b-41d4-a716-446655440000", "jobDefinitionId": "sync-inventory", "finishedAt": "2025-12-07T21:45:05.000Z", "elapsedMs": 4000.5, "result": { "itemsSynced": 150 } } ``` ```json { "jobId": "660e8400-e29b-41d4-a716-446655440001", "jobDefinitionId": "sync-inventory", "finishedAt": "2025-12-07T21:46:00.000Z", "elapsedMs": 1500.0, "error": "Connection timeout" } ``` ```json { "jobDefinitionId": "sync-inventory", "bucketTime": "2025-12-07T21:00:00.000Z", "scheduled": 45, "successful": 40, "failed": 2, "minExecution": 120.5, "maxExecution": 1250.0, "avgExecution": 450.3 } ``` ```json { "checkpointId": "770e8400-e29b-41d4-a716-446655440002", "actorType": "orders", "actorId": "550e8400-e29b-41d4-a716-446655440000", "identityId": "880e8400-e29b-41d4-a716-446655440003", "timestamp": "2025-12-07T21:47:00.000Z" } ``` *** ### `liveQueryCreated` *(Experimental)* Acknowledgment that a live query was created. Followed immediately by one or more [`liveQueryData`](#livequerydata) messages. ```typescript interface LiveQueryCreatedMessage { type: 'liveQueryCreated' id: string totalCount: number | null capped: boolean } ``` | Field | Type | Description | |--------------|----------------|----------------------------------------------------| | `type` | string | `"liveQueryCreated"` | | `id` | string | Live query ID (echoed from `createLiveQuery`) | | `totalCount` | number | null | Total number of matching nodes, or `null` if unknown | | `capped` | boolean | `true` if result set was truncated at the record cap | **Example:** ```json { "type": "liveQueryCreated", "id": "lq-active-orders", "totalCount": 42, "capped": false } ``` *** ### `liveQueryData` *(Experimental)* A batch of nodes from the initial result set. Sent in sequence after `liveQueryCreated`. When `hasMore` is `false`, the initial load is complete and the query is now tracking changes. ```typescript interface LiveQueryDataMessage { type: 'liveQueryData' id: string data: unknown[] hasMore: boolean capped: boolean } ``` | Field | Type | Description | |----------|-----------|------------------------------------------------------| | `type` | string | `"liveQueryData"` | | `id` | string | Live query ID | | `data` | unknown\[] | Batch of graph nodes | | `hasMore`| boolean | `false` on the final batch | | `capped` | boolean | `true` if the result set was truncated at the record cap | **Example:** ```json { "type": "liveQueryData", "id": "lq-active-orders", "data": [ { "id": "550e8400-...", "status": "processing", "createdAt": "2025-12-07T10:00:00Z" } ], "hasMore": true, "capped": false } ``` *** ### `liveQueryAddedNode` *(Experimental)* A node has entered the live query's result set (either newly created or its data changed to match the filter). ```typescript interface LiveQueryAddedNodeMessage { type: 'liveQueryAddedNode' id: string nodeId: string data: unknown } ``` **Example:** ```json { "type": "liveQueryAddedNode", "id": "lq-active-orders", "nodeId": "660e8400-e29b-41d4-a716-446655440001", "data": { "id": "660e8400-...", "status": "processing", "createdAt": "2025-12-07T11:00:00Z" } } ``` *** ### `liveQueryUpdatedNode` *(Experimental)* A node in the live query's result set has changed. ```typescript interface LiveQueryUpdatedNodeMessage { type: 'liveQueryUpdatedNode' id: string nodeId: string data: unknown } ``` **Example:** ```json { "type": "liveQueryUpdatedNode", "id": "lq-active-orders", "nodeId": "550e8400-e29b-41d4-a716-446655440000", "data": { "id": "550e8400-...", "status": "processing", "total": 299.99 } } ``` *** ### `liveQueryRemovedNode` *(Experimental)* A node has left the live query's result set (either deleted or its data no longer matches the filter). ```typescript interface LiveQueryRemovedNodeMessage { type: 'liveQueryRemovedNode' id: string nodeId: string } ``` **Example:** ```json { "type": "liveQueryRemovedNode", "id": "lq-active-orders", "nodeId": "550e8400-e29b-41d4-a716-446655440000" } ``` *** ### `liveQueryDestroyed` *(Experimental)* Confirmation that a live query has been destroyed. ```typescript interface LiveQueryDestroyedMessage { type: 'liveQueryDestroyed' id: string } ``` **Example:** ```json { "type": "liveQueryDestroyed", "id": "lq-active-orders" } ``` *** ## Close Codes | Code | Name | Description | |--------|-----------------|--------------------------------| | `1000` | Normal Closure | Clean disconnect by client | | `1001` | Going Away | Server shutting down | | `1009` | Message Too Big | Message exceeded size limit | | `4001` | Auth Timeout | No `auth` message within 10s | | `4002` | Ping Timeout | No `pong` received within 30s | | `4003` | Max Connections | Connection limit reached | ## TypeScript Types ```typescript // Client → Server type ClientMessage = | AuthMessage | PongMessage | SubscribeEventsMessage | UnsubscribeEventsMessage | CreateLiveQueryMessage | DestroyLiveQueryMessage // Server → Client type ServerMessage = | AuthenticatedMessage | PingMessage | ErrorMessage | WarningMessage | SubscribedEventsMessage | UnsubscribedEventsMessage | EventMessage | LiveQueryCreatedMessage | LiveQueryDataMessage | LiveQueryAddedNodeMessage | LiveQueryUpdatedNodeMessage | LiveQueryRemovedNodeMessage | LiveQueryDestroyedMessage interface GraphQuery { edge: string filter?: string orderBy?: string } ``` --- --- url: /release-notes/2026-2.md --- # 2026.2 ## Platform & Core ### `OnValidate` Rule Hooks A second rule-evaluation phase now runs **after** `OnCommands`, available for all actor types that already expose a `*Commands` hook: `Order`, `Payment`, `Asset`, `Ticket`, and `Sku`. `OnCommands` rules all run in parallel and see the same `before` snapshot — none of their emitted commands are visible to the others. That breaks when one rule's output is another rule's input. The validate phase closes this gap by running once, after the `*Commands`-emitted commands have been applied, with the fully-settled actor state in scope. A validate rule can either: * **Reject** the state by emitting a `common.validationError` effect, or * **Auto-fix** it by emitting more commands. ```filtrera param input: { hook: 'OnOrderValidate' before: Order order: Order } ``` `before` is the state before the incoming message's commands were applied; `order` (or `payment`/`asset`/`ticket`/`sku`) is the state after the `*Commands` phase. The validate phase runs a single pass — there is no second `*Commands` phase afterward and no validate loop. If validation errors are returned, the actor state is reset and the message fails, identical to `*Commands` behavior. A typical use case is inventory routing: an enrichment rule copies SKU data onto order lines in `OnOrderCommands`, and a routing rule subscribed to `OnOrderValidate` reliably sees that data when assigning deliveries to inventories. ### Standardized Currency Model Currency is now modeled as a property of a **bounded context** rather than a dimension of individual values. Orders, invoices, and payments are each single-currency contexts — every monetary value within one shares the same currency — which keeps all platform-internal math inside a single currency and avoids mixed-currency arithmetic entirely. Currencies are defined as registry entries at `currencies/`: ```yaml currencies/SEK: label: 'Swedish Krona' decimals: 2 exchangeRate: 1.0 currencies/EUR: label: 'Euro' decimals: 2 exchangeRate: 11.5 ``` * **Codes are arbitrary identifiers** following the ISO 4217 convention by recommendation, but any string matching `^[A-Za-z0-9_]{1,16}$` is accepted (e.g. `BTC`, `LOYALTY_POINTS`). * **`exchangeRate` is a normalized scalar** — the rate between two currencies A and B is `rate(B) / rate(A)`. There is no system-defined base currency. * **Invoices snapshot the rate.** At invoice creation, the platform reads `currencies/.exchangeRate` once and writes it to the invoice's `exchangeRate` field, where accounting requires a pinned, point-in-time value. This is the only place the platform reads the registry rate at runtime — orders deliberately do not store a rate. * **Formatting is presentation-layer** — symbol placement, separators, and decimals are derived in the browser via `Intl.NumberFormat`, not stored in the registry. Cross-currency conversion remains a reporting concern: the platform never auto-converts between currencies. A new **Currencies** settings view in the portal lets you manage currencies natively — add ISO currencies from a picker (prefilling label and decimals) or define custom codes, with inline editing of labels, decimals, and exchange rates. ### Conventional Inventory Address Model Inventory and delivery addresses now follow a standardized, conventional model rather than ad-hoc per-app shapes. A consistent address structure across deliveries and inventories makes routing, validation, and integration logic portable between apps and removes the need for each app to define its own address conventions. ### `inventoryKey` and `inventoryDate` Removed from Deliveries The `inventoryKey` and `inventoryDate` fields are no longer built-in **system** fields on **deliveries**. They are now **conventional custom fields** on the delivery, aligning delivery inventory data with the same extensibility model used elsewhere in the platform. The delivery's `inventoryDate` custom field supports the date-only type, consistent with the date-only planning-date semantics introduced in 2026.1. Stock models — stock positions, reservations, and incoming stock — are **unaffected** and continue to carry `inventoryKey` and `inventoryDate` as system fields. ### `invoiceAddress` Renamed to `invoiceRecipient` The `invoiceAddress` field on orders has been renamed to `invoiceRecipient`. The new name better reflects that the value identifies *who* is invoiced — a recipient that carries address and identity details — rather than a plain address. Graph and search fields are updated accordingly. ::: warning This is a **breaking change**. Update any API requests, queries, or components that reference `invoiceAddress` to use `invoiceRecipient`. ::: *** ## App Development ### App Contracts — `requires:` Declarations Apps can now **declare the external surfaces they depend on** through a `requires:` block in `h_app.yaml`. When an app is installed, its components are compiled in an isolated context that cannot see anything outside the app's own boundary — which previously meant an app could not type-check against graph shapes or modules contributed by another app. `requires:` closes that gap without breaking isolation. Declared dependencies are merged into the isolated compile context as **stubs**, giving the compiler enough information to type-check: ```yaml # in h_app.yaml requires: graph: { ... } # graph shapes (sets / fields / edges) from other apps modules: { ... } # exported module symbols and their types ``` * **Graph requirements are provider-agnostic** — any app contributing the required shape satisfies them, regardless of its id. * **Module requirements** remain bound to a specific producer by URI. * **Phased validation** — at install time the server compares the declared `requires.graph` against the tenant's actual state and emits warnings; at activation it recompiles the consumer's components against the real producer modules, surfacing any type mismatch as a standard Filtrera error that blocks activation. This also enables the language server to resolve cross-app modules and graph shapes offline, using only the app's own manifest plus the embedded base graph. *** ## Component Runtimes ### Optional Record Fields Filtrera now has first-class syntax for optional record fields: `field?: T`. Previously, optionality was expressed by convention as `T | nothing`, which conflated two distinct meanings — "this field may be absent" versus "this field is present but holds `nothing`". ```filtrera let Patch: { name: text | nothing // required field; nothing means "explicitly cleared" email?: text // optional field; absence means "no change" } ``` `field?: T` desugars to a union of records over the power-set of optional fields, so the existing union-walking and pattern-matching logic handles it naturally: ```filtrera r match { email?: text } |> ... ``` ::: warning This is a **breaking change**. Scripts that relied on `T | nothing` to mean "optional field" will fail to type-check and must migrate to `field?: T`. Use `field: T | nothing` only when a field is required but may explicitly hold `nothing`. ::: *** ## Graph & Queries ### Graph Query `require` Modes The `require` flag on graph navigations now accepts an enum value for finer control over edge presence: * **`any`** — 0 or more edges may be present * **`some`** — 1 or more edges must be present * **`none`** — exactly 0 edges must be present Boolean values continue to work: `true` maps to `some` and `false` maps to `any`. The query editor lets you set the `require` value per navigation. The `none` mode is especially useful for orphan and stale-source cleanup queries — for example, finding entities whose referenced edge no longer exists. ### Query Macro Chaining Fix Query macros can now be chained correctly. Previously, chaining a query macro produced an error. *** ## Ingresses ### Raw HTTP Ingress Responses [HTTP ingresses](/resources/ingresses/http/) can now produce **raw HTTP responses**, giving the component full control over the response status, headers, and body — rather than only returning component output serialized as JSON. This enables ingresses that serve HTML, redirects, custom content types, or non-standard status codes. *** ## General Numerous **bug fixes** and **performance improvements** throughout the platform, portal, and design system. --- --- url: /release-notes/2026-1.md --- # 2026.1 ## Platform & Core ### Inventories Resource Inventories are now a first-class resource at `/inventories/`. Each inventory has a label and can be extended by apps via slots. Previously, inventory keys were free-form text values with no central definition — now they have a home. Inventory definitions automatically feed into graph enum dropdowns, so fields like `inventoryKey` on deliveries and stock positions show labeled options without any manual configuration. ### Enhanced Channel Configuration [Channels](/resources/registry/) can now declare which inventories they support and which countries they serve. This enables: * **Filtered inventory selection** — When setting the inventory on a delivery, only inventories assigned to the order's channel are shown * **Filtered country selection** — Address forms show only countries configured on the channel If a channel has no inventories or countries configured, all available options are shown (preserving existing behavior). Per-country records within a channel are extensible via app slots, allowing apps to attach carrier accounts, tax identifiers, or other country-specific data. ### Promotions A new **Promotions** entity on orders replaces the `DiscountType.Computed` concept. Promotions are a first-class entity with their own graph node, separate from static discounts. A Promotion runs a Filtrera script against the order state and can produce two types of output: * **Discount effects** — Percentage or fixed discounts distributed to order lines and deliveries as calculated discounts * **Promotional messages** — Structured messages stored on the order for consuming apps and storefronts Additional capabilities: * **Combination rules** — Promotion groups with allowlist/blocklist rules to control which promotions can be active together * **Dynamic fields** — Arbitrary metadata for tracking and integration * **Parameters** — Typed inputs to the promotion script * **Localized labels** — Human-readable names with per-language translations **Breaking change**: `DiscountType.Computed` is removed. Static discounts (`Absolute` and `Percentage`) continue to work as before. The `CalculatedDiscount` entity now uses `ReferenceId` and `ReferenceType` fields instead of `DiscountId` to identify whether the calculated discount originated from a static discount or a promotion. ### Rule API — REST with ETag Concurrency The Rule API is now **REST-based**, enabling proper versioning and concurrency control. Rules can now be updated using `If-Match` ETag headers to prevent conflicting concurrent modifications — the same pattern used by the IAM API. ### `moveOrderLine` to New Delivery Command A new `moveOrderLine` order command allows moving an order line to a different delivery within the same order. ### Inventory Dates `inventoryDate` and `expectedAt` fields across deliveries, stock positions, reservations, and incoming stock have been changed from timestamps to **date-only values** (`YYYY-MM-DD` format in JSON). These are planning dates that represent *which day* stock is expected or needed — not precise points in time — and the date-only format aligns with ERP/MRP conventions and eliminates timezone ambiguity. **Breaking change**: API consumers that currently send or receive `"2026-03-17T00:00:00Z"` for these fields must update to `"2026-03-17"`. ### Global Enum Value Sources Enum values for graph fields can now be **defined once and shared** across multiple fields and nodes. Previously, every enum-typed field had to have its values configured independently. Two types of shared sources are now supported: * **System sources** — Inventories, channels, and currencies automatically provide their keys as enum options for any field that references them, without manual configuration * **User-defined sources** — Define custom named enum types at `/enums/definitions//values/` and reference them from multiple custom graph fields Existing per-field enum values at `/enums/graph///values/` continue to work unchanged. *** ## Ingresses ### Server-Sent Events (SSE) [HTTP ingresses](/resources/ingresses/http/) now support **Server-Sent Events**. When a reactor component returns a Filtrera iterator and the client sends `Accept: text/event-stream`, the server establishes an SSE connection and streams each iterator item to the client as it is produced. A new `events()` runtime keyword provides an **infinite iterator** that waits for and yields matching EventHub events, enabling live data streams: ```filtrera param orderId: uuid from events( $'actors/orders/{orderId}' 'checkpoint' ) select event => { status = event.data.status timestamp = event.timestamp } ``` Initial data and live events can be combined using standard Filtrera `flatten`: ```filtrera let initMessages = [{ type = 'init', data = order }] let eventMessages = events ($'actors/orders/{orderId}', 'checkpoint') select event => { type = 'update', data = event.data } from [initMessages, eventMessages] flatten ``` SSE ingresses support the `public: true` flag, enabling unauthenticated event streams for use cases like order tracking pages. The browser's built-in `EventSource` API handles reconnection automatically. *** ## Component Runtimes ### `calculateAvailableStock` Macro A new `calculateAvailableStock` macro is available in both **rule** and **reactor** runtimes. It sends a stock availability request to the SKU actor and returns a `{ text -> number }` map of available quantity per inventory key — replacing the need to write complex graph queries against stock positions, reservations, and allocations manually. ```filtrera // All inventories, with date cutoff for incoming stock let stock = calculateAvailableStock (orderLine.skuNumber, delivery.inventoryDate) from stock->'wh_stockholm' // 50 // Specific inventories only let stock = calculateAvailableStock ( orderLine.skuNumber, delivery.inventoryDate, ['wh_stockholm', 'wh_gothenburg'] ) // With allocation keys — includes stock allocated to these keys let stock = calculateAvailableStock ( orderLine.skuNumber, delivery.inventoryDate, ['wh_stockholm'], ['vip_allocation'] ) ``` The `asOf` parameter accepts a **date** (not a timestamp), consistent with the inventory date change above. ### `triggerHook` and Custom Effects A new `triggerHook` filter is available in both **rule** and **reactor** runtimes, enabling apps to define domain-specific hook points that other rules can subscribe to — enabling a simple form of dependency injection. The calling rule or reactor triggers a hook by passing a data record and a hook name. All matching rules are evaluated against the hook input, and their effects are returned as an iterator for the caller to inspect, filter, and apply: ```filtrera // Calling rule — trigger the hook and collect effects let hookEffects = { claimId = claimId, orderId = orderId, lines = resolvedLines } triggerHook 'OnClaimResolve' // Filter and batch order commands from hook listeners let hookOrderCommands = hookEffects where e => e.effect == 'orderCommand' ``` Listening rules declare a `param input` with a literal `hook` field type — Filtrera's type system ensures the rule is only evaluated for matching hooks: ```filtrera param input: { hook: 'OnClaimResolve' claimId: uuid orderId: uuid lines: [{ ... }] } ``` `triggerHook` is single-level: a rule triggered via `triggerHook` cannot itself call `triggerHook` (it returns an empty iterator), preventing unbounded recursion. *** ## Jobs ### Batch Scheduling Jobs can now be **scheduled in batches** — creating multiple scheduled job instances in a single operation. This is useful for pre-populating a schedule or queuing a large number of similar jobs without making individual API calls. ### Improved Job Scheduling Parameter Assistance The job scheduling API now provides **assisted parameter input**, helping callers supply correctly typed and structured parameters when scheduling jobs. Job definitions expose their parameter schema, making it easier to construct valid scheduling requests. *** ## Portal ### Inventories & Channels Management New **Settings** views for managing inventories and channels: * **Inventories** — Create and configure inventories with labels. App slots allow installed apps to attach additional fields (e.g. warehouse system IDs) to each inventory definition * **Channels** — Assign available inventories and countries to each channel. Per-country records support app slots for carrier accounts, tax rules, or other country-specific configuration ### Basic App Maintenance A new **Apps** section in the portal provides basic management of installed apps: view installed apps, their status, and perform common maintenance operations without needing CLI access. *** ## API & WebSocket ### Unified `/ws` Endpoint The Events API has moved to a new unified WebSocket endpoint at `/ws`. The previous `/events` endpoint is replaced. Both the Events API and the new Live Queries API share this single connection, authenticated once with a bearer token. ::: warning If you are connecting to the Events API, update your WebSocket URL from `/events` to `/ws`. ::: ### Live Queries (Experimental) ::: info Live Queries are released as **experimental** in this version. Not all graph query features are supported yet, and the API may evolve before the final release. ::: **Live Queries** enable real-time reactive data in portal apps and external integrations. Create a live query over the `/ws` WebSocket endpoint and the server maintains the query state, pushing updates as the underlying data changes — no polling required. When a matching entity is created, updated, or removed, the server sends a targeted update message containing only the affected node. Clients maintain a local list by applying add/update/remove messages as they arrive. ```typescript const { available, query } = useLiveQueries() // Reactive list of active orders — updates automatically const { nodes, loading, totalCount } = query({ edge: 'orders', filter: "status == 'readyForPicking'", orderBy: 'createdAt asc' }) ``` The `useLiveQueries` composable (via `@hantera/portal-app`) automatically disposes the query when the component is unmounted and re-establishes it on reconnect. *** ## Developer Experience ### Dev Mode Evaluation Tracing `h_ app dev` now captures **real-time evaluation traces** for all Filtrera components belonging to your dev app — rules, reactors, discounts — and streams them to the CLI as they run. Every evaluation prints a summary line in the console: ``` ⚡ priceRule [orders/abc123/onUpdate] 3712 step(s) 🌐 webhook [my-app/webhook.hrc] POST /api/hook → 200 (45ms) ``` For deeper inspection, the optional `--trace ` flag writes complete JSONL trace files containing every evaluated AST node, resolved symbols, and ingress request/response data: ```bash h_ app dev ./my-app --trace ./eval.trace ``` Each evaluation produces a flat, ordered list of nodes with `id`/`parentId` references for reconstructing the evaluation tree. Ingress logs capture full request and response bodies (up to 1 MB). Trace files can be opened in any JSON Lines-aware viewer for timeline analysis and debugging. *** ## General Numerous **bug fixes** and **performance improvements** throughout the platform, portal, and design system. --- --- url: /release-notes/2025-3.md --- # 2025.3 ## Platform & Core ### Checkpoints & Rewind API All actors now support **checkpoints and rewind** through two new messages: `getCheckpoints` and `rewind`. Every time commands are applied to an actor, a checkpoint is automatically created, forming a complete history of all state changes. Key capabilities include: * **View historical states** — Retrieve all checkpoints for any actor and preview what the actor looked like at any point in time using preview mode * **Non-destructive rewind** — Restore an actor to a previous checkpoint while preserving the full mutation history. The rewind itself becomes a new checkpoint, so you can even rewind a rewind * **Preview-only permissions** — A dedicated `rewind:preview` permission allows support personnel to investigate historical states without the ability to modify data [Learn more about Checkpoints & Rewind →](/resources/actors/checkpoints) ### Sendings Resource Class A new **Sendings** resource class provides a centralized communication queue for delivering emails, with SMS and push notifications planned for the future. Sendings track each message through its entire delivery lifecycle — from pending through sent or bounced — with automatic retries and rate limiting. Sendings can be created via the Filtrera `sendEmail` function in components and rules, or through the REST API for external integrations. Custom data can be attached to sendings and queried through the Graph API for analytics and monitoring. [Learn more about Sendings →](/resources/sendings) ### Audit Logs A new **audit logging** system has been introduced, providing system-level traceability of actions across the platform. Audit logs track who performed what actions and when, enabling compliance monitoring, security auditing, and operational troubleshooting. ### IAM APIs and Portal UI The [Identity & Access Management](/resources/iam/) system now includes comprehensive **REST APIs** and **portal UI** for managing identities, roles, and permissions. Key capabilities include: * **Principal management** — Create, update, suspend, and delete user identities with role assignments * **Client management** — Configure OAuth clients for application and service-to-service authentication * **Role management** — Define custom roles with granular permission sets * **ETag-based concurrency** — Safe concurrent updates using `If-Match` headers to prevent conflicting modifications [Learn more about IAM →](/resources/iam/) ### Event Streaming API ::: info The Event Streaming API is released as a **preview** and is subject to change. Message formats and subscription paths may evolve before the final release. ::: A new **WebSocket-based event streaming API** enables real-time notifications about changes in your system. Connect to the `/events` endpoint to receive live events as they happen. Supported event sources include: * **Jobs** — Track job lifecycle events (scheduled, started, completed, failed) * **Job statistics** — Live aggregated counters per job definition (success/failure rates, execution times) * **Actors** — Receive checkpoint events when orders, payments, SKUs, tickets, or assets change The API uses subscription-based filtering so clients only receive the events they need, with built-in keep-alive and backpressure handling. [Learn more about Event Streaming →](/learn/event-streaming) *** ## Graph ### `anyof` Operator for All Fields The `anyof` [filter](/resources/graph/filtering) operator can now be used on **scalar fields** in addition to array fields. Previously, `anyof` was limited to array fields. This enables more flexible filtering — for example, matching orders where a single-value field equals any of several candidates: ``` orderState anyof ['pending', 'confirmed'] ``` *** ## Portal ### Rewind from Order Timeline Orders can now be **rewound to a previous checkpoint** directly from the order view's visual timeline in the portal. This provides an intuitive way for operators to view an order's history and restore it to an earlier state when needed, without leaving the order view. ### Portal User Profile A new **user profile** page has been added to the portal, allowing users to manage their personal information: * **Profile picture** and **display name** * **Email address** — Change the email used for login * **Password** — Update login password ### Jobs Statistics and Management Views New portal views and APIs for **monitoring and managing jobs**: * **Statistics dashboard** — View job execution metrics including success/failure rates, execution times, and trends per job definition * **Job management** — Browse, inspect, cancel, and retry jobs directly from the portal * **Statistics API** — Programmatic access to job statistics for building custom dashboards and alerting ### Live App Development The [Hantera CLI](/learn/hantera-cli) now supports a **live development mode** for apps. When connected to a Hantera instance, all server-side app changes — components, job definitions, registry entries, rules, and more — are **published in real-time** as you save files. This complements the existing Vite-based portal development mode (for UI) by adding live development for all backend aspects of an app. When you disconnect from the development session, all app resources are automatically cleaned up. --- --- url: /release-notes/2025-2.md --- # 2025.2 ## Platform & Core ### Stock Management The [Sku actor](/resources/actors/sku/) has been extended with comprehensive stock management capabilities. Stock locations, reservations, back orders, and allocations are now first-class concepts within the Sku lifecycle. Additionally, order lines now support a **skuMap** that carries the exact SKUs needed to fulfill a line, enabling precise inventory tracking from order to delivery. ### Custom Routes for HTTP Ingresses [HTTP ingresses](/resources/ingresses/http/) now support **custom route definitions** with route parameters, decoupling the HTTP route from the ingress resource ID. Routes can include dynamic segments (e.g., `api/orders/{orderId}`) that are automatically extracted and bound to component parameters. Previously, ingress routes were determined by convention from the ingress resource ID. With this change, you have full control over your API's URL structure. [Learn more about HTTP Ingresses →](/resources/ingresses/http/) ### Improved Tax Model and Invoicing The invoicing system has been **rebuilt for resilience**, addressing edge cases around tax calculations and discount handling. The updated tax model now supports **absolute tax values** alongside VAT-based percentage tax factors, enabling more accurate tax modeling across different regions and scenarios. ### Changes to Assets and Tickets Two changes to [custom actors](/resources/actors/custom/): * **Custom asset numbers** — Asset actors now support custom-generated asset numbers, giving you control over your asset identification scheme * **Required type key** — Assets and tickets now require a type key to be specified at creation, ensuring consistent classification from the start ### Improved Apps Support The [app](/resources/apps/) system has been expanded with additional capabilities: * **Registry entries** — Apps can now include [registry](/resources/registry/) entries, allowing them to configure platform behavior as part of installation * **Rules** — Apps can now bundle [rules](/resources/rules/), enabling apps to react to system events and enforce business logic out of the box * **Cross-resource references** — Components within an app can reference modules included in the same app and use graph customizations that result from the app's own registry entries *** ## Component Runtimes ### Rule Modules The rule runtime now supports **importing modules**, enabling reusable code to be shared across multiple rules. This reduces duplication and makes it easier to maintain complex rule logic by extracting common patterns into shared modules. ### `dynamicQuery` Macro A new `dynamicQuery` macro provides an alternative to the typed [`query`](/resources/components/runtimes/keywords/query) keyword. Unlike the standard query macro which validates the query structure at compile time and returns a typed result, `dynamicQuery` accepts the query as a **dynamic record** and uses **pattern matching** to parse the result. This is particularly useful when the final query needs to be assembled at runtime based on state or input parameters — for example, building queries where the selected fields or filters vary depending on user input. ### Filtrera Update Components now run on the **latest Filtrera build**, incorporating language-level improvements and bug fixes. *** ## Graph ### New Graph Fields The following fields have been added to the [graph](/resources/graph/): | Node | Field | Description | |------|-------|-------------| | Delivery | `finalizedAt` | Timestamp when the delivery was finalized | | Order | `cancelledAt` | Timestamp when the order was cancelled | | OrderLine | `salesQuantity` | The sales quantity for the order line | | Ticket | `closedAt` | Timestamp when the ticket was closed | ### Dimension Key Format [Dimension](/learn/dimensions) keys in the graph are now available **exactly as configured**. Previously, dimension keys were automatically camel-cased when exposed through the graph. This change ensures consistency between how dimensions are defined and how they appear in query results. ::: warning If you have existing queries or integrations that rely on camel-cased dimension keys, you may need to update them to match the exact key format as defined in your configuration. ::: ### Identities in Graph Identity data is now **queryable through the graph**, making it possible to include identity information in graph queries alongside other business entities. *** ## Portal ### Stack View Manager A new **Stack view manager** has been introduced as an alternative to the tab-based Workspace. The Stack view manager uses a stack-style navigation that is optimized for **small screens and embedded scenarios** — such as rendering Hantera inside a Zendesk ticket view or other constrained environments. ### Improved Responsiveness General responsiveness improvements across the portal, ensuring a better experience across different screen sizes and devices. ### Copy Address from Order View You can now **copy addresses** directly from the order detail view, making it quick and easy to grab delivery or invoice addresses for use in other systems. ### Excel Export from Queries Query results can now be **exported to Excel** directly from the portal, providing a convenient way to extract data for reporting and analysis. *** ## Design System & Portal App Development ### `useGraphDataProvider` A new `useGraphDataProvider` composable is available in `@hantera/portal-app`, making it easy to create **DataTables backed by a graph query**. This significantly simplifies the process of building data-driven views in portal apps. ### New Components Several new components have been added to the design system: * **NavigationBar** — An alternative to TabBar for section-level navigation * **MessageTemplate** — A component for rendering structured message templates * **ActivityLog** — A component for displaying chronological activity entries * **ElasticInput** — A text input that dynamically resizes to fit its content ### Design System Improvements * **Box CSS classes** — New utility classes for common box/container patterns * **DecimalInput** — Now supports a `lazy` modifier for deferred value updates * **DropdownMenu** — Automatically resizes when content changes and properly mounts/unmounts its children when opening and closing, improving both performance and correctness ### Global Portal Components New globally available components for portal app developers (via `@hantera/portal-app`): * **FilesImage** — Display images from the Hantera [Files](/resources/files/) resource * **EmbeddedView** — Embed other portal views within your app's interface * **ProductSelector** — A ready-made product selection component for building order-related workflows --- --- url: /release-notes/2025-1.md --- # 2025.1 ## Platform & Core ### HTTP Ingresses A new [ingress](/resources/ingresses/) resource type has been introduced, starting with HTTP ingresses. HTTP ingresses allow you to expose [reactor components](/resources/components/) as HTTP endpoints, enabling external systems to interact with Hantera through standard HTTP requests. Key capabilities include: * **HTTP method configuration** — Support for GET, POST, PUT, PATCH, and DELETE * **Public endpoints** — Optionally expose ingresses without authentication for webhooks and public-facing APIs * **Query parameters and headers** — Map incoming HTTP query parameters and headers directly to component parameters [Learn more about HTTP Ingresses →](/resources/ingresses/http/) ### Files A new **Files** resource type has been added to the platform. Files can be uploaded, managed, and referenced by other resources, providing a first-class way to handle file storage within Hantera. ### Enum Values and Categories Hantera now supports defining **enum values** and **categories** as core concepts. These are configured through the [registry](/resources/registry/) and can be used across the platform to standardize option sets and classification of entities. ### `messageActor` Batch Preview The `messageActor` runtime keyword now supports **previewing multiple messages in a single transaction**. This allows components to prepare and validate several actor messages together before committing, reducing round-trips and enabling atomic batch operations. ### Transparent Paging in Reactor Queries [Graph queries](/resources/graph/) executed from within reactor components now handle **paging automatically**. Previously, developers had to manually manage page cursors when working with large result sets. The runtime now transparently fetches all requested records behind the scenes — you can use `first` or `take` with any count and the system pages internally. This is especially valuable when generating large data exports such as XML streams, where the transparent paging ensures efficient memory management without any additional code. ### Improved Phrase Search Performance The [graph phrase search](/resources/graph/phrase-search) engine has received significant performance improvements, resulting in faster search results across all indexed nodes. *** ## Filtrera Language ### Streams and XML Reading/Writing Filtrera now includes support for **stream-based processing** as well as **XML reading and writing**. This enables components to work with XML data natively — parsing incoming XML payloads and generating XML output — using Filtrera's stream primitives for efficient, memory-conscious processing of large documents. *** ## Apps ### Apps Can Define Reactors [App](/resources/apps/) manifests now support declaring **reactor components** as part of the app package. This means apps can ship custom automation logic alongside their rules, registry entries, job definitions, and files — making apps a more complete unit of extensibility. ### App Sidebar Extensions Apps can now **extend the portal sidebar** with custom navigation items. This allows installed apps to integrate seamlessly into the portal's navigation, giving users quick access to app-specific functionality directly from the sidebar. ### App Views Apps can now define and render **custom views** within the Hantera portal. Combined with sidebar extensions, this enables apps to provide fully integrated user experiences — custom pages, dashboards, and workflows — that feel native to the portal. *** ## Jobs ### Dynamic Job Arguments Job arguments have been upgraded to **dynamic fields**. This brings two key improvements: 1. **Queryable arguments** — Graph queries can now select individual job arguments as columns, making it easy to inspect and report on job data 2. **Custom filterable fields** — Define custom fields on job arguments that can be used to quickly filter and find jobs in the portal and via the API [Learn more about Jobs →](/resources/jobs/) *** ## Portal ### Dedicated Orders, Deliveries, and Invoices Views The portal now includes **dedicated views** for browsing orders, deliveries, and invoices. Previously, finding these required constructing manual graph queries. The new views provide purpose-built interfaces with integrated filtering and detail panels for day-to-day operational use. ### Revamped Query UX The query builder has been **completely redesigned** with an improved editor, streamlined navigation, column configuration options, save/export capabilities, and a more intuitive overall workflow. ### Improved Workspace Navigation The portal's main workspace — the tree-based navigation that organizes your views and tools — has been improved with better structure, smoother interactions, and more intuitive navigation patterns. ### Improved Order View Responsiveness The order detail view now uses the **InspectorLayout** from the design system, which provides a responsive layout that adapts gracefully between desktop and mobile screen sizes. On larger screens, the summary sidebar and tab content are displayed side by side; on smaller screens, they collapse into a unified tabbed interface. ### User Settings A new **User Settings** section has been added to the portal, allowing users to configure personal preferences including: * **Language** — Choose the display language for the portal interface * **Date and number formatting** — Control how dates and numbers are rendered to match regional conventions *** ## Design System ### New Components Several new components have been added to the Hantera design system: * **DatePicker** — A full-featured date picker component for date selection * **EnumsBar** — A component for displaying and selecting enum values in a compact bar format * **ListDetailLayout** — A split-view layout that displays a data table alongside a detail panel, with support for horizontal/vertical splitting and responsive collapsing on smaller screens * **Tree** — A tree component for rendering hierarchical data with expand/collapse behavior ### Redesigned Interactive Controls All **buttons**, **toggles**, and **dropdowns** across the design system have received a visual redesign for improved consistency, accessibility, and aesthetics. *** ## General Numerous **bug fixes** and **performance improvements** throughout the platform, portal, and design system. --- --- url: /404.md description: Page not found. Check the URL or try using the search bar. --- # 404 --- --- url: /resources/components/runtimes/keywords/absolute.md --- # absolute The `absolute` keyword creates a fixed-amount discount effect. It takes a target (from [`target`](/resources/components/runtimes/keywords/target) or [`order`](/resources/components/runtimes/keywords/order)) and a monetary amount, and returns a discount effect record. The amount is in the order's currency. Whether it's treated as including or excluding tax depends on the order's `taxIncluded` setting. ## Availability ## Examples #### Fixed 50 off the entire order ```filtrera from absolute(order, 50) ``` #### Fixed 10 off each qualifying order line ```filtrera from absolute(target(e => e is OrderLine and e.quantity >= 3), 10) ``` #### Fixed shipping discount ```filtrera from absolute(target(e => e is Delivery), 25) ``` #### Conditional absolute discount ```filtrera param couponValue: number from order.total >= 200 match true |> absolute(order, couponValue) false |> nothing ``` ## See Also * [percentage](/resources/components/runtimes/keywords/percentage) — apply a percentage discount * [target](/resources/components/runtimes/keywords/target) — select which parts of the order to discount * [order](/resources/components/runtimes/keywords/order) — target the entire order * [Order Promotions](/resources/actors/order/promotions) — how promotions work --- --- url: /resources/components/runtimes/types/activity-log.md --- # ActivityLog ## Definition ```filtrera let ActivityLog: { activityLogId: uuid createdAt: instant dynamic: {text->value} messageTemplate: text userId: uuid } ``` ## Availability --- --- url: /resources/graph/nodes/activity-log.md description: '' --- # activityLog Graph Node Root Set Name: `activityLogs` --- --- url: /resources/actors/actor-extensions.md --- # Actor Extensions Actor Extensions customize and extend the default behavior of [Actor](/resources/actors/) resources. They are based on Type definitions to model data more accurately. To be clear, a **typed actor** is an actor with a custom type. For instance, the Ticket Actor can have a Support Ticket type. Although Hantera supports several Actor classes, **Extensions only apply to Asset and Ticket Actors** because they model general business data. In contrast, the [Order](/resources/actors/order/), [Payment](/resources/actors/payment/), and [Sku](/resources/actors/sku/) Actors are for specific use cases. Order models data such as orders, invoices, and deliveries. Therefore, it lacks the flexibility to represent data outside of its scope. In contrast, the [Asset](/resources/actors/custom/asset/) and [Ticket](/resources/actors/custom/ticket/) actors generalize well to custom data. Through Type definitions, developers can define custom types and relations in the [Registry](/resources/registry/). As an example, Ticket can have Support, RMA, Complaint, and Shipment Ticket types. ::: tip * Use Ticket for data valid for a set period * Use Asset to hold long-term data ::: Actor Extensions are important because they: * Can model any kind of business data * Accurately model custom business objects that don’t fit special-use actors * Extend the Graph by creating more nodes and edges to map data relationships ## Main Features of an Actor Type An Actor type must be defined in the Registry before it is used. When defining new types, these are the main features: #### 1. Type Key The Actor `type key` is a unique global name that points to the type's node in the Graph. It is used to query instances of the type. #### 2. Items An Item is an arbitrary entity named by the type definition. It is another node that maps to the specified Actor type. A typed Actor may have many types of Items. It can also have many instances of each Item. Just like the main Actor, each Item must have a type key. In addition, it may have Relations with other nodes too. #### 3. Relations A type can define Relations to other nodes in the Graph. Each Relation will manifest as an edge to the related nodes. This means that a typed Actor’s related Graph node can contain Edges not normally supported by the Actor. Items can also define Relations, allowing for complex and accurate modeling of business data. While the Actor’s main Graph node automatically generates Edges for the Actor’s typed Items, Relations could be added between types of Items as well. See the example below. ::: tip What's New? As of version 2025.2, **Actor Type Keys are now required** for Actor types, Items, and Relations to be properly indexed by the Graph. If absent, you may get an [`UNDEFINED__TYPE`](/resources/actors/errors/) error. ::: #### How to create Items and Relations Create Items and Relations by sending a Message to an Asset or Ticket. The `applyCommands` message mutates the state of Asset and Ticket Actor instances by applying the specified methods to them. Valid Commands used to create and modify Items and Relations are the following (these links point to the Ticket version of these commands, but they are identical with other Actors): * [`createItem`](/resources/actors/custom/ticket/commands/create-item) * [`createItemRelation`](/resources/actors/custom/ticket/commands/create-item-relation) * [`createRelation`](/resources/actors/custom/ticket/commands/create-relation) * [`deleteItem`](/resources/actors/custom/ticket/commands/delete-item) * [`deleteItemRelation`](/resources/actors/custom/ticket/commands/delete-item-relation) * [`deleteRelation`](/resources/actors/custom/ticket/commands/delete-relation) ::: info When creating items and relations in an actor, the `typeKey`, `itemTypeKey` and `relation` must be defined, otherwise you will get an [INVALID\_COMMAND](/resources/actors/errors/#:~:text=INVALID_COMMAND) error.\ ::: ## How Actor Types Extend the Graph? The [Graph](/resources/graph/) mirrors Actor type definitions and relationships with other entities. It automatically generates nodes and edges based on custom type definitions. An Asset/Ticket Type is a node in the Graph. Once a new Asset/Ticket Type is defined in the Registry, the Graph generates a new node. For example, a Ticket type defined as `shipment` will get a node called `shipment` with node name as `ticket.shipment`. `shipment` acts as the unique and global name in the Graph while `ticket.shipment` is used for defining [custom Graph fields](/resources/graph/custom-fields). As seen above, each type can have Items and Relations attached to it. If an Item package is added to `shipment`, the Graph generates a new node with Graph name `package` and node name `ticket.shipment.package`. A new edge is named `items:package` attached to the main node. Relations are likely named. For example, if shipment type has an order relation named orders, the `ticket.shipment` node will have an edge called `related:orders`. In short: | Entity | Convention | |-------------------------|----------------------------------------| | Actor nodes | `.` | | Item nodes | `..` | | Relation edges | `related:` | | Inverse relation edges | `.[.]` | ::: info Inverse relation edge naming convention can be extended in the type definitions. This is required when there are more than one relation to the same node type. ::: Since all the nodes and edges are generated from type definitions, the Graph is a read-only mirror representation of data models. You can query the dynamic graph but cannot directly modify it. That way, the Graph is always an accurate representation of your data. Query the dynamic read-only Graph through the Query Editor in the Hantera Portal. Alternatively, use the Graph Meta endpoint available at `GET https:///resources/graph` ::: warning Be careful when defining new types or modifying relations. All changes automatically affect the Graph and instances of Asset and Ticket types. ::: #### Access Control Access control of custom nodes are based on the base node and refined using [ABAC](/resources/graph/#attribute-based-access). The attribute is `typeKey` and an identity needs a matching access attribute. For example, an identity with attribute-limited ACE `typeKey@graph/ticket:query` can only query tickets where it has a matching value for the `typeKey` access attribute. ## How to Define an Actor Type? Actor Type definitions are sent through YAML files to the Registry. The paths for the definitions also depend on the Actor being extended. The convention looks like this: `actors//types/` #### Schema ::: tip What's New? `graphSetName` is required in Type definitions for the Graph to index new Types. If Items is present, `itemEdgeName` and `edgeName` are also required. ::: #### Common Type Definition Errors The Registry doesn't enforce schema upon writing. This means that if you incorrectly type an Actor, the Registry may pick it up regardless. Fortunately, you can use the a [CLI](/learn/hantera-cli) command, `h_ manage signals` to check if there were any errors. Still, the common pitfalls to avoid include: * not setting `graphSetName` for every Actor type and Item * not setting `itemEdgeName` and `edgeName`, where required * Including numbers in the `defaultNumberPrefix` value. The prefix must only be alphabets. ::: tip Always run `h_ manage signals` after applying a Type definition. ::: ## Example: Define a Shipment Ticket Type Let's say you want to model a Shipment as a Delivery being shipped to a customer. A Shipment has a limited lifespan, so this makes [Ticket Actor](/resources/actors/custom/ticket/) the best option. In this example, a shipment will have an Order and Delivery relation, as well as contain package items. We will also store events for each package as well as Shipment-specific events (common for all packages). To model this, we will apply the following manifest: ```yaml uri: /registry/actors/custom/ticket/types/shipment spec: value: graphSetName: shipment itemEdgeName: head defaultNumberPrefix: "SHIP" items: event: graphSetName: event edgeName: edgeEvent relations: package: node: 'ticket.shipment.package' cardinality: single package: graphSetName: package edgeName: edgePackage relations: delivery: node: delivery cardinality: single ``` It's important to note that while Relations are bi-directional in the Graph, the Relation is controlled by the entity that defines it. So we can't add an event to a package in the above case. Instead we must add the package to the event. --- --- url: /resources/registry/reference/actors_channels_numbering.md description: Channel-scoped prefix override for number series --- # actors/{actor}/channels/{channelKey}/numbering/\* Channel-scoped override for a number series **prefix**. Lets a specific channel use a different prefix while sharing the global seed and counter. For the concepts behind number series, see [Number Series](/resources/actors/number-series). ## Path ``` actors/{actor}/channels/{channelKey}/numbering/{...scope} ``` The `{...scope}` segment mirrors the base [`actors/{actor}/numbering`](/resources/registry/reference/actors_numbering) scope. Channel overrides are only effective for actors that carry a `channelKey` — today that is **order** and **ticket**. ## Properties | Property | Type | Description | | --------------- | -------- | -------------------------------------------------------------------------------------------------------- | | `defaultPrefix` | `string` | Prefix to use for this channel. Same prefix rules as the base config. | | `seeds` | `object` | Not honored here. Seeds are global per series; a `seeds` map on a channel override raises a warning signal. | ## Example ```yaml uri: /registry/actors/order/channels/b2b/numbering/order spec: value: defaultPrefix: B2B ``` --- --- url: /resources/registry/reference/actors_numbering.md description: Number series configuration per actor --- # actors/{actor}/numbering/\* Base configuration for entity number series. Sets the default **prefix** and the per-prefix **seed** (start number) for a series. For the concepts behind number series, see [Number Series](/resources/actors/number-series). ## Path ``` actors/{actor}/numbering/{...scope} ``` The `{...scope}` segment identifies the series within the actor: | Actor | Scope | Example leaf | | ---------------- | ------------- | ---------------------------------- | | order | `order` | `actors/order/numbering/order` | | order → invoice | `invoice` | `actors/order/numbering/invoice` | | order → delivery | `delivery` | `actors/order/numbering/delivery` | | payment | `payment` | `actors/payment/numbering/payment` | | ticket (typed) | `{typeKey}` | `actors/ticket/numbering/claim` | | asset (typed) | `{typeKey}` | `actors/asset/numbering/{typeKey}` | For typed actors (asset, ticket) the scope is the type key. ## Properties | Property | Type | Description | | --------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------ | | `defaultPrefix` | `string` | Default prefix for the series. Only honored for typeless entities (order, invoice, delivery, payment). On typed actors it raises a warning. | | `seeds` | `object` | Map of prefix → start number. Each value must be a positive number. Seeds are global per series and are never channel-overridable. | Prefixes must match `a-z` only and may optionally end with one of `_ . -`. Seeds default to `1000` when not configured. ## Example ```yaml uri: /registry/actors/order/numbering/order spec: value: defaultPrefix: O seeds: O: 1000 ``` A typed actor configuring only seeds (the prefix comes from the type spec): ```yaml uri: /registry/actors/ticket/numbering/claim spec: value: seeds: CLAIM: 5000 ``` --- --- url: /resources/registry/reference/actors_ticket_types.md --- # actors/ticket/types/\* Ticket type definitions. For more info, please refer to [Actor Extensions](/resources/actors/actor-extensions). --- --- url: /resources/components/runtimes/types/address.md --- # Address ## Definition ```filtrera let Address: { addressLine1?: nothing|text addressLine2?: nothing|text attention?: nothing|text careOf?: nothing|text city?: nothing|text countryCode?: nothing|text email?: nothing|text name?: nothing|text phone?: nothing|text postalCode?: nothing|text state?: nothing|text } ``` ## Availability --- --- url: /resources/components/runtimes/types/asset.md --- # Asset ## Definition ```filtrera let Asset: { assetId: uuid assetNumber: text createdAt: instant dynamic: {text->value} items: [{ assetItemId: uuid createdAt: instant dynamic: {text->value} itemTypeKey: text relations: [{ nodeId: uuid relationKey: text }] typeKey: text }] relations: [{ nodeId: uuid relationKey: text }] tags: [text] typeKey: text } ``` ## Availability --- --- url: /resources/components/runtimes/rule-effects/assetCommand.md --- # assetCommand Return this effect to apply an asset command from hooks that supports it. ## Type ```filtrera { effect: 'assetCommand' type: text // Command type // Additional command properties are added here } ``` ## Asset Commands --- --- url: /resources/components/runtimes/types/asset-item.md --- # AssetItem ## Definition ```filtrera let AssetItem: { assetItemId: uuid createdAt: instant dynamic: {text->value} itemTypeKey: text relations: [{ nodeId: uuid relationKey: text }] typeKey: text } ``` ## Availability --- --- url: /resources/graph/nodes/back-order.md description: '' --- # backOrder Graph Node Root Set Name: `backOrders` --- --- url: /resources/registry/reference/branding_logo.md description: The default logo for the Hantera instance --- # branding/logo The default logo for the Hantera instance. Used in sign-in form and throughout the Hantera Portal. ## Example Value Could be a public image URL: ``` "https://placehold.co/600x600" ``` Could also be base64 encoded image: ``` "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+CjwhRE9DVFlQRSBzdmcgUFVCTElDICItLy9XM0MvL0RURCBTVkcgMS4xLy9FTiIgImh0dHA6Ly93d3cudzMub3JnL0dyYXBoaWNzL1NWRy8xLjEvRFREL3N2ZzExLmR0ZCI+Cjxzdmcgd2lkdGg9IjEwMCUiIGhlaWdodD0iMTAwJSIgdmlld0JveD0iMCAwIDc1MiA2NjAiIHZlcnNpb249IjEuMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSIgeG1sbnM6c2VyaWY9Imh0dHA6Ly93d3cuc2VyaWYuY29tLyIgc3R5bGU9ImZpbGwtcnVsZTpldmVub2RkO2NsaXAtcnVsZTpldmVub2RkO3N0cm9rZS1saW5lam9pbjpyb3VuZDtzdHJva2UtbWl0ZXJsaW1pdDoyOyI+CiAgICA8ZyBpZD0iQXJ0Ym9hcmQxIiB0cmFuc2Zvcm09Im1hdHJpeCgwLjk2NjY3OSwwLDAsMSwtMTI3LjIwMiwtMTc5LjgyNCkiPgogICAgICAgIDxyZWN0IHg9IjEzMS41ODYiIHk9IjE3OS44MjQiIHdpZHRoPSI3NzcuMDc0IiBoZWlnaHQ9IjY1OS41NSIgc3R5bGU9ImZpbGw6bm9uZTsiLz4KICAgICAgICA8Y2xpcFBhdGggaWQ9Il9jbGlwMSI+CiAgICAgICAgICAgIDxyZWN0IHg9IjEzMS41ODYiIHk9IjE3OS44MjQiIHdpZHRoPSI3NzcuMDc0IiBoZWlnaHQ9IjY1OS41NSIvPgogICAgICAgIDwvY2xpcFBhdGg+CiAgICAgICAgPGcgY2xpcC1wYXRoPSJ1cmwoI19jbGlwMSkiPgogICAgICAgICAgICA8ZyB0cmFuc2Zvcm09Im1hdHJpeCgxLjAzNDQ3LDAsMCwxLC0zLjkyOTIzLDAuODI0KSI+CiAgICAgICAgICAgICAgICA8cGF0aCBkPSJNNTA2Ljk4LDE3OS44MjRDNTA3LjM1OSwxNzkuOTMyIDUwNy43NzQsMTc5Ljk1NSA1MDguMTE3LDE4MC4xNDlDNTA5Ljc2LDE4MS4wNzggNTEyLjI0MiwxODQuNDI2IDUxMy43MDgsMTg1Ljg4NEM1MjcuMTQzLDE5OS4yMzkgNTQwLjAwOSwyMTMuMDM5IDU1My4yMDgsMjI2LjYxNkM1NjMuMzEyLDIzNy4wMDkgNTczLjk3NiwyNDYuODk3IDU4My45NzMsMjU3LjM3NEM1OTEuMjIyLDI2NC45NyA1OTguMDI2LDI3Mi45OTEgNjA1LjMyNCwyODAuNTQxQzYxMi41NjksMjg4LjAzNiA2MjAuNTc2LDI5NC42MTUgNjI3LjEyNywzMDIuNzhDNjQwLjM3LDI5NC40MDUgNjUzLjg1MiwyODYuNzk3IDY2Ni44MzUsMjc3Ljk3MkM2NzIuNDU2LDI4MS4zMjIgNjc3LjM0NywyODcuMjA3IDY4Mi4xMTEsMjkxLjcwN0M2OTUuNTA0LDMwNC4zNTQgNzA4LjU5OCwzMTcuMjYyIDcyMS44NjUsMzMwLjAzMUM3NTQuNTIsMzYxLjQ3MiA3ODcuNzMyLDM5Mi4zMTQgODIxLjUwMiw0MjIuNTU2QzgyOS4xMDksNDI5LjI5MSA4MzYuOTg0LDQzNS43NTMgODQ0LjUzNiw0NDIuNTM4Qzg1MC45NjQsNDQ4LjMxMSA4NTYuOTM3LDQ1NC40NTIgODYzLjY5NSw0NTkuODdDODY2Ljc0Miw0NjIuMzEyIDg3NS4zNzIsNDY1LjY5MiA4NzYuODA0LDQ2OC44MzRDODczLjYyNyw0NjkuOTU5IDg2Ny4zODQsNDY5LjE1MSA4NjMuODczLDQ2OS4yMTFDODM1LjgyNCw0NjkuNjkgODA3LjgwMSw0NjkuMTc2IDc3OS43NTYsNDY5LjEyNUw1MDQuODc1LDQ2OS4wMjZDMzgwLjc1Miw0NjguNDE4IDI1Ni41NTgsNDcwLjk5OSAxMzIuNDU5LDQ2OC4zOTZDMTM3LjU2NSw0NjUuOTg0IDE0Mi44Nyw0NjQuMjExIDE0Ny42NzksNDYxLjE3M0MxNTYuMzE3LDQ1NS43MTYgMTYyLjk4NSw0NDcuMTE3IDE3MC45Nyw0NDAuNjg4QzE5OS42MjEsNDE3LjYyMyAyMjUuNjAyLDM5Mi4yNTYgMjUyLjA3OSwzNjYuNzk3TDI5NC43ODQsMzI2LjQ3M0wzMTguNDU4LDMwNC4wMTVDMzI3LjM5NSwyOTUuMzkzIDMzNi4xMzcsMjg2LjYxIDM0NS43OSwyNzguNzc4QzM1MS43NiwyODAuNzczIDM1Ny4zNTYsMjg0LjMxNiAzNjIuODM4LDI4Ny4zNjRDMzcxLjYyMiwyOTIuMjQ5IDM4MC42NDEsMjk3LjM5NiAzODkuODgzLDMwMS4zNDJDNDAxLjcxMywyODguNzQgNDE0LjE2MSwyNzYuNjU5IDQyNi4wNzcsMjY0LjEyMUM0NDMuMjA3LDI0Ni4wOTUgNDU5Ljg1NiwyMjcuNjkyIDQ3Ny4zNzEsMjEwLjAyMkM0ODcuMTk1LDIwMC4xMTEgNDk2LjIxNCwxODguNzQ4IDUwNi45OCwxNzkuODI0Wk0yNzcuMzIzLDQ0Ny45OThDMjg1LjQwMSw0NDguNDYxIDI5My42NTMsNDQ4LjA0OSAzMDEuNzUsNDQ4LjA0MkwzNDQuODc5LDQ0OC4wNzNDMzUzLjIyNiw0NDguMDU3IDM2MS42MjIsNDQ3Ljc1NyAzNjkuOTQ2LDQ0OC40NjhDMzc2Ljg5Nyw0NDAuNDgxIDM4OC43NzQsNDEzLjEzMiAzOTMuNzI1LDQwMi4zNDdDNDAyLjQ1MSwzODMuMzM5IDQxMS45MDUsMzY0LjYyOSA0MjAuNDIxLDM0NS41MjVDNDI4LjY3OCwzMjcuMDAxIDQzNS43MTMsMzA4LjEzNCA0NDQuOTUxLDI5MC4wMTFDNDQ2LjIxNywyODcuNTMgNDQ4LjU0NCwyODIuMDQ1IDQ1MS4yNywyODEuMTYxTDQ1Mi4yNzcsMjgyLjE1NUM0NTUuNjk5LDI5MS44MTEgNDQ4LjA1OSwzMjMuMDgzIDQ0Ni4xODUsMzM0LjE3NUM0NDUuNDY4LDMzOC40MjMgNDQ1LjExOSwzNDIuNzE5IDQ0NC44ODUsMzQ3LjAxOEM0NTAuODU0LDM0My4xOTkgNDU1LjY1MywzMzguMDAxIDQ2MC43MzksMzMzLjEyNEM0NzEuNywzMjIuNjE1IDQ4NC40NzgsMzA5LjI1MiA0OTYuNjQyLDMwMC43MjlDNDkyLjg0MiwzMTIuOTUxIDQ4NC40NzgsMzIzLjgxNSA0NzcuOTI3LDMzNC43MjJDNDU0Ljg0NiwzNzMuMTUxIDQyOS4zNjYsNDA5Ljk3NCA0MDUuMDU4LDQ0Ny42MUM0MjAuMzU0LDQ0OC4zMTMgNDM1LjY0OSw0NDcuNTcyIDQ1MC45NTMsNDQ3LjYwNEw1NTkuMjEyLDQ0Ny44MzhDNTczLjA4Miw0NDcuOTU5IDU5MC40ODUsNDQ1LjgxOSA2MDMuNzY4LDQ0OC44MjhDNjAxLjQ4NSw0NDQuNjA2IDU5OC4yODcsNDQwLjkzNCA1OTUuNTE0LDQzNy4wMjlDNTkxLjI4Nyw0MzEuMDc1IDU4Ny43Miw0MjQuNzA1IDU4My4zODMsNDE4LjgwMkM1NzkuMDE0LDQxMi44NTUgNTczLjc3OCw0MDcuNDAxIDU2OS43NDgsNDAxLjIzNEM1NjQuNjQ1LDM5My40MjcgNTYzLjA0MiwzODMuOTQyIDU1Ny45NSwzNzYuMjA3QzU0OC43MDYsMzgwLjQzNSA1MzkuNDY4LDM4My4wNjQgNTI5Ljk4MywzODYuNTE0QzUyMi42NzMsMzg5LjE3MyA1MTUuNDkyLDM5Mi4zMzMgNTA4LjMwNSwzOTUuMzEzQzUyMC45MywzNzAuMjIzIDUzOS4wMjEsMzQ4LjExNiA1NTIuMTIyLDMyMy4zNzVDNTQ1LjUzNCwzMjEuNTEgNTM4LjQ1MSwzMTcuODg0IDUzMS41ODQsMzE3LjQ5N0M1MjguODYxLDMxNy4zNDQgNTI1Ljk5NSwzMTcuNzMxIDUyMy4yNTEsMzE3Ljc2MUw1MjIuODk5LDMxNy4wOTRDNTI0LjAzNiwzMTQuMjA1IDUyNi45NDYsMzExLjc4MiA1MjguODc3LDMwOS4zMzVDNTMxLjgyLDMwNS42MDUgNTM0LjI0LDMwMS40NjkgNTM3LjE3NiwyOTcuNzI1QzU0MC43MTEsMjkzLjIxNiA1NDQuOTMsMjg5LjIzNyA1NDguMDYxLDI4NC40MThMNTQ4LjQ1LDI4My44MDlDNTM2LjI0MSwyODIuMjAzIDUyMi4zMTcsMjgwLjEyNCA1MTAuMzYyLDI4NC4wMjVMNTExLjg2NiwyNzkuNUM1MTguMzI1LDI1OS43OCA1MTguNDU4LDI0NS40NjkgNTEzLjEwMiwyMjUuMzk3QzUxMS42NjEsMjE5Ljk5OSA1MTAuMTc1LDIxNC4xOTMgNTA3LjY0NSwyMDkuMjA0QzQ4NS42MDYsMjI5LjkzOSA0NjUuNTksMjUyLjEzMSA0NDUuMTA3LDI3NC4zNDlDNDM2LjcyMywyODMuNDQyIDQyNy43OTUsMjkxLjk4IDQxOS41NjMsMzAxLjIzN0MzODQuNjkzLDM0MC40NDkgMzUwLjc0NiwzNzkuODEzIDMxMC43NzcsNDE0LjA0M0MzMDUuNTMzLDQxOC41MzQgMzAxLjU5LDQyNC42NzYgMjk2LjM0Niw0MjguNjg3QzI5MS4xOTUsNDMyLjYyNiAyODUuMyw0MzUuMjcyIDI3OS4wMjcsNDM2LjgxN0wyNzguODc5LDQzNi44NTNMMjc4LjA2Miw0MzYuOTMxQzI3Ni40MSw0MzcuMDc2IDI3NS4zNTgsNDM3LjE1MyAyNzQuMDQ4LDQzNi4wODNMMjc0LjAxNyw0MzYuMDU4QzI3Ny4yNiw0MjYuMDczIDI5Mi41NTYsNDA2LjM5OCAyOTkuMDkxLDM5Ni4wODdDMzA2LjgxMiwzODMuOTA1IDMxMi41MDQsMzcwLjQ2OSAzMTkuMTc3LDM1Ny43MDdDMzIxLjg1NSwzNTIuNTg2IDMyNS42OTcsMzQ4LjIwNSAzMjguNTU5LDM0My4yMDNDMzM0LjIzOSwzMzMuMjc1IDMzOS44NzMsMzE5LjA3NyAzNDMuMTMxLDMwOC4xNjZDMzI4LjU4NSwzMjQuOTI0IDMxMS4zOTUsMzQwLjA1NCAyOTUuMDczLDM1NS4wOTNMMjY1Ljc5OSwzODIuNTczQzI1MC4xMiwzOTcuMDQ3IDIzNC4xOTYsNDExLjM4MiAyMTkuMDk1LDQyNi40NTZDMjE1LjMxMiw0MzAuMjMyIDIxMS41ODUsNDM0LjA1MiAyMDcuNjI0LDQzNy42NDNDMjAzLjk5Myw0NDAuOTM2IDE5OS42MDksNDQ0LjEyNyAxOTYuNTM1LDQ0Ny45MkMyMjAuMDcsNDQ4LjQwNiAyNDMuNjA0LDQ0OC4zNzkgMjY3LjEzNyw0NDcuODM4TDI3Ny4zMjMsNDQ3Ljk5OFpNNjY1LjQxNywzMDcuMzg2TDY2Ni4wMTgsMzA3LjYzNUM2NjcuNjgxLDMxNC4wMzcgNjY0LjUzNywzNDUuNjEgNjYyLjc5MywzNTMuMDAzQzY2MS40ODksMzU4LjUzMSA2NTkuMTcsMzYzLjc5NyA2NTcuMDU5LDM2OS4wNTVDNjY1LjM1MywzNjcuMzE5IDY5NS4xODUsMzU3LjM1MiA3MDIuMDEsMzU4LjYwMkM3MDIuNDE0LDM1OS4zMSA3MDIuNTM2LDM1OS4zMzQgNzAyLjYwNywzNjAuMTE3QzcwMy4zOTcsMzY4LjgzNSA2ODguNTcsMzc5Ljc3OCA2ODMuMTA3LDM4NS4zMjVDNjgwLjEzLDM4OC4zNDggNjc3LjY4NywzOTEuNjIyIDY3NS4yODYsMzk1LjExQzY5MS41ODEsMzk2LjYyNSA3MjcuNjU2LDQyNC45NTkgNzQxLjY5NSw0MzUuNDU2Qzc0Ny4wOTQsNDM5LjQ5MyA3NTIuOTQxLDQ0My4wNTEgNzU3Ljk3MSw0NDcuNTQxQzc1Mi4zMDMsNDQ3LjQ5OCA3NDYuNjM1LDQ0Ny41MDUgNzQwLjk2OCw0NDcuNTYzQzcyMS40NDgsNDQ3LjUyIDcwMS45NDQsNDQ4LjE5OSA2ODIuNDExLDQ0Ny45NTFDNjczLjYwNCw0NDcuODM5IDY2NC4zNjIsNDQ4LjA4MyA2NTUuNjYyLDQ0Ni42MjVDNjU2LjE5NSw0NDIuMjU3IDY1NS41NTEsNDM5LjEwOSA2NTMuODE0LDQzNS4xMTRDNjUwLjQ3Myw0MjcuNDI4IDY0NC42MTMsNDIxLjAxNyA2MzkuOTgzLDQxNC4wODlDNjM1LjIzMyw0MDYuOTgyIDYzMS4zMjMsMzk5LjM0MyA2MjYuNDc2LDM5Mi4yODhDNjE5LjE2OSwzODEuNjUgNjEwLjg1NCwzNzEuNzYzIDYwMy42MzEsMzYxLjAwOUM2MTcuOTExLDM0Ny45ODUgNjMwLjYyMywzMzIuNzI5IDY0NS42NTMsMzIwLjYxN0M2NTEuODM3LDMxNS42MzMgNjU4LjY2NSwzMTEuNTM4IDY2NS40MTcsMzA3LjM4NlpNMjc3LjI3LDQ0Ny44MDJMMjc3LjI3Nyw0NDcuODNMMjc3LjMwMSw0NDcuOTIyQzI3Ny4yOTMsNDQ3Ljg5MiAyNzcuMjg1LDQ0Ny44NjEgMjc3LjI3Nyw0NDcuODNMMjc3LjI3NSw0NDcuODIzTDI3Ny4yNyw0NDcuODAyTDI3Ny4yNTksNDQ3Ljc1N0wyNzcuMjUxLDQ0Ny43MjJMMjc3LjI3LDQ0Ny44MDJaTTI3Ny4yMjgsNDQ3LjYxNUwyNzcuMjM1LDQ0Ny42NTFMMjc3LjIyOCw0NDcuNjE1Wk0yNjguNTU3LDQ0Ny40MDRDMjY4LjU3Nyw0NDcuNDAzIDI2OC41OTcsNDQ3LjQwMSAyNjguNjE2LDQ0Ny4zOTlDMjY4LjYzNiw0NDcuMzk4IDI2OC42NTUsNDQ3LjM5NiAyNjguNjc1LDQ0Ny4zOTVMMjY4LjU1Nyw0NDcuNDA0Wk0yNzcuMTc1LDQ0Ny4zMjFDMjc3LjE1NSw0NDcuMTgzIDI3Ny4xMzgsNDQ3LjA0MSAyNzcuMTI1LDQ0Ni44OTZMMjc3LjE4MSw0NDcuMzU4TDI3Ny4xNzUsNDQ3LjMyMVpNMjY5LjEzMiw0NDcuMzQzTDI2OS4xODksNDQ3LjMzM0wyNjkuMTQ2LDQ0Ny4zNDFMMjY5LjEzMiw0NDcuMzQzQzI2OS4wOTgsNDQ3LjM0OCAyNjkuMDYzLDQ0Ny4zNTMgMjY5LjAyNSw0NDcuMzU4TDI2OS4xMzIsNDQ3LjM0M1pNMjY5LjgxOSw0NDcuMTE2QzI2OS41NTIsNDQ3LjIyMiAyNjkuMjc5LDQ0Ny4zMTUgMjY5LjI2OCw0NDcuMzE4TDI2OS44NjYsNDQ3LjA5OEwyNjkuODE5LDQ0Ny4xMTZaTTI3MC4xNzgsNDQ2Ljk2MkMyNzAuMTcsNDQ2Ljk2NiAyNzAuMTYxLDQ0Ni45NyAyNzAuMTUyLDQ0Ni45NzVDMjcwLjE0Myw0NDYuOTc5IDI3MC4xMzQsNDQ2Ljk4MyAyNzAuMTI0LDQ0Ni45ODhDMjcwLjE0Myw0NDYuOTc5IDI3MC4xNjEsNDQ2Ljk3IDI3MC4xNzgsNDQ2Ljk2MlpNMjcwLjE4MSw0NDYuOTZMMjcwLjIxNCw0NDYuOTQ0TDI3MC4xODEsNDQ2Ljk2Wk0yNzAuMjQzLDQ0Ni45MjhDMjcwLjI2Miw0NDYuOTE3IDI3MC4yNzgsNDQ2LjkwNyAyNzAuMjkxLDQ0Ni44OThMMjcwLjIyOSw0NDYuOTM2TDI3MC4yNDMsNDQ2LjkyOFpNMjcwLjMyNSw0NDYuODdDMjcwLjMyMiw0NDYuODczIDI3MC4zMiw0NDYuODc2IDI3MC4zMTYsNDQ2Ljg3OUwyNzAuMzI3LDQ0Ni44NjdMMjcwLjMyNSw0NDYuODdaTTI3MC40MDgsNDQ2Ljc0NEMyNzAuNDAxLDQ0Ni43NTYgMjcwLjM5NCw0NDYuNzY3IDI3MC4zODgsNDQ2Ljc3OEMyNzAuMzgxLDQ0Ni43ODkgMjcwLjM3NCw0NDYuNzk5IDI3MC4zNjcsNDQ2LjgxTDI3MC40MDgsNDQ2Ljc0NFpNMjc3LjEwNCw0NDYuNTc1TDI3Ny4xLDQ0Ni40OTNDMjc3LjEwMiw0NDYuNTM0IDI3Ny4xMDQsNDQ2LjU3NSAyNzcuMTA2LDQ0Ni42MTZMMjc3LjEwNCw0NDYuNTc1Wk0yNzAuNTgsNDQ2LjQwOUMyNzAuNTY1LDQ0Ni40NDIgMjcwLjU1LDQ0Ni40NzMgMjcwLjUzNiw0NDYuNTAzQzI3MC41MjEsNDQ2LjUzNCAyNzAuNTA3LDQ0Ni41NjMgMjcwLjQ5Miw0NDYuNTkxQzI3MC41MjEsNDQ2LjUzNSAyNzAuNTUsNDQ2LjQ3NCAyNzAuNTgsNDQ2LjQwOVpNMjc3LjA5Nyw0NDYuNDExQzI3Ny4wOTYsNDQ2LjM1NSAyNzcuMDk1LDQ0Ni4zIDI3Ny4wOTQsNDQ2LjI0NEwyNzcuMDk5LDQ0Ni40NTJMMjc3LjA5Nyw0NDYuNDExWk0yNzAuNjg5LDQ0Ni4xNTNMMjcwLjczOSw0NDYuMDI2TDI3MC43MTYsNDQ2LjA4NkwyNzAuNjg5LDQ0Ni4xNTNMMjcwLjY3OCw0NDYuMThMMjcwLjY3NCw0NDYuMTlDMjcwLjY1NSw0NDYuMjM2IDI3MC42MzYsNDQ2LjI4IDI3MC42MTgsNDQ2LjMyM0wyNzAuNjc0LDQ0Ni4xOUwyNzAuNjg5LDQ0Ni4xNTNaTTI3Ny4xMTIsNDQ1LjQ2NkwyNzcuMTA5LDQ0NS41NDJMMjc3LjExNiw0NDUuNDEzTDI3Ny4xMTIsNDQ1LjQ2NlpNMjc3LjE2MSw0NDQuODM1TDI3Ny4xNTMsNDQ0LjkyNkMyNzcuMTU1LDQ0NC44OTYgMjc3LjE1OCw0NDQuODY2IDI3Ny4xNjEsNDQ0LjgzNVpNMjcxLjQ3MSw0NDMuNzcyTDI3MS40MTgsNDQzLjk1TDI3MS41MTQsNDQzLjYyNEwyNzEuNDcxLDQ0My43NzJaTTI3MS44ODQsNDQyLjMzM0MyNzEuODQ5LDQ0Mi40NTcgMjcxLjgxNCw0NDIuNTgxIDI3MS43NzksNDQyLjcwNEMyNzEuNzQ0LDQ0Mi44MjYgMjcxLjcwOSw0NDIuOTQ4IDI3MS42NzQsNDQzLjA2OUMyNzEuNzQ0LDQ0Mi44MjggMjcxLjgxMyw0NDIuNTgyIDI3MS44ODQsNDQyLjMzM1pNMjc3LjUzNCw0NDIuNDMxQzI3Ny41MzksNDQyLjQwNiAyNzcuNTQ0LDQ0Mi4zODEgMjc3LjU0OSw0NDIuMzU1QzI3Ny41NTQsNDQyLjMzIDI3Ny41NTksNDQyLjMwNSAyNzcuNTY1LDQ0Mi4yNzlMMjc3LjUzNCw0NDIuNDMxWk0yNzIuMTkxLDQ0MS4yNTFMMjcyLjA2Myw0NDEuNzAyQzI3Mi4xMjQsNDQxLjQ4NiAyNzIuMTg2LDQ0MS4yNjkgMjcyLjI0Nyw0NDEuMDUzTDI3Mi4xOTEsNDQxLjI1MVpNMjc4LjA0NSw0NDAuMTgxTDI3OC4wMTYsNDQwLjI5OEwyNzcuOTEzLDQ0MC43MjJDMjc3Ljk0Nyw0NDAuNTggMjc3Ljk4MSw0NDAuNDM5IDI3OC4wMTYsNDQwLjI5OEwyNzguMDIzLDQ0MC4yNjZMMjc4LjA0NSw0NDAuMTgxTDI3OC4wODksNDQwLjAwMkwyNzguMTM1LDQzOS44MThMMjc4LjA0NSw0NDAuMTgxWk0yNzIuOTA1LDQzOC44NDVDMjcyLjg0Myw0MzkuMDQgMjcyLjc4MSw0MzkuMjQxIDI3Mi43MTksNDM5LjQ0NkMyNzIuNjU2LDQzOS42NTEgMjcyLjU5NCw0MzkuODYgMjcyLjUzMSw0NDAuMDcyQzI3Mi42NTYsNDM5LjY0OCAyNzIuNzgxLDQzOS4yMzUgMjcyLjkwNSw0MzguODQ1Wk0yNzMuMDk1LDQzOC4yNjFDMjczLjA3LDQzOC4zMzYgMjczLjA0NSw0MzguNDExIDI3My4wMiw0MzguNDg4QzI3Mi45OTUsNDM4LjU2NSAyNzIuOTcsNDM4LjY0MiAyNzIuOTQ1LDQzOC43MjFDMjcyLjk5NSw0MzguNTYzIDI3My4wNDUsNDM4LjQxIDI3My4wOTUsNDM4LjI2MVpNMjczLjE2NSw0MzguMDU2QzI3My4xNTgsNDM4LjA3NyAyNzMuMTUxLDQzOC4wOTcgMjczLjE0NCw0MzguMTE4QzI3My4xMzcsNDM4LjEzOCAyNzMuMTMsNDM4LjE1OSAyNzMuMTIzLDQzOC4xNzlMMjczLjE2NSw0MzguMDU2Wk0yNzguNjIxLDQzNy45MTJMMjc4LjYxNCw0MzcuOTM4TDI3OC42MjEsNDM3LjkxMlpNNDg2LjU2OSwyNTUuOTkyQzQ4Ny44NjIsMjU1LjkzMyA0ODkuMzQ3LDI1NS45NTMgNDkwLjU4MSwyNTYuMzY2QzQ5Mi40NjYsMjU2Ljk5NyA0OTMuODQ5LDI1OC4xNTQgNDk0Ljk2NiwyNTkuNTY4QzQ5Ni4yMTEsMjYxLjE0NyA0OTcuMTI0LDI2My4wNDUgNDk4LjAzMiwyNjQuODlDNDk2LjU1LDI2OC41MTYgNDk1LjMxOSwyNzEuNTQxIDQ5MS44OCwyNzMuNzI1QzQ5MS4xODYsMjczLjcwMiA0OTAuNDk0LDI3My42NTcgNDg5LjgwMywyNzMuNTg4QzQ4Ni44NzEsMjczLjI3MSA0ODQuMDA0LDI3MS42ODkgNDgyLjI2MiwyNjkuMjlDNDgyLjA5OSwyNjkuMDYzIDQ4MS45NDcsMjY4LjgyOSA0ODEuODA4LDI2OC41ODdDNDgxLjY2OSwyNjguMzQ0IDQ4MS41NDIsMjY4LjA5NSA0ODEuNDI4LDI2Ny44NEM0ODEuMzE1LDI2Ny41ODUgNDgxLjIxNSwyNjcuMzI0IDQ4MS4xMjgsMjY3LjA1OEM0ODEuMDQyLDI2Ni43OTIgNDgwLjk3LDI2Ni41MjIgNDgwLjkxMSwyNjYuMjQ5QzQ4MC44NTIsMjY1Ljk3NiA0ODAuODA4LDI2NS43IDQ4MC43NzgsMjY1LjQyMkM0ODAuNzQ5LDI2NS4xNDUgNDgwLjczMywyNjQuODY2IDQ4MC43MzIsMjY0LjU4NkM0ODAuNzMxLDI2NC4zMDcgNDgwLjc0NCwyNjQuMDI4IDQ4MC43NzIsMjYzLjc1QzQ4MC44LDI2My40NzIgNDgwLjg0MiwyNjMuMTk2IDQ4MC44OTksMjYyLjkyMkM0ODEuNTgzLDI1OS41NDIgNDgzLjg2MSwyNTcuODM2IDQ4Ni41NjksMjU1Ljk5MloiLz4KICAgICAgICAgICAgPC9nPgogICAgICAgICAgICA8ZyB0cmFuc2Zvcm09Im1hdHJpeCgxLjAzNDQ3LDAsMCwxLC0zLjkyOTIzLC03LjE3NikiPgogICAgICAgICAgICAgICAgPHJlY3QgeD0iMTM0Ljg2NyIgeT0iNzI4LjU5OCIgd2lkdGg9Ijc0MS4yMDciIGhlaWdodD0iMTQuNDEiLz4KICAgICAgICAgICAgPC9nPgogICAgICAgICAgICA8ZyB0cmFuc2Zvcm09Im1hdHJpeCgxLjkwMTA3LDAsMCwxLjg3MTQsLTE4Mi45MjEsMzI1Ljk5OSkiPgogICAgICAgICAgICAgICAgPGcgdHJhbnNmb3JtPSJtYXRyaXgoOTYsMCwwLDk2LDE2Ny41MTUsMTU1LjgwNykiPgogICAgICAgICAgICAgICAgICAgIDxwYXRoIGQ9Ik0wLjU0OCwtMC4xN0wwLjI0NSwtMC4xN0wwLjIwMSwtMC4wNzZDMC4xOTQsLTAuMDYzIDAuMTkxLC0wLjA1MyAwLjE5MSwtMC4wNDVDMC4xOTEsLTAuMDMxIDAuMjAyLC0wLjAyMSAwLjIyNCwtMC4wMTZMMC4yMjQsLTBMMC4wMDMsLTBMMC4wMDMsLTAuMDE2QzAuMDE2LC0wLjAxOCAwLjAyNiwtMC4wMjMgMC4wMzMsLTAuMDI5QzAuMDQsLTAuMDM2IDAuMDQ4LC0wLjA0OSAwLjA1NywtMC4wNjdMMC4zMDIsLTAuNThDMC4zMSwtMC41OTYgMC4zMTMsLTAuNjA5IDAuMzEzLC0wLjYxOUMwLjMxMywtMC42MzQgMC4zMDQsLTAuNjQ0IDAuMjg1LC0wLjY1MUwwLjI4NSwtMC42NjdMMC41MjEsLTAuNjY3TDAuNTIxLC0wLjY1MUMwLjUwMiwtMC42NDUgMC40OTMsLTAuNjM2IDAuNDkzLC0wLjYyMkMwLjQ5MywtMC42MTMgMC40OTYsLTAuNjAyIDAuNTAyLC0wLjU5TDAuNzU2LC0wLjA3NkMwLjc2NywtMC4wNTQgMC43NzYsLTAuMDM5IDAuNzg0LC0wLjAzMUMwLjc5MiwtMC4wMjQgMC44MDMsLTAuMDE4IDAuODE3LC0wLjAxNkwwLjgxNywtMEwwLjU3MiwtMEwwLjU3MiwtMC4wMTZDMC41OTMsLTAuMDE5IDAuNjA0LC0wLjAyOSAwLjYwNCwtMC4wNDZDMC42MDQsLTAuMDUzIDAuNjAxLC0wLjA2MyAwLjU5NCwtMC4wNzZMMC41NDgsLTAuMTdaTTAuNTA2LC0wLjI2MUwwLjM5OCwtMC40OTlMMC4yODksLTAuMjYxTDAuNTA2LC0wLjI2MVoiIHN0eWxlPSJmaWxsLXJ1bGU6bm9uemVybzsiLz4KICAgICAgICAgICAgICAgIDwvZz4KICAgICAgICAgICAgICAgIDxnIHRyYW5zZm9ybT0ibWF0cml4KDk2LDAsMCw5NiwyNDUuNjA4LDE1NS44MDcpIj4KICAgICAgICAgICAgICAgICAgICA8cGF0aCBkPSJNMC4yMzcsLTAuMTFMMC42NDYsLTAuMTFDMC42NTcsLTAuMTEgMC42NjQsLTAuMTEyIDAuNjY5LC0wLjExN0MwLjY3NSwtMC4xMjEgMC42NzksLTAuMTI5IDAuNjgyLC0wLjE0MUwwLjY5OCwtMC4xNDFMMC42OTgsMC4wMzFMMC42ODIsMC4wMzFDMC42NzksMC4wMiAwLjY3NSwwLjAxMSAwLjY2OSwwLjAwN0MwLjY2NCwwLjAwMiAwLjY1NywtMCAwLjY0NiwtMEwwLjA2LC0wTDAuMDYsLTAuMDE2QzAuMDcyLC0wLjAxOSAwLjA4LC0wLjAyMyAwLjA4NCwtMC4wMjhDMC4wODksLTAuMDM0IDAuMDkxLC0wLjA0MSAwLjA5MSwtMC4wNTFMMC4wOTEsLTAuNjE1QzAuMDkxLC0wLjYyNSAwLjA4OSwtMC42MzMgMC4wODQsLTAuNjM4QzAuMDgsLTAuNjQzIDAuMDcyLC0wLjY0OCAwLjA2LC0wLjY1MUwwLjA2LC0wLjY2N0wwLjI2OCwtMC42NjdMMC4yNjgsLTAuNjUxQzAuMjU2LC0wLjY0OCAwLjI0OCwtMC42NDQgMC4yNDQsLTAuNjM4QzAuMjM5LC0wLjYzMyAwLjIzNywtMC42MjUgMC4yMzcsLTAuNjE1TDAuMjM3LC0wLjExWiIgc3R5bGU9ImZpbGwtcnVsZTpub256ZXJvOyIvPgogICAgICAgICAgICAgICAgPC9nPgogICAgICAgICAgICAgICAgPGcgdHJhbnNmb3JtPSJtYXRyaXgoOTYsMCwwLDk2LDMxMy41MywxNTUuODA3KSI+CiAgICAgICAgICAgICAgICAgICAgPHBhdGggZD0iTTAuMjM3LC0wLjI3MUwwLjIzNywtMC4wNTFDMC4yMzcsLTAuMDQxIDAuMjM5LC0wLjAzMyAwLjI0NCwtMC4wMjhDMC4yNDksLTAuMDIzIDAuMjU3LC0wLjAxOSAwLjI2OCwtMC4wMTZMMC4yNjgsLTBMMC4wNiwtMEwwLjA2LC0wLjAxNkMwLjA3MiwtMC4wMTkgMC4wOCwtMC4wMjMgMC4wODQsLTAuMDI4QzAuMDg5LC0wLjAzNCAwLjA5MSwtMC4wNDEgMC4wOTEsLTAuMDUxTDAuMDkxLC0wLjYxNUMwLjA5MSwtMC42MjUgMC4wODksLTAuNjMzIDAuMDg0LC0wLjYzOEMwLjA4LC0wLjY0MyAwLjA3MiwtMC42NDggMC4wNiwtMC42NTFMMC4wNiwtMC42NjdMMC40NzYsLTAuNjY3QzAuNTI1LC0wLjY2NyAwLjU2MywtMC42NjMgMC41OTIsLTAuNjU3QzAuNjIsLTAuNjUgMC42NDUsLTAuNjM5IDAuNjY3LC0wLjYyM0MwLjY4OSwtMC42MDYgMC43MDcsLTAuNTg1IDAuNzIsLTAuNTU4QzAuNzMyLC0wLjUzIDAuNzM5LC0wLjUgMC43MzksLTAuNDY5QzAuNzM5LC0wLjQyNCAwLjcyNywtMC4zODQgMC43MDMsLTAuMzUxQzAuNjgyLC0wLjMyMiAwLjY1NCwtMC4zMDEgMC42MiwtMC4yODlDMC41ODYsLTAuMjc3IDAuNTM4LC0wLjI3MSAwLjQ3NiwtMC4yNzFMMC4yMzcsLTAuMjcxWk0wLjIzNywtMC4zOEwwLjQ1OSwtMC4zOEMwLjUwMywtMC4zOCAwLjUzNCwtMC4zODUgMC41NTIsLTAuMzk1QzAuNTY0LC0wLjQwMSAwLjU3MywtMC40MTEgMC41OCwtMC40MjRDMC41ODcsLTAuNDM4IDAuNTksLTAuNDUyIDAuNTksLTAuNDY5QzAuNTksLTAuNDg1IDAuNTg3LC0wLjQ5OSAwLjU4LC0wLjUxM0MwLjU3MywtMC41MjYgMC41NjQsLTAuNTM2IDAuNTUyLC0wLjU0MkMwLjUzNSwtMC41NTIgMC41MDQsLTAuNTU3IDAuNDU5LC0wLjU1N0wwLjIzNywtMC41NTdMMC4yMzcsLTAuMzhaIiBzdHlsZT0iZmlsbC1ydWxlOm5vbnplcm87Ii8+CiAgICAgICAgICAgICAgICA8L2c+CiAgICAgICAgICAgICAgICA8ZyB0cmFuc2Zvcm09Im1hdHJpeCg5NiwwLDAsOTYsMzg2Ljk4MywxNTUuODA3KSI+CiAgICAgICAgICAgICAgICAgICAgPHBhdGggZD0iTTAuMDkxLC0wLjA1MUwwLjA5MSwtMC42MTZDMC4wOTEsLTAuNjI1IDAuMDg5LC0wLjYzMyAwLjA4NCwtMC42MzhDMC4wOCwtMC42NDMgMC4wNzIsLTAuNjQ4IDAuMDYsLTAuNjUxTDAuMDYsLTAuNjY3TDAuMjY4LC0wLjY2N0wwLjI2OCwtMC42NTFDMC4yNTYsLTAuNjQ4IDAuMjQ4LC0wLjY0NCAwLjI0NCwtMC42MzhDMC4yMzksLTAuNjMzIDAuMjM3LC0wLjYyNSAwLjIzNywtMC42MTZMMC4yMzcsLTAuMDUxQzAuMjM3LC0wLjA0MSAwLjIzOSwtMC4wMzMgMC4yNDQsLTAuMDI4QzAuMjQ5LC0wLjAyMyAwLjI1NywtMC4wMTkgMC4yNjgsLTAuMDE2TDAuMjY4LC0wTDAuMDYsLTBMMC4wNiwtMC4wMTZDMC4wNzIsLTAuMDE5IDAuMDgsLTAuMDIzIDAuMDg0LC0wLjAyOEMwLjA4OSwtMC4wMzQgMC4wOTEsLTAuMDQxIDAuMDkxLC0wLjA1MVoiIHN0eWxlPSJmaWxsLXJ1bGU6bm9uemVybzsiLz4KICAgICAgICAgICAgICAgIDwvZz4KICAgICAgICAgICAgICAgIDxnIHRyYW5zZm9ybT0ibWF0cml4KDk2LDAsMCw5Niw0MTguNDM2LDE1NS44MDcpIj4KICAgICAgICAgICAgICAgICAgICA8cGF0aCBkPSJNMC4yMjUsLTAuNDU3TDAuMjI1LC0wLjA1MUMwLjIyNSwtMC4wNDEgMC4yMjcsLTAuMDM0IDAuMjMxLC0wLjAyOEMwLjIzNiwtMC4wMjMgMC4yNDQsLTAuMDE5IDAuMjU2LC0wLjAxNkwwLjI1NiwtMEwwLjA2LC0wTDAuMDYsLTAuMDE2QzAuMDcyLC0wLjAxOSAwLjA4LC0wLjAyMyAwLjA4NCwtMC4wMjhDMC4wODksLTAuMDM0IDAuMDkxLC0wLjA0MSAwLjA5MSwtMC4wNTFMMC4wOTEsLTAuNjE2QzAuMDkxLC0wLjYyNSAwLjA4OSwtMC42MzMgMC4wODQsLTAuNjM4QzAuMDgsLTAuNjQzIDAuMDcyLC0wLjY0OCAwLjA2LC0wLjY1MUwwLjA2LC0wLjY2N0wwLjI4NywtMC42NjdMMC4yODcsLTAuNjUxQzAuMjczLC0wLjY0NyAwLjI2NiwtMC42NCAwLjI2NiwtMC42MjhDMC4yNjYsLTAuNjIxIDAuMjcsLTAuNjEyIDAuMjc5LC0wLjYwM0wwLjY0NywtMC4yMkwwLjY0NywtMC42MTZDMC42NDcsLTAuNjI1IDAuNjQ1LC0wLjYzMyAwLjY0LC0wLjYzOEMwLjYzNiwtMC42NDMgMC42MjcsLTAuNjQ4IDAuNjE2LC0wLjY1MUwwLjYxNiwtMC42NjdMMC44MTIsLTAuNjY3TDAuODEyLC0wLjY1MUMwLjgsLTAuNjQ4IDAuNzkyLC0wLjY0MyAwLjc4NywtMC42MzhDMC43ODMsLTAuNjMzIDAuNzgsLTAuNjI1IDAuNzgsLTAuNjE2TDAuNzgsLTAuMDUxQzAuNzgsLTAuMDQxIDAuNzgzLC0wLjAzNCAwLjc4NywtMC4wMjhDMC43OTIsLTAuMDIzIDAuOCwtMC4wMTkgMC44MTIsLTAuMDE2TDAuODEyLC0wTDAuNjAyLC0wTDAuNjAyLC0wLjAxNkMwLjYxNSwtMC4wMTkgMC42MjIsLTAuMDI2IDAuNjIyLC0wLjAzN0MwLjYyMiwtMC4wNDMgMC42MTQsLTAuMDU1IDAuNTk4LC0wLjA3MUwwLjIyNSwtMC40NTdaIiBzdHlsZT0iZmlsbC1ydWxlOm5vbnplcm87Ii8+CiAgICAgICAgICAgICAgICA8L2c+CiAgICAgICAgICAgICAgICA8ZyB0cmFuc2Zvcm09Im1hdHJpeCg5NiwwLDAsOTYsNTAyLjEwOCwxNTUuODA3KSI+CiAgICAgICAgICAgICAgICAgICAgPHBhdGggZD0iTTAuMjM3LC0wLjI5MkwwLjIzNywtMC4xMUwwLjY2NiwtMC4xMUMwLjY3NSwtMC4xMSAwLjY4MywtMC4xMTIgMC42ODgsLTAuMTE3QzAuNjkzLC0wLjEyMSAwLjY5NywtMC4xMjkgMC43MDEsLTAuMTQxTDAuNzE2LC0wLjE0MUwwLjcxNiwwLjAzMUwwLjcwMSwwLjAzMUMwLjY5NywwLjAyIDAuNjkzLDAuMDExIDAuNjg4LDAuMDA3QzAuNjgzLDAuMDAyIDAuNjc1LC0wIDAuNjY2LC0wTDAuMDYsLTBMMC4wNiwtMC4wMTZDMC4wNzIsLTAuMDE5IDAuMDgsLTAuMDIzIDAuMDg0LC0wLjAyOEMwLjA4OSwtMC4wMzQgMC4wOTEsLTAuMDQxIDAuMDkxLC0wLjA1MUwwLjA5MSwtMC42MTVDMC4wOTEsLTAuNjI1IDAuMDg5LC0wLjYzMyAwLjA4NCwtMC42MzhDMC4wOCwtMC42NDMgMC4wNzIsLTAuNjQ4IDAuMDYsLTAuNjUxTDAuMDYsLTAuNjY3TDAuNjU0LC0wLjY2N0MwLjY2NCwtMC42NjcgMC42NzEsLTAuNjY5IDAuNjc2LC0wLjY3M0MwLjY4MSwtMC42NzggMC42ODYsLTAuNjg2IDAuNjg5LC0wLjY5OEwwLjcwNSwtMC42OThMMC43MDUsLTAuNTI1TDAuNjg5LC0wLjUyNUMwLjY4NiwtMC41MzcgMC42ODEsLTAuNTQ1IDAuNjc2LC0wLjU1QzAuNjcxLC0wLjU1NCAwLjY2NCwtMC41NTcgMC42NTQsLTAuNTU3TDAuMjM3LC0wLjU1N0wwLjIzNywtMC4zOThMMC41MDcsLTAuMzk4QzAuNTE3LC0wLjM5OCAwLjUyNSwtMC40MDEgMC41MywtMC40MDVDMC41MzUsLTAuNDEgMC41MzksLTAuNDE4IDAuNTQyLC0wLjQzTDAuNTU4LC0wLjQzTDAuNTU4LC0wLjI2MUwwLjU0MiwtMC4yNjFDMC41MzksLTAuMjczIDAuNTM1LC0wLjI4MSAwLjUzLC0wLjI4NUMwLjUyNSwtMC4yOSAwLjUxNywtMC4yOTIgMC41MDcsLTAuMjkyTDAuMjM3LC0wLjI5MloiIHN0eWxlPSJmaWxsLXJ1bGU6bm9uemVybzsiLz4KICAgICAgICAgICAgICAgIDwvZz4KICAgICAgICAgICAgPC9nPgogICAgICAgICAgICA8ZyB0cmFuc2Zvcm09Im1hdHJpeCgwLjc0MTM4OSwwLDAsMC43Mjk4MTksNy43NTYxMSw1NzYuMTk4KSI+CiAgICAgICAgICAgICAgICA8ZyB0cmFuc2Zvcm09Im1hdHJpeCg5NiwwLDAsOTYsMTY3LjUxNSwxNTUuODA3KSI+CiAgICAgICAgICAgICAgICAgICAgPHBhdGggZD0iTTAuNTQ4LC0wLjE3TDAuMjQ1LC0wLjE3TDAuMjAxLC0wLjA3NkMwLjE5NCwtMC4wNjMgMC4xOTEsLTAuMDUzIDAuMTkxLC0wLjA0NUMwLjE5MSwtMC4wMzEgMC4yMDIsLTAuMDIxIDAuMjI0LC0wLjAxNkwwLjIyNCwtMEwwLjAwMywtMEwwLjAwMywtMC4wMTZDMC4wMTYsLTAuMDE4IDAuMDI2LC0wLjAyMyAwLjAzMywtMC4wMjlDMC4wNCwtMC4wMzYgMC4wNDgsLTAuMDQ5IDAuMDU3LC0wLjA2N0wwLjMwMiwtMC41OEMwLjMxLC0wLjU5NiAwLjMxMywtMC42MDkgMC4zMTMsLTAuNjE5QzAuMzEzLC0wLjYzNCAwLjMwNCwtMC42NDQgMC4yODUsLTAuNjUxTDAuMjg1LC0wLjY2N0wwLjUyMSwtMC42NjdMMC41MjEsLTAuNjUxQzAuNTAyLC0wLjY0NSAwLjQ5MywtMC42MzYgMC40OTMsLTAuNjIyQzAuNDkzLC0wLjYxMyAwLjQ5NiwtMC42MDIgMC41MDIsLTAuNTlMMC43NTYsLTAuMDc2QzAuNzY3LC0wLjA1NCAwLjc3NiwtMC4wMzkgMC43ODQsLTAuMDMxQzAuNzkyLC0wLjAyNCAwLjgwMywtMC4wMTggMC44MTcsLTAuMDE2TDAuODE3LC0wTDAuNTcyLC0wTDAuNTcyLC0wLjAxNkMwLjU5MywtMC4wMTkgMC42MDQsLTAuMDI5IDAuNjA0LC0wLjA0NkMwLjYwNCwtMC4wNTMgMC42MDEsLTAuMDYzIDAuNTk0LC0wLjA3NkwwLjU0OCwtMC4xN1pNMC41MDYsLTAuMjYxTDAuMzk4LC0wLjQ5OUwwLjI4OSwtMC4yNjFMMC41MDYsLTAuMjYxWiIgc3R5bGU9ImZpbGwtcnVsZTpub256ZXJvOyIvPgogICAgICAgICAgICAgICAgPC9nPgogICAgICAgICAgICAgICAgPGcgdHJhbnNmb3JtPSJtYXRyaXgoOTYsMCwwLDk2LDI0NS42MDgsMTU1LjgwNykiPgogICAgICAgICAgICAgICAgICAgIDxwYXRoIGQ9Ik0wLjA5MSwtMC4wNTFMMC4wOTEsLTAuNjE1QzAuMDkxLC0wLjYyNSAwLjA4OSwtMC42MzMgMC4wODQsLTAuNjM4QzAuMDgsLTAuNjQzIDAuMDcyLC0wLjY0OCAwLjA2LC0wLjY1MUwwLjA2LC0wLjY2N0wwLjQzMiwtMC42NjdDMC41MTMsLTAuNjY3IDAuNTc2LC0wLjY1NyAwLjYyLC0wLjYzOUMwLjY4MiwtMC42MTIgMC43MjgsLTAuNTY3IDAuNzU4LC0wLjUwNUMwLjc4MiwtMC40NTYgMC43OTQsLTAuMzk5IDAuNzk0LC0wLjMzM0MwLjc5NCwtMC4yMTkgMC43NTksLTAuMTMxIDAuNjg4LC0wLjA3MUMwLjY1OCwtMC4wNDUgMC42MjQsLTAuMDI3IDAuNTg0LC0wLjAxNkMwLjU0NCwtMC4wMDUgMC40OTMsLTAgMC40MzIsLTBMMC4wNiwtMEwwLjA2LC0wLjAxNkMwLjA3MiwtMC4wMTkgMC4wOCwtMC4wMjMgMC4wODQsLTAuMDI4QzAuMDg5LC0wLjAzNCAwLjA5MSwtMC4wNDEgMC4wOTEsLTAuMDUxWk0wLjIzNywtMC4xMUwwLjQyLC0wLjExQzAuNDc4LC0wLjExIDAuNTIyLC0wLjExOCAwLjU1MSwtMC4xMzRDMC42MTQsLTAuMTY4IDAuNjQ2LC0wLjIzNSAwLjY0NiwtMC4zMzNDMC42NDYsLTAuNDA1IDAuNjI5LC0wLjQ2IDAuNTk1LC0wLjQ5OEMwLjU3NiwtMC41MTkgMC41NTMsLTAuNTM0IDAuNTI2LC0wLjU0M0MwLjUsLTAuNTUyIDAuNDY0LC0wLjU1NyAwLjQyLC0wLjU1N0wwLjIzNywtMC41NTdMMC4yMzcsLTAuMTFaIiBzdHlsZT0iZmlsbC1ydWxlOm5vbnplcm87Ii8+CiAgICAgICAgICAgICAgICA8L2c+CiAgICAgICAgICAgICAgICA8ZyB0cmFuc2Zvcm09Im1hdHJpeCg5NiwwLDAsOTYsMzIyLjM0MywxNTUuODA3KSI+CiAgICAgICAgICAgICAgICAgICAgPHBhdGggZD0iTTAuMzg5LC0wLjE5NEwwLjU2NiwtMC41ODVDMC41NzQsLTAuNjAyIDAuNTc4LC0wLjYxNCAwLjU3OCwtMC42MjRDMC41NzgsLTAuNjM4IDAuNTY3LC0wLjY0NyAwLjU0NSwtMC42NTFMMC41NDUsLTAuNjY3TDAuNzczLC0wLjY2N0wwLjc3MywtMC42NTFDMC43NTksLTAuNjQ5IDAuNzQ5LC0wLjY0NSAwLjc0NCwtMC42MzlDMC43MzgsLTAuNjMyIDAuNzI4LC0wLjYxNSAwLjcxNSwtMC41ODVMMC40ODQsLTAuMDg2QzAuNDc0LC0wLjA2NSAwLjQ2OSwtMC4wNTEgMC40NjksLTAuMDQ1QzAuNDY5LC0wLjAyOCAwLjQ3OSwtMC4wMTkgMC41LC0wLjAxNkwwLjUsLTBMMC4yNywtMEwwLjI3LC0wLjAxNkMwLjI5MSwtMC4wMTkgMC4zMDEsLTAuMDI4IDAuMzAxLC0wLjA0NUMwLjMwMSwtMC4wNTIgMC4yOTYsLTAuMDY1IDAuMjg2LC0wLjA4NkwwLjA1NSwtMC41ODVDMC4wNDIsLTAuNjE1IDAuMDMyLC0wLjYzMiAwLjAyNiwtMC42MzlDMC4wMjEsLTAuNjQ1IDAuMDExLC0wLjY0OSAtMC4wMDMsLTAuNjUxTC0wLjAwMywtMC42NjdMMC4yMzMsLTAuNjY3TDAuMjMzLC0wLjY1MUMwLjIxMiwtMC42NDcgMC4yMDEsLTAuNjM4IDAuMjAxLC0wLjYyNEMwLjIwMSwtMC42MTQgMC4yMDUsLTAuNjAyIDAuMjEyLC0wLjU4NUwwLjM4OSwtMC4xOTRaIiBzdHlsZT0iZmlsbC1ydWxlOm5vbnplcm87Ii8+CiAgICAgICAgICAgICAgICA8L2c+CiAgICAgICAgICAgICAgICA8ZyB0cmFuc2Zvcm09Im1hdHJpeCg5NiwwLDAsOTYsMzk2LjI2NSwxNTUuODA3KSI+CiAgICAgICAgICAgICAgICAgICAgPHBhdGggZD0iTTAuMjM3LC0wLjI5MkwwLjIzNywtMC4xMUwwLjY2NiwtMC4xMUMwLjY3NSwtMC4xMSAwLjY4MywtMC4xMTIgMC42ODgsLTAuMTE3QzAuNjkzLC0wLjEyMSAwLjY5NywtMC4xMjkgMC43MDEsLTAuMTQxTDAuNzE2LC0wLjE0MUwwLjcxNiwwLjAzMUwwLjcwMSwwLjAzMUMwLjY5NywwLjAyIDAuNjkzLDAuMDExIDAuNjg4LDAuMDA3QzAuNjgzLDAuMDAyIDAuNjc1LC0wIDAuNjY2LC0wTDAuMDYsLTBMMC4wNiwtMC4wMTZDMC4wNzIsLTAuMDE5IDAuMDgsLTAuMDIzIDAuMDg0LC0wLjAyOEMwLjA4OSwtMC4wMzQgMC4wOTEsLTAuMDQxIDAuMDkxLC0wLjA1MUwwLjA5MSwtMC42MTVDMC4wOTEsLTAuNjI1IDAuMDg5LC0wLjYzMyAwLjA4NCwtMC42MzhDMC4wOCwtMC42NDMgMC4wNzIsLTAuNjQ4IDAuMDYsLTAuNjUxTDAuMDYsLTAuNjY3TDAuNjU0LC0wLjY2N0MwLjY2NCwtMC42NjcgMC42NzEsLTAuNjY5IDAuNjc2LC0wLjY3M0MwLjY4MSwtMC42NzggMC42ODYsLTAuNjg2IDAuNjg5LC0wLjY5OEwwLjcwNSwtMC42OThMMC43MDUsLTAuNTI1TDAuNjg5LC0wLjUyNUMwLjY4NiwtMC41MzcgMC42ODEsLTAuNTQ1IDAuNjc2LC0wLjU1QzAuNjcxLC0wLjU1NCAwLjY2NCwtMC41NTcgMC42NTQsLTAuNTU3TDAuMjM3LC0wLjU1N0wwLjIzNywtMC4zOThMMC41MDcsLTAuMzk4QzAuNTE3LC0wLjM5OCAwLjUyNSwtMC40MDEgMC41MywtMC40MDVDMC41MzUsLTAuNDEgMC41MzksLTAuNDE4IDAuNTQyLC0wLjQzTDAuNTU4LC0wLjQzTDAuNTU4LC0wLjI2MUwwLjU0MiwtMC4yNjFDMC41MzksLTAuMjczIDAuNTM1LC0wLjI4MSAwLjUzLC0wLjI4NUMwLjUyNSwtMC4yOSAwLjUxNywtMC4yOTIgMC41MDcsLTAuMjkyTDAuMjM3LC0wLjI5MloiIHN0eWxlPSJmaWxsLXJ1bGU6bm9uemVybzsiLz4KICAgICAgICAgICAgICAgIDwvZz4KICAgICAgICAgICAgICAgIDxnIHRyYW5zZm9ybT0ibWF0cml4KDk2LDAsMCw5Niw0NjkuMjAyLDE1NS44MDcpIj4KICAgICAgICAgICAgICAgICAgICA8cGF0aCBkPSJNMC4yMjUsLTAuNDU3TDAuMjI1LC0wLjA1MUMwLjIyNSwtMC4wNDEgMC4yMjcsLTAuMDM0IDAuMjMxLC0wLjAyOEMwLjIzNiwtMC4wMjMgMC4yNDQsLTAuMDE5IDAuMjU2LC0wLjAxNkwwLjI1NiwtMEwwLjA2LC0wTDAuMDYsLTAuMDE2QzAuMDcyLC0wLjAxOSAwLjA4LC0wLjAyMyAwLjA4NCwtMC4wMjhDMC4wODksLTAuMDM0IDAuMDkxLC0wLjA0MSAwLjA5MSwtMC4wNTFMMC4wOTEsLTAuNjE2QzAuMDkxLC0wLjYyNSAwLjA4OSwtMC42MzMgMC4wODQsLTAuNjM4QzAuMDgsLTAuNjQzIDAuMDcyLC0wLjY0OCAwLjA2LC0wLjY1MUwwLjA2LC0wLjY2N0wwLjI4NywtMC42NjdMMC4yODcsLTAuNjUxQzAuMjczLC0wLjY0NyAwLjI2NiwtMC42NCAwLjI2NiwtMC42MjhDMC4yNjYsLTAuNjIxIDAuMjcsLTAuNjEyIDAuMjc5LC0wLjYwM0wwLjY0NywtMC4yMkwwLjY0NywtMC42MTZDMC42NDcsLTAuNjI1IDAuNjQ1LC0wLjYzMyAwLjY0LC0wLjYzOEMwLjYzNiwtMC42NDMgMC42MjcsLTAuNjQ4IDAuNjE2LC0wLjY1MUwwLjYxNiwtMC42NjdMMC44MTIsLTAuNjY3TDAuODEyLC0wLjY1MUMwLjgsLTAuNjQ4IDAuNzkyLC0wLjY0MyAwLjc4NywtMC42MzhDMC43ODMsLTAuNjMzIDAuNzgsLTAuNjI1IDAuNzgsLTAuNjE2TDAuNzgsLTAuMDUxQzAuNzgsLTAuMDQxIDAuNzgzLC0wLjAzNCAwLjc4NywtMC4wMjhDMC43OTIsLTAuMDIzIDAuOCwtMC4wMTkgMC44MTIsLTAuMDE2TDAuODEyLC0wTDAuNjAyLC0wTDAuNjAyLC0wLjAxNkMwLjYxNSwtMC4wMTkgMC42MjIsLTAuMDI2IDAuNjIyLC0wLjAzN0MwLjYyMiwtMC4wNDMgMC42MTQsLTAuMDU1IDAuNTk4LC0wLjA3MUwwLjIyNSwtMC40NTdaIiBzdHlsZT0iZmlsbC1ydWxlOm5vbnplcm87Ii8+CiAgICAgICAgICAgICAgICA8L2c+CiAgICAgICAgICAgICAgICA8ZyB0cmFuc2Zvcm09Im1hdHJpeCg5NiwwLDAsOTYsNTUyLjg3NCwxNTUuODA3KSI+CiAgICAgICAgICAgICAgICAgICAgPHBhdGggZD0iTTAuNDEsLTAuNTU3TDAuNDEsLTAuMDUxQzAuNDEsLTAuMDQxIDAuNDEyLC0wLjAzNCAwLjQxNywtMC4wMjhDMC40MjIsLTAuMDIzIDAuNDMsLTAuMDE5IDAuNDQxLC0wLjAxNkwwLjQ0MSwtMEwwLjIzMywtMEwwLjIzMywtMC4wMTZDMC4yNDUsLTAuMDE5IDAuMjUzLC0wLjAyMyAwLjI1OCwtMC4wMjhDMC4yNjIsLTAuMDM0IDAuMjY1LC0wLjA0MSAwLjI2NSwtMC4wNTFMMC4yNjUsLTAuNTU3TDAuMDU4LC0wLjU1N0MwLjA0OCwtMC41NTcgMC4wNCwtMC41NTQgMC4wMzUsLTAuNTVDMC4wMywtMC41NDUgMC4wMjYsLTAuNTM3IDAuMDIyLC0wLjUyNUwwLjAwNywtMC41MjVMMC4wMDcsLTAuNjk4TDAuMDIyLC0wLjY5OEMwLjAyNiwtMC42ODYgMC4wMywtMC42NzggMC4wMzUsLTAuNjczQzAuMDQsLTAuNjY5IDAuMDQ4LC0wLjY2NyAwLjA1OCwtMC42NjdMMC42MTcsLTAuNjY3QzAuNjI3LC0wLjY2NyAwLjYzNCwtMC42NjkgMC42NCwtMC42NzNDMC42NDUsLTAuNjc4IDAuNjQ5LC0wLjY4NiAwLjY1MiwtMC42OThMMC42NjgsLTAuNjk4TDAuNjY4LC0wLjUyNUwwLjY1MiwtMC41MjVDMC42NDksLTAuNTM3IDAuNjQ1LC0wLjU0NSAwLjY0LC0wLjU1QzAuNjM0LC0wLjU1NCAwLjYyNywtMC41NTcgMC42MTcsLTAuNTU3TDAuNDEsLTAuNTU3WiIgc3R5bGU9ImZpbGwtcnVsZTpub256ZXJvOyIvPgogICAgICAgICAgICAgICAgPC9nPgogICAgICAgICAgICAgICAgPGcgdHJhbnNmb3JtPSJtYXRyaXgoOTYsMCwwLDk2LDYxNy42MDgsMTU1LjgwNykiPgogICAgICAgICAgICAgICAgICAgIDxwYXRoIGQ9Ik0wLjczOCwtMC42MTVMMC43MzgsLTAuMjQyQzAuNzM4LC0wLjE3MSAwLjcyNSwtMC4xMTggMC42OTksLTAuMDgzQzAuNjUyLC0wLjAyIDAuNTU2LDAuMDEyIDAuNDA5LDAuMDEyQzAuMzAzLDAuMDEyIDAuMjIxLC0wLjAwNSAwLjE2NSwtMC4wNEMwLjEzMiwtMC4wNTkgMC4xMDksLTAuMDg2IDAuMDk3LC0wLjEyMUMwLjA4NCwtMC4xNTQgMC4wNzgsLTAuMTk1IDAuMDc4LC0wLjI0MkwwLjA3OCwtMC42MTVDMC4wNzgsLTAuNjI1IDAuMDc2LC0wLjYzMyAwLjA3MSwtMC42MzhDMC4wNjcsLTAuNjQzIDAuMDU5LC0wLjY0OCAwLjA0NywtMC42NTFMMC4wNDcsLTAuNjY3TDAuMjU1LC0wLjY2N0wwLjI1NSwtMC42NTFDMC4yNDMsLTAuNjQ4IDAuMjM1LC0wLjY0NCAwLjIzMSwtMC42MzhDMC4yMjYsLTAuNjMzIDAuMjI0LC0wLjYyNSAwLjIyNCwtMC42MTVMMC4yMjQsLTAuMjgzQzAuMjI0LC0wLjI0NCAwLjIyNiwtMC4yMTYgMC4yMywtMC4xOThDMC4yMzQsLTAuMTc5IDAuMjQyLC0wLjE2NCAwLjI1NCwtMC4xNTFDMC4yODUsLTAuMTE4IDAuMzM5LC0wLjEwMiAwLjQxNCwtMC4xMDJDMC40OSwtMC4xMDIgMC41NDMsLTAuMTE4IDAuNTc1LC0wLjE1MUMwLjU4NiwtMC4xNjQgMC41OTQsLTAuMTc5IDAuNTk4LC0wLjE5OEMwLjYwMiwtMC4yMTYgMC42MDQsLTAuMjQ0IDAuNjA0LC0wLjI4M0wwLjYwNCwtMC42MTVDMC42MDQsLTAuNjI1IDAuNjAyLC0wLjYzMyAwLjU5OCwtMC42MzhDMC41OTMsLTAuNjQzIDAuNTg1LC0wLjY0OCAwLjU3MywtMC42NTFMMC41NzMsLTAuNjY3TDAuNzcsLTAuNjY3TDAuNzcsLTAuNjUxQzAuNzU4LC0wLjY0OCAwLjc1LC0wLjY0MyAwLjc0NSwtMC42MzhDMC43NDEsLTAuNjMzIDAuNzM4LC0wLjYyNSAwLjczOCwtMC42MTVaIiBzdHlsZT0iZmlsbC1ydWxlOm5vbnplcm87Ii8+CiAgICAgICAgICAgICAgICA8L2c+CiAgICAgICAgICAgICAgICA8ZyB0cmFuc2Zvcm09Im1hdHJpeCg5NiwwLDAsOTYsNjk1LjkzNiwxNTUuODA3KSI+CiAgICAgICAgICAgICAgICAgICAgPHBhdGggZD0iTTAuMjM3LC0wLjI3MkwwLjIzNywtMC4wNTFDMC4yMzcsLTAuMDQxIDAuMjM5LC0wLjAzMyAwLjI0NCwtMC4wMjhDMC4yNDksLTAuMDIzIDAuMjU3LC0wLjAxOSAwLjI2OCwtMC4wMTZMMC4yNjgsLTBMMC4wNiwtMEwwLjA2LC0wLjAxNkMwLjA3MiwtMC4wMTkgMC4wOCwtMC4wMjMgMC4wODQsLTAuMDI4QzAuMDg5LC0wLjAzNCAwLjA5MSwtMC4wNDEgMC4wOTEsLTAuMDUxTDAuMDkxLC0wLjYxNUMwLjA5MSwtMC42MjUgMC4wODksLTAuNjMzIDAuMDg0LC0wLjYzOEMwLjA4LC0wLjY0MyAwLjA3MiwtMC42NDggMC4wNiwtMC42NTFMMC4wNiwtMC42NjdMMC40NzcsLTAuNjY3QzAuNTI4LC0wLjY2NyAwLjU2OCwtMC42NjQgMC41OTgsLTAuNjU4QzAuNjI4LC0wLjY1MiAwLjY1NCwtMC42NDIgMC42NzYsLTAuNjI3QzAuNzAxLC0wLjYxIDAuNzIxLC0wLjU4NyAwLjczMywtMC41NThDMC43NDUsLTAuNTMgMC43NTEsLTAuNSAwLjc1MSwtMC40NjhDMC43NTEsLTAuMzYgMC42OTEsLTAuMjk2IDAuNTcsLTAuMjc3TDAuNywtMC4wOTNDMC43MjIsLTAuMDYxIDAuNzM5LC0wLjA0IDAuNzUsLTAuMDMxQzAuNzYsLTAuMDIyIDAuNzczLC0wLjAxNyAwLjc4OSwtMC4wMTZMMC43ODksLTBMMC41NDgsLTBMMC41NDgsLTAuMDE2QzAuNTY0LC0wLjAxOCAwLjU3MywtMC4wMjQgMC41NzMsLTAuMDMzQzAuNTczLC0wLjAzOSAwLjU2NSwtMC4wNTMgMC41NSwtMC4wNzZMMC40MTUsLTAuMjcyTDAuMjM3LC0wLjI3MlpNMC4yMzcsLTAuMzgyTDAuNDYxLC0wLjM4MkMwLjQ5NSwtMC4zODIgMC41MTgsLTAuMzgzIDAuNTMyLC0wLjM4NUMwLjU0NSwtMC4zODcgMC41NTgsLTAuMzkxIDAuNTY5LC0wLjM5N0MwLjU5NCwtMC40MSAwLjYwNiwtMC40MzQgMC42MDYsLTAuNDY5QzAuNjA2LC0wLjUwNCAwLjU5NCwtMC41MjggMC41NjksLTAuNTQyQzAuNTU4LC0wLjU0OCAwLjU0NSwtMC41NTIgMC41MzIsLTAuNTU0QzAuNTE5LC0wLjU1NiAwLjQ5NSwtMC41NTcgMC40NjEsLTAuNTU3TDAuMjM3LC0wLjU1N0wwLjIzNywtMC4zODJaIiBzdHlsZT0iZmlsbC1ydWxlOm5vbnplcm87Ii8+CiAgICAgICAgICAgICAgICA8L2c+CiAgICAgICAgICAgICAgICA8ZyB0cmFuc2Zvcm09Im1hdHJpeCg5NiwwLDAsOTYsNzczLjU2MSwxNTUuODA3KSI+CiAgICAgICAgICAgICAgICAgICAgPHBhdGggZD0iTTAuMjM3LC0wLjI5MkwwLjIzNywtMC4xMUwwLjY2NiwtMC4xMUMwLjY3NSwtMC4xMSAwLjY4MywtMC4xMTIgMC42ODgsLTAuMTE3QzAuNjkzLC0wLjEyMSAwLjY5NywtMC4xMjkgMC43MDEsLTAuMTQxTDAuNzE2LC0wLjE0MUwwLjcxNiwwLjAzMUwwLjcwMSwwLjAzMUMwLjY5NywwLjAyIDAuNjkzLDAuMDExIDAuNjg4LDAuMDA3QzAuNjgzLDAuMDAyIDAuNjc1LC0wIDAuNjY2LC0wTDAuMDYsLTBMMC4wNiwtMC4wMTZDMC4wNzIsLTAuMDE5IDAuMDgsLTAuMDIzIDAuMDg0LC0wLjAyOEMwLjA4OSwtMC4wMzQgMC4wOTEsLTAuMDQxIDAuMDkxLC0wLjA1MUwwLjA5MSwtMC42MTVDMC4wOTEsLTAuNjI1IDAuMDg5LC0wLjYzMyAwLjA4NCwtMC42MzhDMC4wOCwtMC42NDMgMC4wNzIsLTAuNjQ4IDAuMDYsLTAuNjUxTDAuMDYsLTAuNjY3TDAuNjU0LC0wLjY2N0MwLjY2NCwtMC42NjcgMC42NzEsLTAuNjY5IDAuNjc2LC0wLjY3M0MwLjY4MSwtMC42NzggMC42ODYsLTAuNjg2IDAuNjg5LC0wLjY5OEwwLjcwNSwtMC42OThMMC43MDUsLTAuNTI1TDAuNjg5LC0wLjUyNUMwLjY4NiwtMC41MzcgMC42ODEsLTAuNTQ1IDAuNjc2LC0wLjU1QzAuNjcxLC0wLjU1NCAwLjY2NCwtMC41NTcgMC42NTQsLTAuNTU3TDAuMjM3LC0wLjU1N0wwLjIzNywtMC4zOThMMC41MDcsLTAuMzk4QzAuNTE3LC0wLjM5OCAwLjUyNSwtMC40MDEgMC41MywtMC40MDVDMC41MzUsLTAuNDEgMC41MzksLTAuNDE4IDAuNTQyLC0wLjQzTDAuNTU4LC0wLjQzTDAuNTU4LC0wLjI2MUwwLjU0MiwtMC4yNjFDMC41MzksLTAuMjczIDAuNTM1LC0wLjI4MSAwLjUzLC0wLjI4NUMwLjUyNSwtMC4yOSAwLjUxNywtMC4yOTIgMC41MDcsLTAuMjkyTDAuMjM3LC0wLjI5MloiIHN0eWxlPSJmaWxsLXJ1bGU6bm9uemVybzsiLz4KICAgICAgICAgICAgICAgIDwvZz4KICAgICAgICAgICAgICAgIDxnIHRyYW5zZm9ybT0ibWF0cml4KDk2LDAsMCw5Niw4NzAuNDk5LDE1NS44MDcpIj4KICAgICAgICAgICAgICAgICAgICA8cGF0aCBkPSJNMC41NjUsLTAuNDdMMC40MTMsLTAuMDg0QzAuNDA0LC0wLjA2NSAwLjQsLTAuMDUzIDAuNCwtMC4wNDdDMC40LC0wLjAzMiAwLjQxMiwtMC4wMjIgMC40MzYsLTAuMDE2TDAuNDM2LC0wTDAuMjIxLC0wTDAuMjIxLC0wLjAxNkMwLjI0MywtMC4wMiAwLjI1NCwtMC4wMzEgMC4yNTQsLTAuMDQ5QzAuMjU0LC0wLjA1NCAwLjI1LC0wLjA2OCAwLjI0LC0wLjA5MkwwLjA0OSwtMC41ODRDMC4wNCwtMC42MDggMC4wMzEsLTAuNjI1IDAuMDI0LC0wLjYzNEMwLjAxNiwtMC42NDIgMC4wMDUsLTAuNjQ4IC0wLjAxLC0wLjY1MUwtMC4wMSwtMC42NjdMMC4yMjIsLTAuNjY3TDAuMjIyLC0wLjY1MUMwLjIwMSwtMC42NDYgMC4xOTEsLTAuNjM3IDAuMTkxLC0wLjYyMkMwLjE5MSwtMC42MTUgMC4xOTQsLTAuNjA0IDAuMTk5LC0wLjU5TDAuMzMxLC0wLjIzN0wwLjQ3NCwtMC41OTJDMC40OCwtMC42MDcgMC40ODMsLTAuNjE3IDAuNDgzLC0wLjYyMkMwLjQ4MywtMC42MzcgMC40NzIsLTAuNjQ3IDAuNDUsLTAuNjUxTDAuNDUsLTAuNjY3TDAuNjkyLC0wLjY2N0wwLjY5MiwtMC42NTFDMC42NywtMC42NDUgMC42NTksLTAuNjM0IDAuNjU5LC0wLjYxOUMwLjY1OSwtMC42MTQgMC42NjIsLTAuNjA0IDAuNjY3LC0wLjU5TDAuODAzLC0wLjIzN0wwLjkzNSwtMC41ODRDMC45NDIsLTAuNjAxIDAuOTQ1LC0wLjYxNCAwLjk0NSwtMC42MjNDMC45NDUsLTAuNjM3IDAuOTMzLC0wLjY0NiAwLjkxLC0wLjY1MUwwLjkxLC0wLjY2N0wxLjEzOSwtMC42NjdMMS4xMzksLTAuNjUxQzEuMTIzLC0wLjY0NiAxLjExMiwtMC42NCAxLjEwNSwtMC42MzNDMS4wOTksLTAuNjI2IDEuMDkxLC0wLjYxIDEuMDgxLC0wLjU4NEwwLjg5LC0wLjA5MkMwLjg4MSwtMC4wNjkgMC44NzYsLTAuMDUzIDAuODc2LC0wLjA0NUMwLjg3NiwtMC4wMjkgMC44ODgsLTAuMDIgMC45MTEsLTAuMDE2TDAuOTExLC0wTDAuNjk0LC0wTDAuNjk0LC0wLjAxNkMwLjcxNiwtMC4wMTkgMC43MjgsLTAuMDMgMC43MjgsLTAuMDQ4QzAuNzI4LC0wLjA1NSAwLjcyMywtMC4wNjkgMC43MTMsLTAuMDkyTDAuNTY1LC0wLjQ3WiIgc3R5bGU9ImZpbGwtcnVsZTpub256ZXJvOyIvPgogICAgICAgICAgICAgICAgPC9nPgogICAgICAgICAgICAgICAgPGcgdHJhbnNmb3JtPSJtYXRyaXgoOTYsMCwwLDk2LDk3OC44MjcsMTU1LjgwNykiPgogICAgICAgICAgICAgICAgICAgIDxwYXRoIGQ9Ik0wLjIzNywtMC4yOTJMMC4yMzcsLTAuMTFMMC42NjYsLTAuMTFDMC42NzUsLTAuMTEgMC42ODMsLTAuMTEyIDAuNjg4LC0wLjExN0MwLjY5MywtMC4xMjEgMC42OTcsLTAuMTI5IDAuNzAxLC0wLjE0MUwwLjcxNiwtMC4xNDFMMC43MTYsMC4wMzFMMC43MDEsMC4wMzFDMC42OTcsMC4wMiAwLjY5MywwLjAxMSAwLjY4OCwwLjAwN0MwLjY4MywwLjAwMiAwLjY3NSwtMCAwLjY2NiwtMEwwLjA2LC0wTDAuMDYsLTAuMDE2QzAuMDcyLC0wLjAxOSAwLjA4LC0wLjAyMyAwLjA4NCwtMC4wMjhDMC4wODksLTAuMDM0IDAuMDkxLC0wLjA0MSAwLjA5MSwtMC4wNTFMMC4wOTEsLTAuNjE1QzAuMDkxLC0wLjYyNSAwLjA4OSwtMC42MzMgMC4wODQsLTAuNjM4QzAuMDgsLTAuNjQzIDAuMDcyLC0wLjY0OCAwLjA2LC0wLjY1MUwwLjA2LC0wLjY2N0wwLjY1NCwtMC42NjdDMC42NjQsLTAuNjY3IDAuNjcxLC0wLjY2OSAwLjY3NiwtMC42NzNDMC42ODEsLTAuNjc4IDAuNjg2LC0wLjY4NiAwLjY4OSwtMC42OThMMC43MDUsLTAuNjk4TDAuNzA1LC0wLjUyNUwwLjY4OSwtMC41MjVDMC42ODYsLTAuNTM3IDAuNjgxLC0wLjU0NSAwLjY3NiwtMC41NUMwLjY3MSwtMC41NTQgMC42NjQsLTAuNTU3IDAuNjU0LC0wLjU1N0wwLjIzNywtMC41NTdMMC4yMzcsLTAuMzk4TDAuNTA3LC0wLjM5OEMwLjUxNywtMC4zOTggMC41MjUsLTAuNDAxIDAuNTMsLTAuNDA1QzAuNTM1LC0wLjQxIDAuNTM5LC0wLjQxOCAwLjU0MiwtMC40M0wwLjU1OCwtMC40M0wwLjU1OCwtMC4yNjFMMC41NDIsLTAuMjYxQzAuNTM5LC0wLjI3MyAwLjUzNSwtMC4yODEgMC41MywtMC4yODVDMC41MjUsLTAuMjkgMC41MTcsLTAuMjkyIDAuNTA3LC0wLjI5MkwwLjIzNywtMC4yOTJaIiBzdHlsZT0iZmlsbC1ydWxlOm5vbnplcm87Ii8+CiAgICAgICAgICAgICAgICA8L2c+CiAgICAgICAgICAgICAgICA8ZyB0cmFuc2Zvcm09Im1hdHJpeCg5NiwwLDAsOTYsMTA1MS43NiwxNTUuODA3KSI+CiAgICAgICAgICAgICAgICAgICAgPHBhdGggZD0iTTAuNTQ4LC0wLjE3TDAuMjQ1LC0wLjE3TDAuMjAxLC0wLjA3NkMwLjE5NCwtMC4wNjMgMC4xOTEsLTAuMDUzIDAuMTkxLC0wLjA0NUMwLjE5MSwtMC4wMzEgMC4yMDIsLTAuMDIxIDAuMjI0LC0wLjAxNkwwLjIyNCwtMEwwLjAwMywtMEwwLjAwMywtMC4wMTZDMC4wMTYsLTAuMDE4IDAuMDI2LC0wLjAyMyAwLjAzMywtMC4wMjlDMC4wNCwtMC4wMzYgMC4wNDgsLTAuMDQ5IDAuMDU3LC0wLjA2N0wwLjMwMiwtMC41OEMwLjMxLC0wLjU5NiAwLjMxMywtMC42MDkgMC4zMTMsLTAuNjE5QzAuMzEzLC0wLjYzNCAwLjMwNCwtMC42NDQgMC4yODUsLTAuNjUxTDAuMjg1LC0wLjY2N0wwLjUyMSwtMC42NjdMMC41MjEsLTAuNjUxQzAuNTAyLC0wLjY0NSAwLjQ5MywtMC42MzYgMC40OTMsLTAuNjIyQzAuNDkzLC0wLjYxMyAwLjQ5NiwtMC42MDIgMC41MDIsLTAuNTlMMC43NTYsLTAuMDc2QzAuNzY3LC0wLjA1NCAwLjc3NiwtMC4wMzkgMC43ODQsLTAuMDMxQzAuNzkyLC0wLjAyNCAwLjgwMywtMC4wMTggMC44MTcsLTAuMDE2TDAuODE3LC0wTDAuNTcyLC0wTDAuNTcyLC0wLjAxNkMwLjU5MywtMC4wMTkgMC42MDQsLTAuMDI5IDAuNjA0LC0wLjA0NkMwLjYwNCwtMC4wNTMgMC42MDEsLTAuMDYzIDAuNTk0LC0wLjA3NkwwLjU0OCwtMC4xN1pNMC41MDYsLTAuMjYxTDAuMzk4LC0wLjQ5OUwwLjI4OSwtMC4yNjFMMC41MDYsLTAuMjYxWiIgc3R5bGU9ImZpbGwtcnVsZTpub256ZXJvOyIvPgogICAgICAgICAgICAgICAgPC9nPgogICAgICAgICAgICAgICAgPGcgdHJhbnNmb3JtPSJtYXRyaXgoOTYsMCwwLDk2LDExMjkuODYsMTU1LjgwNykiPgogICAgICAgICAgICAgICAgICAgIDxwYXRoIGQ9Ik0wLjIzNywtMC4yNzJMMC4yMzcsLTAuMDUxQzAuMjM3LC0wLjA0MSAwLjIzOSwtMC4wMzMgMC4yNDQsLTAuMDI4QzAuMjQ5LC0wLjAyMyAwLjI1NywtMC4wMTkgMC4yNjgsLTAuMDE2TDAuMjY4LC0wTDAuMDYsLTBMMC4wNiwtMC4wMTZDMC4wNzIsLTAuMDE5IDAuMDgsLTAuMDIzIDAuMDg0LC0wLjAyOEMwLjA4OSwtMC4wMzQgMC4wOTEsLTAuMDQxIDAuMDkxLC0wLjA1MUwwLjA5MSwtMC42MTVDMC4wOTEsLTAuNjI1IDAuMDg5LC0wLjYzMyAwLjA4NCwtMC42MzhDMC4wOCwtMC42NDMgMC4wNzIsLTAuNjQ4IDAuMDYsLTAuNjUxTDAuMDYsLTAuNjY3TDAuNDc3LC0wLjY2N0MwLjUyOCwtMC42NjcgMC41NjgsLTAuNjY0IDAuNTk4LC0wLjY1OEMwLjYyOCwtMC42NTIgMC42NTQsLTAuNjQyIDAuNjc2LC0wLjYyN0MwLjcwMSwtMC42MSAwLjcyMSwtMC41ODcgMC43MzMsLTAuNTU4QzAuNzQ1LC0wLjUzIDAuNzUxLC0wLjUgMC43NTEsLTAuNDY4QzAuNzUxLC0wLjM2IDAuNjkxLC0wLjI5NiAwLjU3LC0wLjI3N0wwLjcsLTAuMDkzQzAuNzIyLC0wLjA2MSAwLjczOSwtMC4wNCAwLjc1LC0wLjAzMUMwLjc2LC0wLjAyMiAwLjc3MywtMC4wMTcgMC43ODksLTAuMDE2TDAuNzg5LC0wTDAuNTQ4LC0wTDAuNTQ4LC0wLjAxNkMwLjU2NCwtMC4wMTggMC41NzMsLTAuMDI0IDAuNTczLC0wLjAzM0MwLjU3MywtMC4wMzkgMC41NjUsLTAuMDUzIDAuNTUsLTAuMDc2TDAuNDE1LC0wLjI3MkwwLjIzNywtMC4yNzJaTTAuMjM3LC0wLjM4MkwwLjQ2MSwtMC4zODJDMC40OTUsLTAuMzgyIDAuNTE4LC0wLjM4MyAwLjUzMiwtMC4zODVDMC41NDUsLTAuMzg3IDAuNTU4LC0wLjM5MSAwLjU2OSwtMC4zOTdDMC41OTQsLTAuNDEgMC42MDYsLTAuNDM0IDAuNjA2LC0wLjQ2OUMwLjYwNiwtMC41MDQgMC41OTQsLTAuNTI4IDAuNTY5LC0wLjU0MkMwLjU1OCwtMC41NDggMC41NDUsLTAuNTUyIDAuNTMyLC0wLjU1NEMwLjUxOSwtMC41NTYgMC40OTUsLTAuNTU3IDAuNDYxLC0wLjU1N0wwLjIzNywtMC41NTdMMC4yMzcsLTAuMzgyWiIgc3R5bGU9ImZpbGwtcnVsZTpub256ZXJvOyIvPgogICAgICAgICAgICAgICAgPC9nPgogICAgICAgICAgICA8L2c+CiAgICAgICAgICAgIDxnIHRyYW5zZm9ybT0ibWF0cml4KDAuOTQ4MjY0LDAsMCwwLjk0NjIzNywyMjcuODgxLDE3LjgyOTIpIj4KICAgICAgICAgICAgICAgIDxwYXRoIGQ9Ik0zMDkuNTg3LDc4NS4zMzdMMzM2LjYwNSw4MjcuMjE1TDMwOS41ODcsODY5LjA5NEwyODIuNTY4LDgyNy4yMTVMMzA5LjU4Nyw3ODUuMzM3WiIvPgogICAgICAgICAgICA8L2c+CiAgICAgICAgICAgIDxnIHRyYW5zZm9ybT0ibWF0cml4KDEuMDM0NDcsMCwwLDEsNDQuNDE0MiwtMjQuMTk0NSkiPgogICAgICAgICAgICAgICAgPGNpcmNsZSBjeD0iNDAwLjA5OSIgY3k9IjgyMy42MTMiIHI9IjExLjI1OCIvPgogICAgICAgICAgICA8L2c+CiAgICAgICAgICAgIDxnIHRyYW5zZm9ybT0ibWF0cml4KDEuMDM0NDcsMCwwLDEsMTcxLjUzMSwtMjQuMTk0NSkiPgogICAgICAgICAgICAgICAgPGNpcmNsZSBjeD0iNDAwLjA5OSIgY3k9IjgyMy42MTMiIHI9IjExLjI1OCIvPgogICAgICAgICAgICA8L2c+CiAgICAgICAgPC9nPgogICAgPC9nPgo8L3N2Zz4K" ``` --- --- url: /resources/registry/reference/branding_logoDark.md description: >- Optional alternative [logo](/resources/registry/reference/branding_logo) for dark-themed interfaces --- # branding/logoDark Optional alternative [logo](/resources/registry/reference/branding_logo) for dark-themed interfaces. Used in sign-in form and throughout the Hantera Portal. ## Example Value Could be a public image URL: ``` "https://placehold.co/600x600" ``` Could also be base64 encoded image: ``` "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+CjwhRE9DVFlQRSBzdmcgUFVCTElDICItLy9XM0MvL0RURCBTVkcgMS4xLy9FTiIgImh0dHA6Ly93d3cudzMub3JnL0dyYXBoaWNzL1NWRy8xLjEvRFREL3N2ZzExLmR0ZCI+Cjxzdmcgd2lkdGg9IjEwMCUiIGhlaWdodD0iMTAwJSIgdmlld0JveD0iMCAwIDc1MiA2NjAiIHZlcnNpb249IjEuMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSIgeG1sbnM6c2VyaWY9Imh0dHA6Ly93d3cuc2VyaWYuY29tLyIgc3R5bGU9ImZpbGwtcnVsZTpldmVub2RkO2NsaXAtcnVsZTpldmVub2RkO3N0cm9rZS1saW5lam9pbjpyb3VuZDtzdHJva2UtbWl0ZXJsaW1pdDoyOyI+CiAgICA8ZyBpZD0iQXJ0Ym9hcmQxIiB0cmFuc2Zvcm09Im1hdHJpeCgwLjk2NjY3OSwwLDAsMSwtMTI3LjIwMiwtMTc5LjgyNCkiPgogICAgICAgIDxyZWN0IHg9IjEzMS41ODYiIHk9IjE3OS44MjQiIHdpZHRoPSI3NzcuMDc0IiBoZWlnaHQ9IjY1OS41NSIgc3R5bGU9ImZpbGw6bm9uZTsiLz4KICAgICAgICA8Y2xpcFBhdGggaWQ9Il9jbGlwMSI+CiAgICAgICAgICAgIDxyZWN0IHg9IjEzMS41ODYiIHk9IjE3OS44MjQiIHdpZHRoPSI3NzcuMDc0IiBoZWlnaHQ9IjY1OS41NSIvPgogICAgICAgIDwvY2xpcFBhdGg+CiAgICAgICAgPGcgY2xpcC1wYXRoPSJ1cmwoI19jbGlwMSkiPgogICAgICAgICAgICA8ZyB0cmFuc2Zvcm09Im1hdHJpeCgxLjAzNDQ3LDAsMCwxLC0zLjkyOTIzLDAuODI0KSI+CiAgICAgICAgICAgICAgICA8cGF0aCBkPSJNNTA2Ljk4LDE3OS44MjRDNTA3LjM1OSwxNzkuOTMyIDUwNy43NzQsMTc5Ljk1NSA1MDguMTE3LDE4MC4xNDlDNTA5Ljc2LDE4MS4wNzggNTEyLjI0MiwxODQuNDI2IDUxMy43MDgsMTg1Ljg4NEM1MjcuMTQzLDE5OS4yMzkgNTQwLjAwOSwyMTMuMDM5IDU1My4yMDgsMjI2LjYxNkM1NjMuMzEyLDIzNy4wMDkgNTczLjk3NiwyNDYuODk3IDU4My45NzMsMjU3LjM3NEM1OTEuMjIyLDI2NC45NyA1OTguMDI2LDI3Mi45OTEgNjA1LjMyNCwyODAuNTQxQzYxMi41NjksMjg4LjAzNiA2MjAuNTc2LDI5NC42MTUgNjI3LjEyNywzMDIuNzhDNjQwLjM3LDI5NC40MDUgNjUzLjg1MiwyODYuNzk3IDY2Ni44MzUsMjc3Ljk3MkM2NzIuNDU2LDI4MS4zMjIgNjc3LjM0NywyODcuMjA3IDY4Mi4xMTEsMjkxLjcwN0M2OTUuNTA0LDMwNC4zNTQgNzA4LjU5OCwzMTcuMjYyIDcyMS44NjUsMzMwLjAzMUM3NTQuNTIsMzYxLjQ3MiA3ODcuNzMyLDM5Mi4zMTQgODIxLjUwMiw0MjIuNTU2QzgyOS4xMDksNDI5LjI5MSA4MzYuOTg0LDQzNS43NTMgODQ0LjUzNiw0NDIuNTM4Qzg1MC45NjQsNDQ4LjMxMSA4NTYuOTM3LDQ1NC40NTIgODYzLjY5NSw0NTkuODdDODY2Ljc0Miw0NjIuMzEyIDg3NS4zNzIsNDY1LjY5MiA4NzYuODA0LDQ2OC44MzRDODczLjYyNyw0NjkuOTU5IDg2Ny4zODQsNDY5LjE1MSA4NjMuODczLDQ2OS4yMTFDODM1LjgyNCw0NjkuNjkgODA3LjgwMSw0NjkuMTc2IDc3OS43NTYsNDY5LjEyNUw1MDQuODc1LDQ2OS4wMjZDMzgwLjc1Miw0NjguNDE4IDI1Ni41NTgsNDcwLjk5OSAxMzIuNDU5LDQ2OC4zOTZDMTM3LjU2NSw0NjUuOTg0IDE0Mi44Nyw0NjQuMjExIDE0Ny42NzksNDYxLjE3M0MxNTYuMzE3LDQ1NS43MTYgMTYyLjk4NSw0NDcuMTE3IDE3MC45Nyw0NDAuNjg4QzE5OS42MjEsNDE3LjYyMyAyMjUuNjAyLDM5Mi4yNTYgMjUyLjA3OSwzNjYuNzk3TDI5NC43ODQsMzI2LjQ3M0wzMTguNDU4LDMwNC4wMTVDMzI3LjM5NSwyOTUuMzkzIDMzNi4xMzcsMjg2LjYxIDM0NS43OSwyNzguNzc4QzM1MS43NiwyODAuNzczIDM1Ny4zNTYsMjg0LjMxNiAzNjIuODM4LDI4Ny4zNjRDMzcxLjYyMiwyOTIuMjQ5IDM4MC42NDEsMjk3LjM5NiAzODkuODgzLDMwMS4zNDJDNDAxLjcxMywyODguNzQgNDE0LjE2MSwyNzYuNjU5IDQyNi4wNzcsMjY0LjEyMUM0NDMuMjA3LDI0Ni4wOTUgNDU5Ljg1NiwyMjcuNjkyIDQ3Ny4zNzEsMjEwLjAyMkM0ODcuMTk1LDIwMC4xMTEgNDk2LjIxNCwxODguNzQ4IDUwNi45OCwxNzkuODI0Wk0yNzcuMzIzLDQ0Ny45OThDMjg1LjQwMSw0NDguNDYxIDI5My42NTMsNDQ4LjA0OSAzMDEuNzUsNDQ4LjA0MkwzNDQuODc5LDQ0OC4wNzNDMzUzLjIyNiw0NDguMDU3IDM2MS42MjIsNDQ3Ljc1NyAzNjkuOTQ2LDQ0OC40NjhDMzc2Ljg5Nyw0NDAuNDgxIDM4OC43NzQsNDEzLjEzMiAzOTMuNzI1LDQwMi4zNDdDNDAyLjQ1MSwzODMuMzM5IDQxMS45MDUsMzY0LjYyOSA0MjAuNDIxLDM0NS41MjVDNDI4LjY3OCwzMjcuMDAxIDQzNS43MTMsMzA4LjEzNCA0NDQuOTUxLDI5MC4wMTFDNDQ2LjIxNywyODcuNTMgNDQ4LjU0NCwyODIuMDQ1IDQ1MS4yNywyODEuMTYxTDQ1Mi4yNzcsMjgyLjE1NUM0NTUuNjk5LDI5MS44MTEgNDQ4LjA1OSwzMjMuMDgzIDQ0Ni4xODUsMzM0LjE3NUM0NDUuNDY4LDMzOC40MjMgNDQ1LjExOSwzNDIuNzE5IDQ0NC44ODUsMzQ3LjAxOEM0NTAuODU0LDM0My4xOTkgNDU1LjY1MywzMzguMDAxIDQ2MC43MzksMzMzLjEyNEM0NzEuNywzMjIuNjE1IDQ4NC40NzgsMzA5LjI1MiA0OTYuNjQyLDMwMC43MjlDNDkyLjg0MiwzMTIuOTUxIDQ4NC40NzgsMzIzLjgxNSA0NzcuOTI3LDMzNC43MjJDNDU0Ljg0NiwzNzMuMTUxIDQyOS4zNjYsNDA5Ljk3NCA0MDUuMDU4LDQ0Ny42MUM0MjAuMzU0LDQ0OC4zMTMgNDM1LjY0OSw0NDcuNTcyIDQ1MC45NTMsNDQ3LjYwNEw1NTkuMjEyLDQ0Ny44MzhDNTczLjA4Miw0NDcuOTU5IDU5MC40ODUsNDQ1LjgxOSA2MDMuNzY4LDQ0OC44MjhDNjAxLjQ4NSw0NDQuNjA2IDU5OC4yODcsNDQwLjkzNCA1OTUuNTE0LDQzNy4wMjlDNTkxLjI4Nyw0MzEuMDc1IDU4Ny43Miw0MjQuNzA1IDU4My4zODMsNDE4LjgwMkM1NzkuMDE0LDQxMi44NTUgNTczLjc3OCw0MDcuNDAxIDU2OS43NDgsNDAxLjIzNEM1NjQuNjQ1LDM5My40MjcgNTYzLjA0MiwzODMuOTQyIDU1Ny45NSwzNzYuMjA3QzU0OC43MDYsMzgwLjQzNSA1MzkuNDY4LDM4My4wNjQgNTI5Ljk4MywzODYuNTE0QzUyMi42NzMsMzg5LjE3MyA1MTUuNDkyLDM5Mi4zMzMgNTA4LjMwNSwzOTUuMzEzQzUyMC45MywzNzAuMjIzIDUzOS4wMjEsMzQ4LjExNiA1NTIuMTIyLDMyMy4zNzVDNTQ1LjUzNCwzMjEuNTEgNTM4LjQ1MSwzMTcuODg0IDUzMS41ODQsMzE3LjQ5N0M1MjguODYxLDMxNy4zNDQgNTI1Ljk5NSwzMTcuNzMxIDUyMy4yNTEsMzE3Ljc2MUw1MjIuODk5LDMxNy4wOTRDNTI0LjAzNiwzMTQuMjA1IDUyNi45NDYsMzExLjc4MiA1MjguODc3LDMwOS4zMzVDNTMxLjgyLDMwNS42MDUgNTM0LjI0LDMwMS40NjkgNTM3LjE3NiwyOTcuNzI1QzU0MC43MTEsMjkzLjIxNiA1NDQuOTMsMjg5LjIzNyA1NDguMDYxLDI4NC40MThMNTQ4LjQ1LDI4My44MDlDNTM2LjI0MSwyODIuMjAzIDUyMi4zMTcsMjgwLjEyNCA1MTAuMzYyLDI4NC4wMjVMNTExLjg2NiwyNzkuNUM1MTguMzI1LDI1OS43OCA1MTguNDU4LDI0NS40NjkgNTEzLjEwMiwyMjUuMzk3QzUxMS42NjEsMjE5Ljk5OSA1MTAuMTc1LDIxNC4xOTMgNTA3LjY0NSwyMDkuMjA0QzQ4NS42MDYsMjI5LjkzOSA0NjUuNTksMjUyLjEzMSA0NDUuMTA3LDI3NC4zNDlDNDM2LjcyMywyODMuNDQyIDQyNy43OTUsMjkxLjk4IDQxOS41NjMsMzAxLjIzN0MzODQuNjkzLDM0MC40NDkgMzUwLjc0NiwzNzkuODEzIDMxMC43NzcsNDE0LjA0M0MzMDUuNTMzLDQxOC41MzQgMzAxLjU5LDQyNC42NzYgMjk2LjM0Niw0MjguNjg3QzI5MS4xOTUsNDMyLjYyNiAyODUuMyw0MzUuMjcyIDI3OS4wMjcsNDM2LjgxN0wyNzguODc5LDQzNi44NTNMMjc4LjA2Miw0MzYuOTMxQzI3Ni40MSw0MzcuMDc2IDI3NS4zNTgsNDM3LjE1MyAyNzQuMDQ4LDQzNi4wODNMMjc0LjAxNyw0MzYuMDU4QzI3Ny4yNiw0MjYuMDczIDI5Mi41NTYsNDA2LjM5OCAyOTkuMDkxLDM5Ni4wODdDMzA2LjgxMiwzODMuOTA1IDMxMi41MDQsMzcwLjQ2OSAzMTkuMTc3LDM1Ny43MDdDMzIxLjg1NSwzNTIuNTg2IDMyNS42OTcsMzQ4LjIwNSAzMjguNTU5LDM0My4yMDNDMzM0LjIzOSwzMzMuMjc1IDMzOS44NzMsMzE5LjA3NyAzNDMuMTMxLDMwOC4xNjZDMzI4LjU4NSwzMjQuOTI0IDMxMS4zOTUsMzQwLjA1NCAyOTUuMDczLDM1NS4wOTNMMjY1Ljc5OSwzODIuNTczQzI1MC4xMiwzOTcuMDQ3IDIzNC4xOTYsNDExLjM4MiAyMTkuMDk1LDQyNi40NTZDMjE1LjMxMiw0MzAuMjMyIDIxMS41ODUsNDM0LjA1MiAyMDcuNjI0LDQzNy42NDNDMjAzLjk5Myw0NDAuOTM2IDE5OS42MDksNDQ0LjEyNyAxOTYuNTM1LDQ0Ny45MkMyMjAuMDcsNDQ4LjQwNiAyNDMuNjA0LDQ0OC4zNzkgMjY3LjEzNyw0NDcuODM4TDI3Ny4zMjMsNDQ3Ljk5OFpNNjY1LjQxNywzMDcuMzg2TDY2Ni4wMTgsMzA3LjYzNUM2NjcuNjgxLDMxNC4wMzcgNjY0LjUzNywzNDUuNjEgNjYyLjc5MywzNTMuMDAzQzY2MS40ODksMzU4LjUzMSA2NTkuMTcsMzYzLjc5NyA2NTcuMDU5LDM2OS4wNTVDNjY1LjM1MywzNjcuMzE5IDY5NS4xODUsMzU3LjM1MiA3MDIuMDEsMzU4LjYwMkM3MDIuNDE0LDM1OS4zMSA3MDIuNTM2LDM1OS4zMzQgNzAyLjYwNywzNjAuMTE3QzcwMy4zOTcsMzY4LjgzNSA2ODguNTcsMzc5Ljc3OCA2ODMuMTA3LDM4NS4zMjVDNjgwLjEzLDM4OC4zNDggNjc3LjY4NywzOTEuNjIyIDY3NS4yODYsMzk1LjExQzY5MS41ODEsMzk2LjYyNSA3MjcuNjU2LDQyNC45NTkgNzQxLjY5NSw0MzUuNDU2Qzc0Ny4wOTQsNDM5LjQ5MyA3NTIuOTQxLDQ0My4wNTEgNzU3Ljk3MSw0NDcuNTQxQzc1Mi4zMDMsNDQ3LjQ5OCA3NDYuNjM1LDQ0Ny41MDUgNzQwLjk2OCw0NDcuNTYzQzcyMS40NDgsNDQ3LjUyIDcwMS45NDQsNDQ4LjE5OSA2ODIuNDExLDQ0Ny45NTFDNjczLjYwNCw0NDcuODM5IDY2NC4zNjIsNDQ4LjA4MyA2NTUuNjYyLDQ0Ni42MjVDNjU2LjE5NSw0NDIuMjU3IDY1NS41NTEsNDM5LjEwOSA2NTMuODE0LDQzNS4xMTRDNjUwLjQ3Myw0MjcuNDI4IDY0NC42MTMsNDIxLjAxNyA2MzkuOTgzLDQxNC4wODlDNjM1LjIzMyw0MDYuOTgyIDYzMS4zMjMsMzk5LjM0MyA2MjYuNDc2LDM5Mi4yODhDNjE5LjE2OSwzODEuNjUgNjEwLjg1NCwzNzEuNzYzIDYwMy42MzEsMzYxLjAwOUM2MTcuOTExLDM0Ny45ODUgNjMwLjYyMywzMzIuNzI5IDY0NS42NTMsMzIwLjYxN0M2NTEuODM3LDMxNS42MzMgNjU4LjY2NSwzMTEuNTM4IDY2NS40MTcsMzA3LjM4NlpNMjc3LjI3LDQ0Ny44MDJMMjc3LjI3Nyw0NDcuODNMMjc3LjMwMSw0NDcuOTIyQzI3Ny4yOTMsNDQ3Ljg5MiAyNzcuMjg1LDQ0Ny44NjEgMjc3LjI3Nyw0NDcuODNMMjc3LjI3NSw0NDcuODIzTDI3Ny4yNyw0NDcuODAyTDI3Ny4yNTksNDQ3Ljc1N0wyNzcuMjUxLDQ0Ny43MjJMMjc3LjI3LDQ0Ny44MDJaTTI3Ny4yMjgsNDQ3LjYxNUwyNzcuMjM1LDQ0Ny42NTFMMjc3LjIyOCw0NDcuNjE1Wk0yNjguNTU3LDQ0Ny40MDRDMjY4LjU3Nyw0NDcuNDAzIDI2OC41OTcsNDQ3LjQwMSAyNjguNjE2LDQ0Ny4zOTlDMjY4LjYzNiw0NDcuMzk4IDI2OC42NTUsNDQ3LjM5NiAyNjguNjc1LDQ0Ny4zOTVMMjY4LjU1Nyw0NDcuNDA0Wk0yNzcuMTc1LDQ0Ny4zMjFDMjc3LjE1NSw0NDcuMTgzIDI3Ny4xMzgsNDQ3LjA0MSAyNzcuMTI1LDQ0Ni44OTZMMjc3LjE4MSw0NDcuMzU4TDI3Ny4xNzUsNDQ3LjMyMVpNMjY5LjEzMiw0NDcuMzQzTDI2OS4xODksNDQ3LjMzM0wyNjkuMTQ2LDQ0Ny4zNDFMMjY5LjEzMiw0NDcuMzQzQzI2OS4wOTgsNDQ3LjM0OCAyNjkuMDYzLDQ0Ny4zNTMgMjY5LjAyNSw0NDcuMzU4TDI2OS4xMzIsNDQ3LjM0M1pNMjY5LjgxOSw0NDcuMTE2QzI2OS41NTIsNDQ3LjIyMiAyNjkuMjc5LDQ0Ny4zMTUgMjY5LjI2OCw0NDcuMzE4TDI2OS44NjYsNDQ3LjA5OEwyNjkuODE5LDQ0Ny4xMTZaTTI3MC4xNzgsNDQ2Ljk2MkMyNzAuMTcsNDQ2Ljk2NiAyNzAuMTYxLDQ0Ni45NyAyNzAuMTUyLDQ0Ni45NzVDMjcwLjE0Myw0NDYuOTc5IDI3MC4xMzQsNDQ2Ljk4MyAyNzAuMTI0LDQ0Ni45ODhDMjcwLjE0Myw0NDYuOTc5IDI3MC4xNjEsNDQ2Ljk3IDI3MC4xNzgsNDQ2Ljk2MlpNMjcwLjE4MSw0NDYuOTZMMjcwLjIxNCw0NDYuOTQ0TDI3MC4xODEsNDQ2Ljk2Wk0yNzAuMjQzLDQ0Ni45MjhDMjcwLjI2Miw0NDYuOTE3IDI3MC4yNzgsNDQ2LjkwNyAyNzAuMjkxLDQ0Ni44OThMMjcwLjIyOSw0NDYuOTM2TDI3MC4yNDMsNDQ2LjkyOFpNMjcwLjMyNSw0NDYuODdDMjcwLjMyMiw0NDYuODczIDI3MC4zMiw0NDYuODc2IDI3MC4zMTYsNDQ2Ljg3OUwyNzAuMzI3LDQ0Ni44NjdMMjcwLjMyNSw0NDYuODdaTTI3MC40MDgsNDQ2Ljc0NEMyNzAuNDAxLDQ0Ni43NTYgMjcwLjM5NCw0NDYuNzY3IDI3MC4zODgsNDQ2Ljc3OEMyNzAuMzgxLDQ0Ni43ODkgMjcwLjM3NCw0NDYuNzk5IDI3MC4zNjcsNDQ2LjgxTDI3MC40MDgsNDQ2Ljc0NFpNMjc3LjEwNCw0NDYuNTc1TDI3Ny4xLDQ0Ni40OTNDMjc3LjEwMiw0NDYuNTM0IDI3Ny4xMDQsNDQ2LjU3NSAyNzcuMTA2LDQ0Ni42MTZMMjc3LjEwNCw0NDYuNTc1Wk0yNzAuNTgsNDQ2LjQwOUMyNzAuNTY1LDQ0Ni40NDIgMjcwLjU1LDQ0Ni40NzMgMjcwLjUzNiw0NDYuNTAzQzI3MC41MjEsNDQ2LjUzNCAyNzAuNTA3LDQ0Ni41NjMgMjcwLjQ5Miw0NDYuNTkxQzI3MC41MjEsNDQ2LjUzNSAyNzAuNTUsNDQ2LjQ3NCAyNzAuNTgsNDQ2LjQwOVpNMjc3LjA5Nyw0NDYuNDExQzI3Ny4wOTYsNDQ2LjM1NSAyNzcuMDk1LDQ0Ni4zIDI3Ny4wOTQsNDQ2LjI0NEwyNzcuMDk5LDQ0Ni40NTJMMjc3LjA5Nyw0NDYuNDExWk0yNzAuNjg5LDQ0Ni4xNTNMMjcwLjczOSw0NDYuMDI2TDI3MC43MTYsNDQ2LjA4NkwyNzAuNjg5LDQ0Ni4xNTNMMjcwLjY3OCw0NDYuMThMMjcwLjY3NCw0NDYuMTlDMjcwLjY1NSw0NDYuMjM2IDI3MC42MzYsNDQ2LjI4IDI3MC42MTgsNDQ2LjMyM0wyNzAuNjc0LDQ0Ni4xOUwyNzAuNjg5LDQ0Ni4xNTNaTTI3Ny4xMTIsNDQ1LjQ2NkwyNzcuMTA5LDQ0NS41NDJMMjc3LjExNiw0NDUuNDEzTDI3Ny4xMTIsNDQ1LjQ2NlpNMjc3LjE2MSw0NDQuODM1TDI3Ny4xNTMsNDQ0LjkyNkMyNzcuMTU1LDQ0NC44OTYgMjc3LjE1OCw0NDQuODY2IDI3Ny4xNjEsNDQ0LjgzNVpNMjcxLjQ3MSw0NDMuNzcyTDI3MS40MTgsNDQzLjk1TDI3MS41MTQsNDQzLjYyNEwyNzEuNDcxLDQ0My43NzJaTTI3MS44ODQsNDQyLjMzM0MyNzEuODQ5LDQ0Mi40NTcgMjcxLjgxNCw0NDIuNTgxIDI3MS43NzksNDQyLjcwNEMyNzEuNzQ0LDQ0Mi44MjYgMjcxLjcwOSw0NDIuOTQ4IDI3MS42NzQsNDQzLjA2OUMyNzEuNzQ0LDQ0Mi44MjggMjcxLjgxMyw0NDIuNTgyIDI3MS44ODQsNDQyLjMzM1pNMjc3LjUzNCw0NDIuNDMxQzI3Ny41MzksNDQyLjQwNiAyNzcuNTQ0LDQ0Mi4zODEgMjc3LjU0OSw0NDIuMzU1QzI3Ny41NTQsNDQyLjMzIDI3Ny41NTksNDQyLjMwNSAyNzcuNTY1LDQ0Mi4yNzlMMjc3LjUzNCw0NDIuNDMxWk0yNzIuMTkxLDQ0MS4yNTFMMjcyLjA2Myw0NDEuNzAyQzI3Mi4xMjQsNDQxLjQ4NiAyNzIuMTg2LDQ0MS4yNjkgMjcyLjI0Nyw0NDEuMDUzTDI3Mi4xOTEsNDQxLjI1MVpNMjc4LjA0NSw0NDAuMTgxTDI3OC4wMTYsNDQwLjI5OEwyNzcuOTEzLDQ0MC43MjJDMjc3Ljk0Nyw0NDAuNTggMjc3Ljk4MSw0NDAuNDM5IDI3OC4wMTYsNDQwLjI5OEwyNzguMDIzLDQ0MC4yNjZMMjc4LjA0NSw0NDAuMTgxTDI3OC4wODksNDQwLjAwMkwyNzguMTM1LDQzOS44MThMMjc4LjA0NSw0NDAuMTgxWk0yNzIuOTA1LDQzOC44NDVDMjcyLjg0Myw0MzkuMDQgMjcyLjc4MSw0MzkuMjQxIDI3Mi43MTksNDM5LjQ0NkMyNzIuNjU2LDQzOS42NTEgMjcyLjU5NCw0MzkuODYgMjcyLjUzMSw0NDAuMDcyQzI3Mi42NTYsNDM5LjY0OCAyNzIuNzgxLDQzOS4yMzUgMjcyLjkwNSw0MzguODQ1Wk0yNzMuMDk1LDQzOC4yNjFDMjczLjA3LDQzOC4zMzYgMjczLjA0NSw0MzguNDExIDI3My4wMiw0MzguNDg4QzI3Mi45OTUsNDM4LjU2NSAyNzIuOTcsNDM4LjY0MiAyNzIuOTQ1LDQzOC43MjFDMjcyLjk5NSw0MzguNTYzIDI3My4wNDUsNDM4LjQxIDI3My4wOTUsNDM4LjI2MVpNMjczLjE2NSw0MzguMDU2QzI3My4xNTgsNDM4LjA3NyAyNzMuMTUxLDQzOC4wOTcgMjczLjE0NCw0MzguMTE4QzI3My4xMzcsNDM4LjEzOCAyNzMuMTMsNDM4LjE1OSAyNzMuMTIzLDQzOC4xNzlMMjczLjE2NSw0MzguMDU2Wk0yNzguNjIxLDQzNy45MTJMMjc4LjYxNCw0MzcuOTM4TDI3OC42MjEsNDM3LjkxMlpNNDg2LjU2OSwyNTUuOTkyQzQ4Ny44NjIsMjU1LjkzMyA0ODkuMzQ3LDI1NS45NTMgNDkwLjU4MSwyNTYuMzY2QzQ5Mi40NjYsMjU2Ljk5NyA0OTMuODQ5LDI1OC4xNTQgNDk0Ljk2NiwyNTkuNTY4QzQ5Ni4yMTEsMjYxLjE0NyA0OTcuMTI0LDI2My4wNDUgNDk4LjAzMiwyNjQuODlDNDk2LjU1LDI2OC41MTYgNDk1LjMxOSwyNzEuNTQxIDQ5MS44OCwyNzMuNzI1QzQ5MS4xODYsMjczLjcwMiA0OTAuNDk0LDI3My42NTcgNDg5LjgwMywyNzMuNTg4QzQ4Ni44NzEsMjczLjI3MSA0ODQuMDA0LDI3MS42ODkgNDgyLjI2MiwyNjkuMjlDNDgyLjA5OSwyNjkuMDYzIDQ4MS45NDcsMjY4LjgyOSA0ODEuODA4LDI2OC41ODdDNDgxLjY2OSwyNjguMzQ0IDQ4MS41NDIsMjY4LjA5NSA0ODEuNDI4LDI2Ny44NEM0ODEuMzE1LDI2Ny41ODUgNDgxLjIxNSwyNjcuMzI0IDQ4MS4xMjgsMjY3LjA1OEM0ODEuMDQyLDI2Ni43OTIgNDgwLjk3LDI2Ni41MjIgNDgwLjkxMSwyNjYuMjQ5QzQ4MC44NTIsMjY1Ljk3NiA0ODAuODA4LDI2NS43IDQ4MC43NzgsMjY1LjQyMkM0ODAuNzQ5LDI2NS4xNDUgNDgwLjczMywyNjQuODY2IDQ4MC43MzIsMjY0LjU4NkM0ODAuNzMxLDI2NC4zMDcgNDgwLjc0NCwyNjQuMDI4IDQ4MC43NzIsMjYzLjc1QzQ4MC44LDI2My40NzIgNDgwLjg0MiwyNjMuMTk2IDQ4MC44OTksMjYyLjkyMkM0ODEuNTgzLDI1OS41NDIgNDgzLjg2MSwyNTcuODM2IDQ4Ni41NjksMjU1Ljk5MloiIHN0eWxlPSJmaWxsOndoaXRlOyIvPgogICAgICAgICAgICA8L2c+CiAgICAgICAgICAgIDxnIHRyYW5zZm9ybT0ibWF0cml4KDEuMDM0NDcsMCwwLDEsLTMuOTI5MjMsLTcuMTc2KSI+CiAgICAgICAgICAgICAgICA8cmVjdCB4PSIxMzQuODY3IiB5PSI3MjguNTk4IiB3aWR0aD0iNzQxLjIwNyIgaGVpZ2h0PSIxNC40MSIgc3R5bGU9ImZpbGw6d2hpdGU7Ii8+CiAgICAgICAgICAgIDwvZz4KICAgICAgICAgICAgPGcgdHJhbnNmb3JtPSJtYXRyaXgoMS45MDEwNywwLDAsMS44NzE0LC0xODIuOTIxLDMyNS45OTkpIj4KICAgICAgICAgICAgICAgIDxnIHRyYW5zZm9ybT0ibWF0cml4KDk2LDAsMCw5NiwxNjcuNTE1LDE1NS44MDcpIj4KICAgICAgICAgICAgICAgICAgICA8cGF0aCBkPSJNMC41NDgsLTAuMTdMMC4yNDUsLTAuMTdMMC4yMDEsLTAuMDc2QzAuMTk0LC0wLjA2MyAwLjE5MSwtMC4wNTMgMC4xOTEsLTAuMDQ1QzAuMTkxLC0wLjAzMSAwLjIwMiwtMC4wMjEgMC4yMjQsLTAuMDE2TDAuMjI0LC0wTDAuMDAzLC0wTDAuMDAzLC0wLjAxNkMwLjAxNiwtMC4wMTggMC4wMjYsLTAuMDIzIDAuMDMzLC0wLjAyOUMwLjA0LC0wLjAzNiAwLjA0OCwtMC4wNDkgMC4wNTcsLTAuMDY3TDAuMzAyLC0wLjU4QzAuMzEsLTAuNTk2IDAuMzEzLC0wLjYwOSAwLjMxMywtMC42MTlDMC4zMTMsLTAuNjM0IDAuMzA0LC0wLjY0NCAwLjI4NSwtMC42NTFMMC4yODUsLTAuNjY3TDAuNTIxLC0wLjY2N0wwLjUyMSwtMC42NTFDMC41MDIsLTAuNjQ1IDAuNDkzLC0wLjYzNiAwLjQ5MywtMC42MjJDMC40OTMsLTAuNjEzIDAuNDk2LC0wLjYwMiAwLjUwMiwtMC41OUwwLjc1NiwtMC4wNzZDMC43NjcsLTAuMDU0IDAuNzc2LC0wLjAzOSAwLjc4NCwtMC4wMzFDMC43OTIsLTAuMDI0IDAuODAzLC0wLjAxOCAwLjgxNywtMC4wMTZMMC44MTcsLTBMMC41NzIsLTBMMC41NzIsLTAuMDE2QzAuNTkzLC0wLjAxOSAwLjYwNCwtMC4wMjkgMC42MDQsLTAuMDQ2QzAuNjA0LC0wLjA1MyAwLjYwMSwtMC4wNjMgMC41OTQsLTAuMDc2TDAuNTQ4LC0wLjE3Wk0wLjUwNiwtMC4yNjFMMC4zOTgsLTAuNDk5TDAuMjg5LC0wLjI2MUwwLjUwNiwtMC4yNjFaIiBzdHlsZT0iZmlsbDp3aGl0ZTtmaWxsLXJ1bGU6bm9uemVybzsiLz4KICAgICAgICAgICAgICAgIDwvZz4KICAgICAgICAgICAgICAgIDxnIHRyYW5zZm9ybT0ibWF0cml4KDk2LDAsMCw5NiwyNDUuNjA4LDE1NS44MDcpIj4KICAgICAgICAgICAgICAgICAgICA8cGF0aCBkPSJNMC4yMzcsLTAuMTFMMC42NDYsLTAuMTFDMC42NTcsLTAuMTEgMC42NjQsLTAuMTEyIDAuNjY5LC0wLjExN0MwLjY3NSwtMC4xMjEgMC42NzksLTAuMTI5IDAuNjgyLC0wLjE0MUwwLjY5OCwtMC4xNDFMMC42OTgsMC4wMzFMMC42ODIsMC4wMzFDMC42NzksMC4wMiAwLjY3NSwwLjAxMSAwLjY2OSwwLjAwN0MwLjY2NCwwLjAwMiAwLjY1NywtMCAwLjY0NiwtMEwwLjA2LC0wTDAuMDYsLTAuMDE2QzAuMDcyLC0wLjAxOSAwLjA4LC0wLjAyMyAwLjA4NCwtMC4wMjhDMC4wODksLTAuMDM0IDAuMDkxLC0wLjA0MSAwLjA5MSwtMC4wNTFMMC4wOTEsLTAuNjE1QzAuMDkxLC0wLjYyNSAwLjA4OSwtMC42MzMgMC4wODQsLTAuNjM4QzAuMDgsLTAuNjQzIDAuMDcyLC0wLjY0OCAwLjA2LC0wLjY1MUwwLjA2LC0wLjY2N0wwLjI2OCwtMC42NjdMMC4yNjgsLTAuNjUxQzAuMjU2LC0wLjY0OCAwLjI0OCwtMC42NDQgMC4yNDQsLTAuNjM4QzAuMjM5LC0wLjYzMyAwLjIzNywtMC42MjUgMC4yMzcsLTAuNjE1TDAuMjM3LC0wLjExWiIgc3R5bGU9ImZpbGw6d2hpdGU7ZmlsbC1ydWxlOm5vbnplcm87Ii8+CiAgICAgICAgICAgICAgICA8L2c+CiAgICAgICAgICAgICAgICA8ZyB0cmFuc2Zvcm09Im1hdHJpeCg5NiwwLDAsOTYsMzEzLjUzLDE1NS44MDcpIj4KICAgICAgICAgICAgICAgICAgICA8cGF0aCBkPSJNMC4yMzcsLTAuMjcxTDAuMjM3LC0wLjA1MUMwLjIzNywtMC4wNDEgMC4yMzksLTAuMDMzIDAuMjQ0LC0wLjAyOEMwLjI0OSwtMC4wMjMgMC4yNTcsLTAuMDE5IDAuMjY4LC0wLjAxNkwwLjI2OCwtMEwwLjA2LC0wTDAuMDYsLTAuMDE2QzAuMDcyLC0wLjAxOSAwLjA4LC0wLjAyMyAwLjA4NCwtMC4wMjhDMC4wODksLTAuMDM0IDAuMDkxLC0wLjA0MSAwLjA5MSwtMC4wNTFMMC4wOTEsLTAuNjE1QzAuMDkxLC0wLjYyNSAwLjA4OSwtMC42MzMgMC4wODQsLTAuNjM4QzAuMDgsLTAuNjQzIDAuMDcyLC0wLjY0OCAwLjA2LC0wLjY1MUwwLjA2LC0wLjY2N0wwLjQ3NiwtMC42NjdDMC41MjUsLTAuNjY3IDAuNTYzLC0wLjY2MyAwLjU5MiwtMC42NTdDMC42MiwtMC42NSAwLjY0NSwtMC42MzkgMC42NjcsLTAuNjIzQzAuNjg5LC0wLjYwNiAwLjcwNywtMC41ODUgMC43MiwtMC41NThDMC43MzIsLTAuNTMgMC43MzksLTAuNSAwLjczOSwtMC40NjlDMC43MzksLTAuNDI0IDAuNzI3LC0wLjM4NCAwLjcwMywtMC4zNTFDMC42ODIsLTAuMzIyIDAuNjU0LC0wLjMwMSAwLjYyLC0wLjI4OUMwLjU4NiwtMC4yNzcgMC41MzgsLTAuMjcxIDAuNDc2LC0wLjI3MUwwLjIzNywtMC4yNzFaTTAuMjM3LC0wLjM4TDAuNDU5LC0wLjM4QzAuNTAzLC0wLjM4IDAuNTM0LC0wLjM4NSAwLjU1MiwtMC4zOTVDMC41NjQsLTAuNDAxIDAuNTczLC0wLjQxMSAwLjU4LC0wLjQyNEMwLjU4NywtMC40MzggMC41OSwtMC40NTIgMC41OSwtMC40NjlDMC41OSwtMC40ODUgMC41ODcsLTAuNDk5IDAuNTgsLTAuNTEzQzAuNTczLC0wLjUyNiAwLjU2NCwtMC41MzYgMC41NTIsLTAuNTQyQzAuNTM1LC0wLjU1MiAwLjUwNCwtMC41NTcgMC40NTksLTAuNTU3TDAuMjM3LC0wLjU1N0wwLjIzNywtMC4zOFoiIHN0eWxlPSJmaWxsOndoaXRlO2ZpbGwtcnVsZTpub256ZXJvOyIvPgogICAgICAgICAgICAgICAgPC9nPgogICAgICAgICAgICAgICAgPGcgdHJhbnNmb3JtPSJtYXRyaXgoOTYsMCwwLDk2LDM4Ni45ODMsMTU1LjgwNykiPgogICAgICAgICAgICAgICAgICAgIDxwYXRoIGQ9Ik0wLjA5MSwtMC4wNTFMMC4wOTEsLTAuNjE2QzAuMDkxLC0wLjYyNSAwLjA4OSwtMC42MzMgMC4wODQsLTAuNjM4QzAuMDgsLTAuNjQzIDAuMDcyLC0wLjY0OCAwLjA2LC0wLjY1MUwwLjA2LC0wLjY2N0wwLjI2OCwtMC42NjdMMC4yNjgsLTAuNjUxQzAuMjU2LC0wLjY0OCAwLjI0OCwtMC42NDQgMC4yNDQsLTAuNjM4QzAuMjM5LC0wLjYzMyAwLjIzNywtMC42MjUgMC4yMzcsLTAuNjE2TDAuMjM3LC0wLjA1MUMwLjIzNywtMC4wNDEgMC4yMzksLTAuMDMzIDAuMjQ0LC0wLjAyOEMwLjI0OSwtMC4wMjMgMC4yNTcsLTAuMDE5IDAuMjY4LC0wLjAxNkwwLjI2OCwtMEwwLjA2LC0wTDAuMDYsLTAuMDE2QzAuMDcyLC0wLjAxOSAwLjA4LC0wLjAyMyAwLjA4NCwtMC4wMjhDMC4wODksLTAuMDM0IDAuMDkxLC0wLjA0MSAwLjA5MSwtMC4wNTFaIiBzdHlsZT0iZmlsbDp3aGl0ZTtmaWxsLXJ1bGU6bm9uemVybzsiLz4KICAgICAgICAgICAgICAgIDwvZz4KICAgICAgICAgICAgICAgIDxnIHRyYW5zZm9ybT0ibWF0cml4KDk2LDAsMCw5Niw0MTguNDM2LDE1NS44MDcpIj4KICAgICAgICAgICAgICAgICAgICA8cGF0aCBkPSJNMC4yMjUsLTAuNDU3TDAuMjI1LC0wLjA1MUMwLjIyNSwtMC4wNDEgMC4yMjcsLTAuMDM0IDAuMjMxLC0wLjAyOEMwLjIzNiwtMC4wMjMgMC4yNDQsLTAuMDE5IDAuMjU2LC0wLjAxNkwwLjI1NiwtMEwwLjA2LC0wTDAuMDYsLTAuMDE2QzAuMDcyLC0wLjAxOSAwLjA4LC0wLjAyMyAwLjA4NCwtMC4wMjhDMC4wODksLTAuMDM0IDAuMDkxLC0wLjA0MSAwLjA5MSwtMC4wNTFMMC4wOTEsLTAuNjE2QzAuMDkxLC0wLjYyNSAwLjA4OSwtMC42MzMgMC4wODQsLTAuNjM4QzAuMDgsLTAuNjQzIDAuMDcyLC0wLjY0OCAwLjA2LC0wLjY1MUwwLjA2LC0wLjY2N0wwLjI4NywtMC42NjdMMC4yODcsLTAuNjUxQzAuMjczLC0wLjY0NyAwLjI2NiwtMC42NCAwLjI2NiwtMC42MjhDMC4yNjYsLTAuNjIxIDAuMjcsLTAuNjEyIDAuMjc5LC0wLjYwM0wwLjY0NywtMC4yMkwwLjY0NywtMC42MTZDMC42NDcsLTAuNjI1IDAuNjQ1LC0wLjYzMyAwLjY0LC0wLjYzOEMwLjYzNiwtMC42NDMgMC42MjcsLTAuNjQ4IDAuNjE2LC0wLjY1MUwwLjYxNiwtMC42NjdMMC44MTIsLTAuNjY3TDAuODEyLC0wLjY1MUMwLjgsLTAuNjQ4IDAuNzkyLC0wLjY0MyAwLjc4NywtMC42MzhDMC43ODMsLTAuNjMzIDAuNzgsLTAuNjI1IDAuNzgsLTAuNjE2TDAuNzgsLTAuMDUxQzAuNzgsLTAuMDQxIDAuNzgzLC0wLjAzNCAwLjc4NywtMC4wMjhDMC43OTIsLTAuMDIzIDAuOCwtMC4wMTkgMC44MTIsLTAuMDE2TDAuODEyLC0wTDAuNjAyLC0wTDAuNjAyLC0wLjAxNkMwLjYxNSwtMC4wMTkgMC42MjIsLTAuMDI2IDAuNjIyLC0wLjAzN0MwLjYyMiwtMC4wNDMgMC42MTQsLTAuMDU1IDAuNTk4LC0wLjA3MUwwLjIyNSwtMC40NTdaIiBzdHlsZT0iZmlsbDp3aGl0ZTtmaWxsLXJ1bGU6bm9uemVybzsiLz4KICAgICAgICAgICAgICAgIDwvZz4KICAgICAgICAgICAgICAgIDxnIHRyYW5zZm9ybT0ibWF0cml4KDk2LDAsMCw5Niw1MDIuMTA4LDE1NS44MDcpIj4KICAgICAgICAgICAgICAgICAgICA8cGF0aCBkPSJNMC4yMzcsLTAuMjkyTDAuMjM3LC0wLjExTDAuNjY2LC0wLjExQzAuNjc1LC0wLjExIDAuNjgzLC0wLjExMiAwLjY4OCwtMC4xMTdDMC42OTMsLTAuMTIxIDAuNjk3LC0wLjEyOSAwLjcwMSwtMC4xNDFMMC43MTYsLTAuMTQxTDAuNzE2LDAuMDMxTDAuNzAxLDAuMDMxQzAuNjk3LDAuMDIgMC42OTMsMC4wMTEgMC42ODgsMC4wMDdDMC42ODMsMC4wMDIgMC42NzUsLTAgMC42NjYsLTBMMC4wNiwtMEwwLjA2LC0wLjAxNkMwLjA3MiwtMC4wMTkgMC4wOCwtMC4wMjMgMC4wODQsLTAuMDI4QzAuMDg5LC0wLjAzNCAwLjA5MSwtMC4wNDEgMC4wOTEsLTAuMDUxTDAuMDkxLC0wLjYxNUMwLjA5MSwtMC42MjUgMC4wODksLTAuNjMzIDAuMDg0LC0wLjYzOEMwLjA4LC0wLjY0MyAwLjA3MiwtMC42NDggMC4wNiwtMC42NTFMMC4wNiwtMC42NjdMMC42NTQsLTAuNjY3QzAuNjY0LC0wLjY2NyAwLjY3MSwtMC42NjkgMC42NzYsLTAuNjczQzAuNjgxLC0wLjY3OCAwLjY4NiwtMC42ODYgMC42ODksLTAuNjk4TDAuNzA1LC0wLjY5OEwwLjcwNSwtMC41MjVMMC42ODksLTAuNTI1QzAuNjg2LC0wLjUzNyAwLjY4MSwtMC41NDUgMC42NzYsLTAuNTVDMC42NzEsLTAuNTU0IDAuNjY0LC0wLjU1NyAwLjY1NCwtMC41NTdMMC4yMzcsLTAuNTU3TDAuMjM3LC0wLjM5OEwwLjUwNywtMC4zOThDMC41MTcsLTAuMzk4IDAuNTI1LC0wLjQwMSAwLjUzLC0wLjQwNUMwLjUzNSwtMC40MSAwLjUzOSwtMC40MTggMC41NDIsLTAuNDNMMC41NTgsLTAuNDNMMC41NTgsLTAuMjYxTDAuNTQyLC0wLjI2MUMwLjUzOSwtMC4yNzMgMC41MzUsLTAuMjgxIDAuNTMsLTAuMjg1QzAuNTI1LC0wLjI5IDAuNTE3LC0wLjI5MiAwLjUwNywtMC4yOTJMMC4yMzcsLTAuMjkyWiIgc3R5bGU9ImZpbGw6d2hpdGU7ZmlsbC1ydWxlOm5vbnplcm87Ii8+CiAgICAgICAgICAgICAgICA8L2c+CiAgICAgICAgICAgIDwvZz4KICAgICAgICAgICAgPGcgdHJhbnNmb3JtPSJtYXRyaXgoMC43NDEzODksMCwwLDAuNzI5ODE5LDcuNzU2MTEsNTc2LjE5OCkiPgogICAgICAgICAgICAgICAgPGcgdHJhbnNmb3JtPSJtYXRyaXgoOTYsMCwwLDk2LDE2Ny41MTUsMTU1LjgwNykiPgogICAgICAgICAgICAgICAgICAgIDxwYXRoIGQ9Ik0wLjU0OCwtMC4xN0wwLjI0NSwtMC4xN0wwLjIwMSwtMC4wNzZDMC4xOTQsLTAuMDYzIDAuMTkxLC0wLjA1MyAwLjE5MSwtMC4wNDVDMC4xOTEsLTAuMDMxIDAuMjAyLC0wLjAyMSAwLjIyNCwtMC4wMTZMMC4yMjQsLTBMMC4wMDMsLTBMMC4wMDMsLTAuMDE2QzAuMDE2LC0wLjAxOCAwLjAyNiwtMC4wMjMgMC4wMzMsLTAuMDI5QzAuMDQsLTAuMDM2IDAuMDQ4LC0wLjA0OSAwLjA1NywtMC4wNjdMMC4zMDIsLTAuNThDMC4zMSwtMC41OTYgMC4zMTMsLTAuNjA5IDAuMzEzLC0wLjYxOUMwLjMxMywtMC42MzQgMC4zMDQsLTAuNjQ0IDAuMjg1LC0wLjY1MUwwLjI4NSwtMC42NjdMMC41MjEsLTAuNjY3TDAuNTIxLC0wLjY1MUMwLjUwMiwtMC42NDUgMC40OTMsLTAuNjM2IDAuNDkzLC0wLjYyMkMwLjQ5MywtMC42MTMgMC40OTYsLTAuNjAyIDAuNTAyLC0wLjU5TDAuNzU2LC0wLjA3NkMwLjc2NywtMC4wNTQgMC43NzYsLTAuMDM5IDAuNzg0LC0wLjAzMUMwLjc5MiwtMC4wMjQgMC44MDMsLTAuMDE4IDAuODE3LC0wLjAxNkwwLjgxNywtMEwwLjU3MiwtMEwwLjU3MiwtMC4wMTZDMC41OTMsLTAuMDE5IDAuNjA0LC0wLjAyOSAwLjYwNCwtMC4wNDZDMC42MDQsLTAuMDUzIDAuNjAxLC0wLjA2MyAwLjU5NCwtMC4wNzZMMC41NDgsLTAuMTdaTTAuNTA2LC0wLjI2MUwwLjM5OCwtMC40OTlMMC4yODksLTAuMjYxTDAuNTA2LC0wLjI2MVoiIHN0eWxlPSJmaWxsOndoaXRlO2ZpbGwtcnVsZTpub256ZXJvOyIvPgogICAgICAgICAgICAgICAgPC9nPgogICAgICAgICAgICAgICAgPGcgdHJhbnNmb3JtPSJtYXRyaXgoOTYsMCwwLDk2LDI0NS42MDgsMTU1LjgwNykiPgogICAgICAgICAgICAgICAgICAgIDxwYXRoIGQ9Ik0wLjA5MSwtMC4wNTFMMC4wOTEsLTAuNjE1QzAuMDkxLC0wLjYyNSAwLjA4OSwtMC42MzMgMC4wODQsLTAuNjM4QzAuMDgsLTAuNjQzIDAuMDcyLC0wLjY0OCAwLjA2LC0wLjY1MUwwLjA2LC0wLjY2N0wwLjQzMiwtMC42NjdDMC41MTMsLTAuNjY3IDAuNTc2LC0wLjY1NyAwLjYyLC0wLjYzOUMwLjY4MiwtMC42MTIgMC43MjgsLTAuNTY3IDAuNzU4LC0wLjUwNUMwLjc4MiwtMC40NTYgMC43OTQsLTAuMzk5IDAuNzk0LC0wLjMzM0MwLjc5NCwtMC4yMTkgMC43NTksLTAuMTMxIDAuNjg4LC0wLjA3MUMwLjY1OCwtMC4wNDUgMC42MjQsLTAuMDI3IDAuNTg0LC0wLjAxNkMwLjU0NCwtMC4wMDUgMC40OTMsLTAgMC40MzIsLTBMMC4wNiwtMEwwLjA2LC0wLjAxNkMwLjA3MiwtMC4wMTkgMC4wOCwtMC4wMjMgMC4wODQsLTAuMDI4QzAuMDg5LC0wLjAzNCAwLjA5MSwtMC4wNDEgMC4wOTEsLTAuMDUxWk0wLjIzNywtMC4xMUwwLjQyLC0wLjExQzAuNDc4LC0wLjExIDAuNTIyLC0wLjExOCAwLjU1MSwtMC4xMzRDMC42MTQsLTAuMTY4IDAuNjQ2LC0wLjIzNSAwLjY0NiwtMC4zMzNDMC42NDYsLTAuNDA1IDAuNjI5LC0wLjQ2IDAuNTk1LC0wLjQ5OEMwLjU3NiwtMC41MTkgMC41NTMsLTAuNTM0IDAuNTI2LC0wLjU0M0MwLjUsLTAuNTUyIDAuNDY0LC0wLjU1NyAwLjQyLC0wLjU1N0wwLjIzNywtMC41NTdMMC4yMzcsLTAuMTFaIiBzdHlsZT0iZmlsbDp3aGl0ZTtmaWxsLXJ1bGU6bm9uemVybzsiLz4KICAgICAgICAgICAgICAgIDwvZz4KICAgICAgICAgICAgICAgIDxnIHRyYW5zZm9ybT0ibWF0cml4KDk2LDAsMCw5NiwzMjIuMzQzLDE1NS44MDcpIj4KICAgICAgICAgICAgICAgICAgICA8cGF0aCBkPSJNMC4zODksLTAuMTk0TDAuNTY2LC0wLjU4NUMwLjU3NCwtMC42MDIgMC41NzgsLTAuNjE0IDAuNTc4LC0wLjYyNEMwLjU3OCwtMC42MzggMC41NjcsLTAuNjQ3IDAuNTQ1LC0wLjY1MUwwLjU0NSwtMC42NjdMMC43NzMsLTAuNjY3TDAuNzczLC0wLjY1MUMwLjc1OSwtMC42NDkgMC43NDksLTAuNjQ1IDAuNzQ0LC0wLjYzOUMwLjczOCwtMC42MzIgMC43MjgsLTAuNjE1IDAuNzE1LC0wLjU4NUwwLjQ4NCwtMC4wODZDMC40NzQsLTAuMDY1IDAuNDY5LC0wLjA1MSAwLjQ2OSwtMC4wNDVDMC40NjksLTAuMDI4IDAuNDc5LC0wLjAxOSAwLjUsLTAuMDE2TDAuNSwtMEwwLjI3LC0wTDAuMjcsLTAuMDE2QzAuMjkxLC0wLjAxOSAwLjMwMSwtMC4wMjggMC4zMDEsLTAuMDQ1QzAuMzAxLC0wLjA1MiAwLjI5NiwtMC4wNjUgMC4yODYsLTAuMDg2TDAuMDU1LC0wLjU4NUMwLjA0MiwtMC42MTUgMC4wMzIsLTAuNjMyIDAuMDI2LC0wLjYzOUMwLjAyMSwtMC42NDUgMC4wMTEsLTAuNjQ5IC0wLjAwMywtMC42NTFMLTAuMDAzLC0wLjY2N0wwLjIzMywtMC42NjdMMC4yMzMsLTAuNjUxQzAuMjEyLC0wLjY0NyAwLjIwMSwtMC42MzggMC4yMDEsLTAuNjI0QzAuMjAxLC0wLjYxNCAwLjIwNSwtMC42MDIgMC4yMTIsLTAuNTg1TDAuMzg5LC0wLjE5NFoiIHN0eWxlPSJmaWxsOndoaXRlO2ZpbGwtcnVsZTpub256ZXJvOyIvPgogICAgICAgICAgICAgICAgPC9nPgogICAgICAgICAgICAgICAgPGcgdHJhbnNmb3JtPSJtYXRyaXgoOTYsMCwwLDk2LDM5Ni4yNjUsMTU1LjgwNykiPgogICAgICAgICAgICAgICAgICAgIDxwYXRoIGQ9Ik0wLjIzNywtMC4yOTJMMC4yMzcsLTAuMTFMMC42NjYsLTAuMTFDMC42NzUsLTAuMTEgMC42ODMsLTAuMTEyIDAuNjg4LC0wLjExN0MwLjY5MywtMC4xMjEgMC42OTcsLTAuMTI5IDAuNzAxLC0wLjE0MUwwLjcxNiwtMC4xNDFMMC43MTYsMC4wMzFMMC43MDEsMC4wMzFDMC42OTcsMC4wMiAwLjY5MywwLjAxMSAwLjY4OCwwLjAwN0MwLjY4MywwLjAwMiAwLjY3NSwtMCAwLjY2NiwtMEwwLjA2LC0wTDAuMDYsLTAuMDE2QzAuMDcyLC0wLjAxOSAwLjA4LC0wLjAyMyAwLjA4NCwtMC4wMjhDMC4wODksLTAuMDM0IDAuMDkxLC0wLjA0MSAwLjA5MSwtMC4wNTFMMC4wOTEsLTAuNjE1QzAuMDkxLC0wLjYyNSAwLjA4OSwtMC42MzMgMC4wODQsLTAuNjM4QzAuMDgsLTAuNjQzIDAuMDcyLC0wLjY0OCAwLjA2LC0wLjY1MUwwLjA2LC0wLjY2N0wwLjY1NCwtMC42NjdDMC42NjQsLTAuNjY3IDAuNjcxLC0wLjY2OSAwLjY3NiwtMC42NzNDMC42ODEsLTAuNjc4IDAuNjg2LC0wLjY4NiAwLjY4OSwtMC42OThMMC43MDUsLTAuNjk4TDAuNzA1LC0wLjUyNUwwLjY4OSwtMC41MjVDMC42ODYsLTAuNTM3IDAuNjgxLC0wLjU0NSAwLjY3NiwtMC41NUMwLjY3MSwtMC41NTQgMC42NjQsLTAuNTU3IDAuNjU0LC0wLjU1N0wwLjIzNywtMC41NTdMMC4yMzcsLTAuMzk4TDAuNTA3LC0wLjM5OEMwLjUxNywtMC4zOTggMC41MjUsLTAuNDAxIDAuNTMsLTAuNDA1QzAuNTM1LC0wLjQxIDAuNTM5LC0wLjQxOCAwLjU0MiwtMC40M0wwLjU1OCwtMC40M0wwLjU1OCwtMC4yNjFMMC41NDIsLTAuMjYxQzAuNTM5LC0wLjI3MyAwLjUzNSwtMC4yODEgMC41MywtMC4yODVDMC41MjUsLTAuMjkgMC41MTcsLTAuMjkyIDAuNTA3LC0wLjI5MkwwLjIzNywtMC4yOTJaIiBzdHlsZT0iZmlsbDp3aGl0ZTtmaWxsLXJ1bGU6bm9uemVybzsiLz4KICAgICAgICAgICAgICAgIDwvZz4KICAgICAgICAgICAgICAgIDxnIHRyYW5zZm9ybT0ibWF0cml4KDk2LDAsMCw5Niw0NjkuMjAyLDE1NS44MDcpIj4KICAgICAgICAgICAgICAgICAgICA8cGF0aCBkPSJNMC4yMjUsLTAuNDU3TDAuMjI1LC0wLjA1MUMwLjIyNSwtMC4wNDEgMC4yMjcsLTAuMDM0IDAuMjMxLC0wLjAyOEMwLjIzNiwtMC4wMjMgMC4yNDQsLTAuMDE5IDAuMjU2LC0wLjAxNkwwLjI1NiwtMEwwLjA2LC0wTDAuMDYsLTAuMDE2QzAuMDcyLC0wLjAxOSAwLjA4LC0wLjAyMyAwLjA4NCwtMC4wMjhDMC4wODksLTAuMDM0IDAuMDkxLC0wLjA0MSAwLjA5MSwtMC4wNTFMMC4wOTEsLTAuNjE2QzAuMDkxLC0wLjYyNSAwLjA4OSwtMC42MzMgMC4wODQsLTAuNjM4QzAuMDgsLTAuNjQzIDAuMDcyLC0wLjY0OCAwLjA2LC0wLjY1MUwwLjA2LC0wLjY2N0wwLjI4NywtMC42NjdMMC4yODcsLTAuNjUxQzAuMjczLC0wLjY0NyAwLjI2NiwtMC42NCAwLjI2NiwtMC42MjhDMC4yNjYsLTAuNjIxIDAuMjcsLTAuNjEyIDAuMjc5LC0wLjYwM0wwLjY0NywtMC4yMkwwLjY0NywtMC42MTZDMC42NDcsLTAuNjI1IDAuNjQ1LC0wLjYzMyAwLjY0LC0wLjYzOEMwLjYzNiwtMC42NDMgMC42MjcsLTAuNjQ4IDAuNjE2LC0wLjY1MUwwLjYxNiwtMC42NjdMMC44MTIsLTAuNjY3TDAuODEyLC0wLjY1MUMwLjgsLTAuNjQ4IDAuNzkyLC0wLjY0MyAwLjc4NywtMC42MzhDMC43ODMsLTAuNjMzIDAuNzgsLTAuNjI1IDAuNzgsLTAuNjE2TDAuNzgsLTAuMDUxQzAuNzgsLTAuMDQxIDAuNzgzLC0wLjAzNCAwLjc4NywtMC4wMjhDMC43OTIsLTAuMDIzIDAuOCwtMC4wMTkgMC44MTIsLTAuMDE2TDAuODEyLC0wTDAuNjAyLC0wTDAuNjAyLC0wLjAxNkMwLjYxNSwtMC4wMTkgMC42MjIsLTAuMDI2IDAuNjIyLC0wLjAzN0MwLjYyMiwtMC4wNDMgMC42MTQsLTAuMDU1IDAuNTk4LC0wLjA3MUwwLjIyNSwtMC40NTdaIiBzdHlsZT0iZmlsbDp3aGl0ZTtmaWxsLXJ1bGU6bm9uemVybzsiLz4KICAgICAgICAgICAgICAgIDwvZz4KICAgICAgICAgICAgICAgIDxnIHRyYW5zZm9ybT0ibWF0cml4KDk2LDAsMCw5Niw1NTIuODc0LDE1NS44MDcpIj4KICAgICAgICAgICAgICAgICAgICA8cGF0aCBkPSJNMC40MSwtMC41NTdMMC40MSwtMC4wNTFDMC40MSwtMC4wNDEgMC40MTIsLTAuMDM0IDAuNDE3LC0wLjAyOEMwLjQyMiwtMC4wMjMgMC40MywtMC4wMTkgMC40NDEsLTAuMDE2TDAuNDQxLC0wTDAuMjMzLC0wTDAuMjMzLC0wLjAxNkMwLjI0NSwtMC4wMTkgMC4yNTMsLTAuMDIzIDAuMjU4LC0wLjAyOEMwLjI2MiwtMC4wMzQgMC4yNjUsLTAuMDQxIDAuMjY1LC0wLjA1MUwwLjI2NSwtMC41NTdMMC4wNTgsLTAuNTU3QzAuMDQ4LC0wLjU1NyAwLjA0LC0wLjU1NCAwLjAzNSwtMC41NUMwLjAzLC0wLjU0NSAwLjAyNiwtMC41MzcgMC4wMjIsLTAuNTI1TDAuMDA3LC0wLjUyNUwwLjAwNywtMC42OThMMC4wMjIsLTAuNjk4QzAuMDI2LC0wLjY4NiAwLjAzLC0wLjY3OCAwLjAzNSwtMC42NzNDMC4wNCwtMC42NjkgMC4wNDgsLTAuNjY3IDAuMDU4LC0wLjY2N0wwLjYxNywtMC42NjdDMC42MjcsLTAuNjY3IDAuNjM0LC0wLjY2OSAwLjY0LC0wLjY3M0MwLjY0NSwtMC42NzggMC42NDksLTAuNjg2IDAuNjUyLC0wLjY5OEwwLjY2OCwtMC42OThMMC42NjgsLTAuNTI1TDAuNjUyLC0wLjUyNUMwLjY0OSwtMC41MzcgMC42NDUsLTAuNTQ1IDAuNjQsLTAuNTVDMC42MzQsLTAuNTU0IDAuNjI3LC0wLjU1NyAwLjYxNywtMC41NTdMMC40MSwtMC41NTdaIiBzdHlsZT0iZmlsbDp3aGl0ZTtmaWxsLXJ1bGU6bm9uemVybzsiLz4KICAgICAgICAgICAgICAgIDwvZz4KICAgICAgICAgICAgICAgIDxnIHRyYW5zZm9ybT0ibWF0cml4KDk2LDAsMCw5Niw2MTcuNjA4LDE1NS44MDcpIj4KICAgICAgICAgICAgICAgICAgICA8cGF0aCBkPSJNMC43MzgsLTAuNjE1TDAuNzM4LC0wLjI0MkMwLjczOCwtMC4xNzEgMC43MjUsLTAuMTE4IDAuNjk5LC0wLjA4M0MwLjY1MiwtMC4wMiAwLjU1NiwwLjAxMiAwLjQwOSwwLjAxMkMwLjMwMywwLjAxMiAwLjIyMSwtMC4wMDUgMC4xNjUsLTAuMDRDMC4xMzIsLTAuMDU5IDAuMTA5LC0wLjA4NiAwLjA5NywtMC4xMjFDMC4wODQsLTAuMTU0IDAuMDc4LC0wLjE5NSAwLjA3OCwtMC4yNDJMMC4wNzgsLTAuNjE1QzAuMDc4LC0wLjYyNSAwLjA3NiwtMC42MzMgMC4wNzEsLTAuNjM4QzAuMDY3LC0wLjY0MyAwLjA1OSwtMC42NDggMC4wNDcsLTAuNjUxTDAuMDQ3LC0wLjY2N0wwLjI1NSwtMC42NjdMMC4yNTUsLTAuNjUxQzAuMjQzLC0wLjY0OCAwLjIzNSwtMC42NDQgMC4yMzEsLTAuNjM4QzAuMjI2LC0wLjYzMyAwLjIyNCwtMC42MjUgMC4yMjQsLTAuNjE1TDAuMjI0LC0wLjI4M0MwLjIyNCwtMC4yNDQgMC4yMjYsLTAuMjE2IDAuMjMsLTAuMTk4QzAuMjM0LC0wLjE3OSAwLjI0MiwtMC4xNjQgMC4yNTQsLTAuMTUxQzAuMjg1LC0wLjExOCAwLjMzOSwtMC4xMDIgMC40MTQsLTAuMTAyQzAuNDksLTAuMTAyIDAuNTQzLC0wLjExOCAwLjU3NSwtMC4xNTFDMC41ODYsLTAuMTY0IDAuNTk0LC0wLjE3OSAwLjU5OCwtMC4xOThDMC42MDIsLTAuMjE2IDAuNjA0LC0wLjI0NCAwLjYwNCwtMC4yODNMMC42MDQsLTAuNjE1QzAuNjA0LC0wLjYyNSAwLjYwMiwtMC42MzMgMC41OTgsLTAuNjM4QzAuNTkzLC0wLjY0MyAwLjU4NSwtMC42NDggMC41NzMsLTAuNjUxTDAuNTczLC0wLjY2N0wwLjc3LC0wLjY2N0wwLjc3LC0wLjY1MUMwLjc1OCwtMC42NDggMC43NSwtMC42NDMgMC43NDUsLTAuNjM4QzAuNzQxLC0wLjYzMyAwLjczOCwtMC42MjUgMC43MzgsLTAuNjE1WiIgc3R5bGU9ImZpbGw6d2hpdGU7ZmlsbC1ydWxlOm5vbnplcm87Ii8+CiAgICAgICAgICAgICAgICA8L2c+CiAgICAgICAgICAgICAgICA8ZyB0cmFuc2Zvcm09Im1hdHJpeCg5NiwwLDAsOTYsNjk1LjkzNiwxNTUuODA3KSI+CiAgICAgICAgICAgICAgICAgICAgPHBhdGggZD0iTTAuMjM3LC0wLjI3MkwwLjIzNywtMC4wNTFDMC4yMzcsLTAuMDQxIDAuMjM5LC0wLjAzMyAwLjI0NCwtMC4wMjhDMC4yNDksLTAuMDIzIDAuMjU3LC0wLjAxOSAwLjI2OCwtMC4wMTZMMC4yNjgsLTBMMC4wNiwtMEwwLjA2LC0wLjAxNkMwLjA3MiwtMC4wMTkgMC4wOCwtMC4wMjMgMC4wODQsLTAuMDI4QzAuMDg5LC0wLjAzNCAwLjA5MSwtMC4wNDEgMC4wOTEsLTAuMDUxTDAuMDkxLC0wLjYxNUMwLjA5MSwtMC42MjUgMC4wODksLTAuNjMzIDAuMDg0LC0wLjYzOEMwLjA4LC0wLjY0MyAwLjA3MiwtMC42NDggMC4wNiwtMC42NTFMMC4wNiwtMC42NjdMMC40NzcsLTAuNjY3QzAuNTI4LC0wLjY2NyAwLjU2OCwtMC42NjQgMC41OTgsLTAuNjU4QzAuNjI4LC0wLjY1MiAwLjY1NCwtMC42NDIgMC42NzYsLTAuNjI3QzAuNzAxLC0wLjYxIDAuNzIxLC0wLjU4NyAwLjczMywtMC41NThDMC43NDUsLTAuNTMgMC43NTEsLTAuNSAwLjc1MSwtMC40NjhDMC43NTEsLTAuMzYgMC42OTEsLTAuMjk2IDAuNTcsLTAuMjc3TDAuNywtMC4wOTNDMC43MjIsLTAuMDYxIDAuNzM5LC0wLjA0IDAuNzUsLTAuMDMxQzAuNzYsLTAuMDIyIDAuNzczLC0wLjAxNyAwLjc4OSwtMC4wMTZMMC43ODksLTBMMC41NDgsLTBMMC41NDgsLTAuMDE2QzAuNTY0LC0wLjAxOCAwLjU3MywtMC4wMjQgMC41NzMsLTAuMDMzQzAuNTczLC0wLjAzOSAwLjU2NSwtMC4wNTMgMC41NSwtMC4wNzZMMC40MTUsLTAuMjcyTDAuMjM3LC0wLjI3MlpNMC4yMzcsLTAuMzgyTDAuNDYxLC0wLjM4MkMwLjQ5NSwtMC4zODIgMC41MTgsLTAuMzgzIDAuNTMyLC0wLjM4NUMwLjU0NSwtMC4zODcgMC41NTgsLTAuMzkxIDAuNTY5LC0wLjM5N0MwLjU5NCwtMC40MSAwLjYwNiwtMC40MzQgMC42MDYsLTAuNDY5QzAuNjA2LC0wLjUwNCAwLjU5NCwtMC41MjggMC41NjksLTAuNTQyQzAuNTU4LC0wLjU0OCAwLjU0NSwtMC41NTIgMC41MzIsLTAuNTU0QzAuNTE5LC0wLjU1NiAwLjQ5NSwtMC41NTcgMC40NjEsLTAuNTU3TDAuMjM3LC0wLjU1N0wwLjIzNywtMC4zODJaIiBzdHlsZT0iZmlsbDp3aGl0ZTtmaWxsLXJ1bGU6bm9uemVybzsiLz4KICAgICAgICAgICAgICAgIDwvZz4KICAgICAgICAgICAgICAgIDxnIHRyYW5zZm9ybT0ibWF0cml4KDk2LDAsMCw5Niw3NzMuNTYxLDE1NS44MDcpIj4KICAgICAgICAgICAgICAgICAgICA8cGF0aCBkPSJNMC4yMzcsLTAuMjkyTDAuMjM3LC0wLjExTDAuNjY2LC0wLjExQzAuNjc1LC0wLjExIDAuNjgzLC0wLjExMiAwLjY4OCwtMC4xMTdDMC42OTMsLTAuMTIxIDAuNjk3LC0wLjEyOSAwLjcwMSwtMC4xNDFMMC43MTYsLTAuMTQxTDAuNzE2LDAuMDMxTDAuNzAxLDAuMDMxQzAuNjk3LDAuMDIgMC42OTMsMC4wMTEgMC42ODgsMC4wMDdDMC42ODMsMC4wMDIgMC42NzUsLTAgMC42NjYsLTBMMC4wNiwtMEwwLjA2LC0wLjAxNkMwLjA3MiwtMC4wMTkgMC4wOCwtMC4wMjMgMC4wODQsLTAuMDI4QzAuMDg5LC0wLjAzNCAwLjA5MSwtMC4wNDEgMC4wOTEsLTAuMDUxTDAuMDkxLC0wLjYxNUMwLjA5MSwtMC42MjUgMC4wODksLTAuNjMzIDAuMDg0LC0wLjYzOEMwLjA4LC0wLjY0MyAwLjA3MiwtMC42NDggMC4wNiwtMC42NTFMMC4wNiwtMC42NjdMMC42NTQsLTAuNjY3QzAuNjY0LC0wLjY2NyAwLjY3MSwtMC42NjkgMC42NzYsLTAuNjczQzAuNjgxLC0wLjY3OCAwLjY4NiwtMC42ODYgMC42ODksLTAuNjk4TDAuNzA1LC0wLjY5OEwwLjcwNSwtMC41MjVMMC42ODksLTAuNTI1QzAuNjg2LC0wLjUzNyAwLjY4MSwtMC41NDUgMC42NzYsLTAuNTVDMC42NzEsLTAuNTU0IDAuNjY0LC0wLjU1NyAwLjY1NCwtMC41NTdMMC4yMzcsLTAuNTU3TDAuMjM3LC0wLjM5OEwwLjUwNywtMC4zOThDMC41MTcsLTAuMzk4IDAuNTI1LC0wLjQwMSAwLjUzLC0wLjQwNUMwLjUzNSwtMC40MSAwLjUzOSwtMC40MTggMC41NDIsLTAuNDNMMC41NTgsLTAuNDNMMC41NTgsLTAuMjYxTDAuNTQyLC0wLjI2MUMwLjUzOSwtMC4yNzMgMC41MzUsLTAuMjgxIDAuNTMsLTAuMjg1QzAuNTI1LC0wLjI5IDAuNTE3LC0wLjI5MiAwLjUwNywtMC4yOTJMMC4yMzcsLTAuMjkyWiIgc3R5bGU9ImZpbGw6d2hpdGU7ZmlsbC1ydWxlOm5vbnplcm87Ii8+CiAgICAgICAgICAgICAgICA8L2c+CiAgICAgICAgICAgICAgICA8ZyB0cmFuc2Zvcm09Im1hdHJpeCg5NiwwLDAsOTYsODcwLjQ5OSwxNTUuODA3KSI+CiAgICAgICAgICAgICAgICAgICAgPHBhdGggZD0iTTAuNTY1LC0wLjQ3TDAuNDEzLC0wLjA4NEMwLjQwNCwtMC4wNjUgMC40LC0wLjA1MyAwLjQsLTAuMDQ3QzAuNCwtMC4wMzIgMC40MTIsLTAuMDIyIDAuNDM2LC0wLjAxNkwwLjQzNiwtMEwwLjIyMSwtMEwwLjIyMSwtMC4wMTZDMC4yNDMsLTAuMDIgMC4yNTQsLTAuMDMxIDAuMjU0LC0wLjA0OUMwLjI1NCwtMC4wNTQgMC4yNSwtMC4wNjggMC4yNCwtMC4wOTJMMC4wNDksLTAuNTg0QzAuMDQsLTAuNjA4IDAuMDMxLC0wLjYyNSAwLjAyNCwtMC42MzRDMC4wMTYsLTAuNjQyIDAuMDA1LC0wLjY0OCAtMC4wMSwtMC42NTFMLTAuMDEsLTAuNjY3TDAuMjIyLC0wLjY2N0wwLjIyMiwtMC42NTFDMC4yMDEsLTAuNjQ2IDAuMTkxLC0wLjYzNyAwLjE5MSwtMC42MjJDMC4xOTEsLTAuNjE1IDAuMTk0LC0wLjYwNCAwLjE5OSwtMC41OUwwLjMzMSwtMC4yMzdMMC40NzQsLTAuNTkyQzAuNDgsLTAuNjA3IDAuNDgzLC0wLjYxNyAwLjQ4MywtMC42MjJDMC40ODMsLTAuNjM3IDAuNDcyLC0wLjY0NyAwLjQ1LC0wLjY1MUwwLjQ1LC0wLjY2N0wwLjY5MiwtMC42NjdMMC42OTIsLTAuNjUxQzAuNjcsLTAuNjQ1IDAuNjU5LC0wLjYzNCAwLjY1OSwtMC42MTlDMC42NTksLTAuNjE0IDAuNjYyLC0wLjYwNCAwLjY2NywtMC41OUwwLjgwMywtMC4yMzdMMC45MzUsLTAuNTg0QzAuOTQyLC0wLjYwMSAwLjk0NSwtMC42MTQgMC45NDUsLTAuNjIzQzAuOTQ1LC0wLjYzNyAwLjkzMywtMC42NDYgMC45MSwtMC42NTFMMC45MSwtMC42NjdMMS4xMzksLTAuNjY3TDEuMTM5LC0wLjY1MUMxLjEyMywtMC42NDYgMS4xMTIsLTAuNjQgMS4xMDUsLTAuNjMzQzEuMDk5LC0wLjYyNiAxLjA5MSwtMC42MSAxLjA4MSwtMC41ODRMMC44OSwtMC4wOTJDMC44ODEsLTAuMDY5IDAuODc2LC0wLjA1MyAwLjg3NiwtMC4wNDVDMC44NzYsLTAuMDI5IDAuODg4LC0wLjAyIDAuOTExLC0wLjAxNkwwLjkxMSwtMEwwLjY5NCwtMEwwLjY5NCwtMC4wMTZDMC43MTYsLTAuMDE5IDAuNzI4LC0wLjAzIDAuNzI4LC0wLjA0OEMwLjcyOCwtMC4wNTUgMC43MjMsLTAuMDY5IDAuNzEzLC0wLjA5MkwwLjU2NSwtMC40N1oiIHN0eWxlPSJmaWxsOndoaXRlO2ZpbGwtcnVsZTpub256ZXJvOyIvPgogICAgICAgICAgICAgICAgPC9nPgogICAgICAgICAgICAgICAgPGcgdHJhbnNmb3JtPSJtYXRyaXgoOTYsMCwwLDk2LDk3OC44MjcsMTU1LjgwNykiPgogICAgICAgICAgICAgICAgICAgIDxwYXRoIGQ9Ik0wLjIzNywtMC4yOTJMMC4yMzcsLTAuMTFMMC42NjYsLTAuMTFDMC42NzUsLTAuMTEgMC42ODMsLTAuMTEyIDAuNjg4LC0wLjExN0MwLjY5MywtMC4xMjEgMC42OTcsLTAuMTI5IDAuNzAxLC0wLjE0MUwwLjcxNiwtMC4xNDFMMC43MTYsMC4wMzFMMC43MDEsMC4wMzFDMC42OTcsMC4wMiAwLjY5MywwLjAxMSAwLjY4OCwwLjAwN0MwLjY4MywwLjAwMiAwLjY3NSwtMCAwLjY2NiwtMEwwLjA2LC0wTDAuMDYsLTAuMDE2QzAuMDcyLC0wLjAxOSAwLjA4LC0wLjAyMyAwLjA4NCwtMC4wMjhDMC4wODksLTAuMDM0IDAuMDkxLC0wLjA0MSAwLjA5MSwtMC4wNTFMMC4wOTEsLTAuNjE1QzAuMDkxLC0wLjYyNSAwLjA4OSwtMC42MzMgMC4wODQsLTAuNjM4QzAuMDgsLTAuNjQzIDAuMDcyLC0wLjY0OCAwLjA2LC0wLjY1MUwwLjA2LC0wLjY2N0wwLjY1NCwtMC42NjdDMC42NjQsLTAuNjY3IDAuNjcxLC0wLjY2OSAwLjY3NiwtMC42NzNDMC42ODEsLTAuNjc4IDAuNjg2LC0wLjY4NiAwLjY4OSwtMC42OThMMC43MDUsLTAuNjk4TDAuNzA1LC0wLjUyNUwwLjY4OSwtMC41MjVDMC42ODYsLTAuNTM3IDAuNjgxLC0wLjU0NSAwLjY3NiwtMC41NUMwLjY3MSwtMC41NTQgMC42NjQsLTAuNTU3IDAuNjU0LC0wLjU1N0wwLjIzNywtMC41NTdMMC4yMzcsLTAuMzk4TDAuNTA3LC0wLjM5OEMwLjUxNywtMC4zOTggMC41MjUsLTAuNDAxIDAuNTMsLTAuNDA1QzAuNTM1LC0wLjQxIDAuNTM5LC0wLjQxOCAwLjU0MiwtMC40M0wwLjU1OCwtMC40M0wwLjU1OCwtMC4yNjFMMC41NDIsLTAuMjYxQzAuNTM5LC0wLjI3MyAwLjUzNSwtMC4yODEgMC41MywtMC4yODVDMC41MjUsLTAuMjkgMC41MTcsLTAuMjkyIDAuNTA3LC0wLjI5MkwwLjIzNywtMC4yOTJaIiBzdHlsZT0iZmlsbDp3aGl0ZTtmaWxsLXJ1bGU6bm9uemVybzsiLz4KICAgICAgICAgICAgICAgIDwvZz4KICAgICAgICAgICAgICAgIDxnIHRyYW5zZm9ybT0ibWF0cml4KDk2LDAsMCw5NiwxMDUxLjc2LDE1NS44MDcpIj4KICAgICAgICAgICAgICAgICAgICA8cGF0aCBkPSJNMC41NDgsLTAuMTdMMC4yNDUsLTAuMTdMMC4yMDEsLTAuMDc2QzAuMTk0LC0wLjA2MyAwLjE5MSwtMC4wNTMgMC4xOTEsLTAuMDQ1QzAuMTkxLC0wLjAzMSAwLjIwMiwtMC4wMjEgMC4yMjQsLTAuMDE2TDAuMjI0LC0wTDAuMDAzLC0wTDAuMDAzLC0wLjAxNkMwLjAxNiwtMC4wMTggMC4wMjYsLTAuMDIzIDAuMDMzLC0wLjAyOUMwLjA0LC0wLjAzNiAwLjA0OCwtMC4wNDkgMC4wNTcsLTAuMDY3TDAuMzAyLC0wLjU4QzAuMzEsLTAuNTk2IDAuMzEzLC0wLjYwOSAwLjMxMywtMC42MTlDMC4zMTMsLTAuNjM0IDAuMzA0LC0wLjY0NCAwLjI4NSwtMC42NTFMMC4yODUsLTAuNjY3TDAuNTIxLC0wLjY2N0wwLjUyMSwtMC42NTFDMC41MDIsLTAuNjQ1IDAuNDkzLC0wLjYzNiAwLjQ5MywtMC42MjJDMC40OTMsLTAuNjEzIDAuNDk2LC0wLjYwMiAwLjUwMiwtMC41OUwwLjc1NiwtMC4wNzZDMC43NjcsLTAuMDU0IDAuNzc2LC0wLjAzOSAwLjc4NCwtMC4wMzFDMC43OTIsLTAuMDI0IDAuODAzLC0wLjAxOCAwLjgxNywtMC4wMTZMMC44MTcsLTBMMC41NzIsLTBMMC41NzIsLTAuMDE2QzAuNTkzLC0wLjAxOSAwLjYwNCwtMC4wMjkgMC42MDQsLTAuMDQ2QzAuNjA0LC0wLjA1MyAwLjYwMSwtMC4wNjMgMC41OTQsLTAuMDc2TDAuNTQ4LC0wLjE3Wk0wLjUwNiwtMC4yNjFMMC4zOTgsLTAuNDk5TDAuMjg5LC0wLjI2MUwwLjUwNiwtMC4yNjFaIiBzdHlsZT0iZmlsbDp3aGl0ZTtmaWxsLXJ1bGU6bm9uemVybzsiLz4KICAgICAgICAgICAgICAgIDwvZz4KICAgICAgICAgICAgICAgIDxnIHRyYW5zZm9ybT0ibWF0cml4KDk2LDAsMCw5NiwxMTI5Ljg2LDE1NS44MDcpIj4KICAgICAgICAgICAgICAgICAgICA8cGF0aCBkPSJNMC4yMzcsLTAuMjcyTDAuMjM3LC0wLjA1MUMwLjIzNywtMC4wNDEgMC4yMzksLTAuMDMzIDAuMjQ0LC0wLjAyOEMwLjI0OSwtMC4wMjMgMC4yNTcsLTAuMDE5IDAuMjY4LC0wLjAxNkwwLjI2OCwtMEwwLjA2LC0wTDAuMDYsLTAuMDE2QzAuMDcyLC0wLjAxOSAwLjA4LC0wLjAyMyAwLjA4NCwtMC4wMjhDMC4wODksLTAuMDM0IDAuMDkxLC0wLjA0MSAwLjA5MSwtMC4wNTFMMC4wOTEsLTAuNjE1QzAuMDkxLC0wLjYyNSAwLjA4OSwtMC42MzMgMC4wODQsLTAuNjM4QzAuMDgsLTAuNjQzIDAuMDcyLC0wLjY0OCAwLjA2LC0wLjY1MUwwLjA2LC0wLjY2N0wwLjQ3NywtMC42NjdDMC41MjgsLTAuNjY3IDAuNTY4LC0wLjY2NCAwLjU5OCwtMC42NThDMC42MjgsLTAuNjUyIDAuNjU0LC0wLjY0MiAwLjY3NiwtMC42MjdDMC43MDEsLTAuNjEgMC43MjEsLTAuNTg3IDAuNzMzLC0wLjU1OEMwLjc0NSwtMC41MyAwLjc1MSwtMC41IDAuNzUxLC0wLjQ2OEMwLjc1MSwtMC4zNiAwLjY5MSwtMC4yOTYgMC41NywtMC4yNzdMMC43LC0wLjA5M0MwLjcyMiwtMC4wNjEgMC43MzksLTAuMDQgMC43NSwtMC4wMzFDMC43NiwtMC4wMjIgMC43NzMsLTAuMDE3IDAuNzg5LC0wLjAxNkwwLjc4OSwtMEwwLjU0OCwtMEwwLjU0OCwtMC4wMTZDMC41NjQsLTAuMDE4IDAuNTczLC0wLjAyNCAwLjU3MywtMC4wMzNDMC41NzMsLTAuMDM5IDAuNTY1LC0wLjA1MyAwLjU1LC0wLjA3NkwwLjQxNSwtMC4yNzJMMC4yMzcsLTAuMjcyWk0wLjIzNywtMC4zODJMMC40NjEsLTAuMzgyQzAuNDk1LC0wLjM4MiAwLjUxOCwtMC4zODMgMC41MzIsLTAuMzg1QzAuNTQ1LC0wLjM4NyAwLjU1OCwtMC4zOTEgMC41NjksLTAuMzk3QzAuNTk0LC0wLjQxIDAuNjA2LC0wLjQzNCAwLjYwNiwtMC40NjlDMC42MDYsLTAuNTA0IDAuNTk0LC0wLjUyOCAwLjU2OSwtMC41NDJDMC41NTgsLTAuNTQ4IDAuNTQ1LC0wLjU1MiAwLjUzMiwtMC41NTRDMC41MTksLTAuNTU2IDAuNDk1LC0wLjU1NyAwLjQ2MSwtMC41NTdMMC4yMzcsLTAuNTU3TDAuMjM3LC0wLjM4MloiIHN0eWxlPSJmaWxsOndoaXRlO2ZpbGwtcnVsZTpub256ZXJvOyIvPgogICAgICAgICAgICAgICAgPC9nPgogICAgICAgICAgICA8L2c+CiAgICAgICAgICAgIDxnIHRyYW5zZm9ybT0ibWF0cml4KDAuOTQ4MjY0LDAsMCwwLjk0NjIzNywyMjcuODgxLDE3LjgyOTIpIj4KICAgICAgICAgICAgICAgIDxwYXRoIGQ9Ik0zMDkuNTg3LDc4NS4zMzdMMzM2LjYwNSw4MjcuMjE1TDMwOS41ODcsODY5LjA5NEwyODIuNTY4LDgyNy4yMTVMMzA5LjU4Nyw3ODUuMzM3WiIgc3R5bGU9ImZpbGw6d2hpdGU7Ii8+CiAgICAgICAgICAgIDwvZz4KICAgICAgICAgICAgPGcgdHJhbnNmb3JtPSJtYXRyaXgoMS4wMzQ0NywwLDAsMSw0NC40MTQyLC0yNC4xOTQ1KSI+CiAgICAgICAgICAgICAgICA8Y2lyY2xlIGN4PSI0MDAuMDk5IiBjeT0iODIzLjYxMyIgcj0iMTEuMjU4IiBzdHlsZT0iZmlsbDp3aGl0ZTsiLz4KICAgICAgICAgICAgPC9nPgogICAgICAgICAgICA8ZyB0cmFuc2Zvcm09Im1hdHJpeCgxLjAzNDQ3LDAsMCwxLDE3MS41MzEsLTI0LjE5NDUpIj4KICAgICAgICAgICAgICAgIDxjaXJjbGUgY3g9IjQwMC4wOTkiIGN5PSI4MjMuNjEzIiByPSIxMS4yNTgiIHN0eWxlPSJmaWxsOndoaXRlOyIvPgogICAgICAgICAgICA8L2c+CiAgICAgICAgPC9nPgogICAgPC9nPgo8L3N2Zz4K" ``` --- --- url: /resources/registry/reference/branding_name.md description: The display name of the Hantera instance --- # branding/name The display name of the Hantera instance. Used in the Hantera Portal to identify accounts. ## Example Value ``` "Alpine Adventure Wear" ``` --- --- url: /resources/components/runtimes/keywords/calculateAvailableStock.md --- # calculateAvailableStock ``` calculateAvailableStock( skuNumber: text asOf: instant inventoryKeys: [text] | nothing allocationKeys: [text] | nothing ): { text -> number } ``` Queries available stock for a SKU across inventories. The available quantity per inventory key is computed as: unallocated physical stock + incoming stock expected before `asOf` + any stock allocated to the specified `allocationKeys`. ## Parameters | Parameter | Type | Required | Description | |---|---|---|---| | `skuNumber` | `text` | Yes | The SKU number to look up. | | `asOf` | `instant` | Yes | Only include incoming stock expected before this date. | | `inventoryKeys` | `[text]` | No | Inventory keys to include. If omitted, all inventories are returned. | | `allocationKeys` | `[text]` | No | Include stock allocated to these keys in the available total. | ## Return Value ``` { text -> number } ``` A map where each key is an inventory key and each value is the total available quantity for that inventory. ## Availability ## Examples #### Basic usage ```filtrera let stock = calculateAvailableStock (orderLine.skuNumber, now) from stock->'warehouse-stockholm' ``` #### Specific inventories ```filtrera let stock = calculateAvailableStock ( orderLine.skuNumber, now, ['warehouse-stockholm', 'warehouse-gothenburg'] ) ``` #### With allocation keys ```filtrera let stock = calculateAvailableStock ( orderLine.skuNumber, now, ['warehouse-stockholm'], ['vip-allocation'] ) ``` --- --- url: /resources/graph/nodes/calculated-discount.md description: '' --- # calculatedDiscount Graph Node Root Set Name: `calculatedDiscounts` --- --- url: /resources/registry/reference/channels.md description: Contains channels --- # channels Channels are defined in the registry and used throughout the system. Refer to [Dimensions](/learn/dimensions) for more info. Each channel must have a unique key that can only contain a-z 0-9 and \_. And the first character has to be non-numeric. ## Properties | Property | Type | Description | |----------|------|-------------| | `label` | `string` or `object` | Display label for the channel. Can be a simple string or a localized object with locale keys (e.g., `default`, `sv`). | | `inventories` | `string[]` | Inventory keys available for this channel. When set, only these inventories can be selected on deliveries for orders on this channel. If omitted or empty, all inventories are available. | | `countries` | `object` | Map of ISO 3166-1 alpha-2 country codes (uppercase) to per-country data objects. When set, only these countries are shown in address forms for this channel. If omitted or empty, all countries are available. Each country value is an object for app-provided fields; use `{}` for a country with no custom fields. | Additional properties may be added by installed apps. Inside `countries`, all fields on the per-country object are app-provided. Channels automatically provide enum values for `channelKey` fields on orders and tickets. The `label` is used as the dropdown display text. Custom graph fields may use channels too by specifying `values: "channels"`. ## Example ```yaml uri: /registry/channels/b2c spec: value: label: B2C Webshop inventories: - warehouse-stockholm - warehouse-gothenburg countries: SE: {} NO: {} DK: {} ``` With localization: ```yaml uri: /registry/channels/b2b spec: value: label: default: B2B Portal sv: B2B-portalen ``` --- --- url: /resources/actors/custom/asset/commands/add-activity-log.md description: | Adds an activity log to an Asset --- # Command: asset.addActivityLog Adds an activity log to an Asset --- --- url: /resources/actors/custom/asset/commands/add-tag.md description: | Adds a tag to an Asset --- # Command: asset.addTag Adds a tag to an Asset --- --- url: /resources/actors/custom/asset/commands/create-item.md description: | Adds a new Asset Item to a Asset --- # Command: asset.createItem Adds a new Asset Item to a Asset --- --- url: /resources/actors/custom/asset/commands/create-item-relation.md description: | Adds a new graph node relation to a Asset Item --- # Command: asset.createItemRelation Adds a new graph node relation to a Asset Item --- --- url: /resources/actors/custom/asset/commands/create-relation.md description: | Adds a new graph node relation to a Asset --- # Command: asset.createRelation Adds a new graph node relation to a Asset --- --- url: /resources/actors/custom/asset/commands/delete-item.md description: | Removes an Asset Item from an Asset --- # Command: asset.deleteItem Removes an Asset Item from an Asset --- --- url: /resources/actors/custom/asset/commands/delete-item-relation.md description: | Removes a graph node relation from a Asset Item --- # Command: asset.deleteItemRelation Removes a graph node relation from a Asset Item --- --- url: /resources/actors/custom/asset/commands/delete-relation.md description: | Removes a graph node relation from a Asset --- # Command: asset.deleteRelation Removes a graph node relation from a Asset --- --- url: /resources/actors/custom/asset/commands/generate-asset-number-by-prefix.md description: | Generates an asset number for an Asset based on the given prefix --- # Command: asset.generateAssetNumberByPrefix Generates an asset number for an Asset based on the given prefix --- --- url: /resources/actors/custom/asset/commands/remove-tag.md description: | Removes a tag from an Asset --- # Command: asset.removeTag Removes a tag from an Asset --- --- url: /resources/actors/custom/asset/commands/set-dynamic-fields.md description: > Sets dynamic fields of an asset. Non specified fields will be left unchanged. Set value to null to remove field. --- # Command: asset.setDynamicFields Sets dynamic fields of an asset. Non specified fields will be left unchanged. Set value to null to remove field. --- --- url: /resources/actors/custom/asset/commands/set-item-dynamic-fields.md description: > Sets dynamic fields of an asset item. Non specified fields will be left unchanged. Set value to null to remove field. --- # Command: asset.setItemDynamicFields Sets dynamic fields of an asset item. Non specified fields will be left unchanged. Set value to null to remove field. --- --- url: /resources/actors/custom/asset/commands/set-tags.md description: | Replaces all tags on an Asset --- # Command: asset.setTags Replaces all tags on an Asset --- --- url: /resources/actors/order/commands/add-activity-log.md description: | Adds an activity log to an Order --- # Command: order.addActivityLog Adds an activity log to an Order ## Examples --- --- url: /resources/actors/order/commands/add-delivery-tag.md description: | Adds a tag to a Delivery --- # Command: order.addDeliveryTag Adds a tag to a Delivery --- --- url: /resources/actors/order/commands/add-tag.md description: | Adds a tag to an Order --- # Command: order.addTag Adds a tag to an Order ## Examples --- --- url: /resources/actors/order/commands/cancel-delivery.md description: | Cancels a delivery. --- # Command: order.cancelDelivery Cancels a delivery. --- --- url: /resources/actors/order/commands/cancel-invoice.md description: | Cancels an Invoice --- # Command: order.cancelInvoice Cancels an Invoice --- --- url: /resources/actors/order/commands/complete-delivery.md description: | Marks a delivery as completed. --- # Command: order.completeDelivery Marks a delivery as completed. --- --- url: /resources/actors/order/commands/create-delivery.md description: | Adds a new Delivery to an Order --- # Command: order.createDelivery Adds a new Delivery to an Order --- --- url: /resources/actors/order/commands/create-order-line.md description: | Creates a new Order Row --- # Command: order.createOrderLine Creates a new Order Row --- --- url: /resources/actors/order/commands/create-promotion.md description: | Creates a promotion on an Order based on a component --- # Command: order.createPromotion Creates a promotion on an Order based on a component --- --- url: /resources/actors/order/commands/create-promotion-by-source.md description: | Creates a promotion on an Order with inline Filtrera source --- # Command: order.createPromotionBySource Creates a promotion on an Order with inline Filtrera source --- --- url: /resources/actors/order/commands/create-return.md description: | Creates a Return for a specific Order Row --- # Command: order.createReturn Creates a Return for a specific Order Row --- --- url: /resources/actors/order/commands/create-static-order-discount.md description: > Creates a static discount on an Order. An order discount value is distributed across all order lines and shipping fees proportionally to their value. --- # Command: order.createStaticOrderDiscount Creates a static discount on an Order. An order discount value is distributed across all order lines and shipping fees proportionally to their value. --- --- url: /resources/actors/order/commands/create-static-order-line-discount.md description: | Creates a static discount on an order line --- # Command: order.createStaticOrderLineDiscount Creates a static discount on an order line --- --- url: /resources/actors/order/commands/create-static-shipping-discount.md description: | Creates a static discount on a shipping fee --- # Command: order.createStaticShippingDiscount Creates a static discount on a shipping fee --- --- url: /resources/actors/order/commands/delete-discount.md description: | Deletes an existing Discount from an Order --- # Command: order.deleteDiscount Deletes an existing Discount from an Order --- --- url: /resources/actors/order/commands/delete-order-line.md description: | Deletes an existing Order Row --- # Command: order.deleteOrderLine Deletes an existing Order Row --- --- url: /resources/actors/order/commands/delete-promotion.md description: | Deletes a promotion from an Order --- # Command: order.deletePromotion Deletes a promotion from an Order --- --- url: /resources/actors/order/commands/delete-return.md description: | Deletes and existing Return --- # Command: order.deleteReturn Deletes and existing Return --- --- url: /resources/actors/order/commands/generate-order-number-by-prefix.md description: | Generates an order number for an order based on the given prefix --- # Command: order.generateOrderNumberByPrefix Generates an order number for an order based on the given prefix ## Examples --- --- url: /resources/actors/order/commands/invoice.md description: | Invoices all entities released for invoicing on the Order. --- # Command: order.invoice Invoices all entities released for invoicing on the Order. --- --- url: /resources/actors/order/commands/link-payment.md description: | Associates a Payment with an Order --- # Command: order.linkPayment Associates a Payment with an Order --- --- url: /resources/actors/order/commands/move-order-line-to-delivery.md description: | Moves an existing order line to a different delivery. --- # Command: order.moveOrderLineToDelivery Moves an existing order line to a different delivery. --- --- url: /resources/actors/order/commands/open-delivery.md description: > Opens a delivery that is currently in processing. Ensure there are no external dependencies such as warehouses actively working on the delivery. --- # Command: order.openDelivery Opens a delivery that is currently in processing. Ensure there are no external dependencies such as warehouses actively working on the delivery. --- --- url: /resources/actors/order/commands/release-delivery.md description: | Releases a delivery for fulfillment processing. --- # Command: order.releaseDelivery Releases a delivery for fulfillment processing. --- --- url: /resources/actors/order/commands/release-order-line-to-invoicing.md description: | Releases an order line to be invoiced. --- # Command: order.releaseOrderLineToInvoicing Releases an order line to be invoiced. --- --- url: /resources/actors/order/commands/release-shipping-to-invoicing.md description: | Releases a delivery's shipping fee to be invoiced. --- # Command: order.releaseShippingToInvoicing Releases a delivery's shipping fee to be invoiced. --- --- url: /resources/actors/order/commands/remove-delivery-tag.md description: | Removes a tag from a Delivery --- # Command: order.removeDeliveryTag Removes a tag from a Delivery --- --- url: /resources/actors/order/commands/remove-tag.md description: | Removes a tag from an Order --- # Command: order.removeTag Removes a tag from an Order ## Examples --- --- url: /resources/actors/order/commands/retract-order-line-from-invoicing.md description: | Unreleases an order line from invoicing. --- # Command: order.retractOrderLineFromInvoicing Unreleases an order line from invoicing. --- --- url: /resources/actors/order/commands/retract-shipping-from-invoicing.md description: | Unreleases a delivery's shipping fee from invoicing. --- # Command: order.retractShippingFromInvoicing Unreleases a delivery's shipping fee from invoicing. --- --- url: /resources/actors/order/commands/set-channel-key.md description: | Sets the channel key of an order. --- # Command: order.setChannelKey Sets the channel key of an order. --- --- url: /resources/actors/order/commands/set-customer-number.md description: | Sets the customer number of an order. --- # Command: order.setCustomerNumber Sets the customer number of an order. --- --- url: /resources/actors/order/commands/set-delivery-address.md description: | Sets the Address of a Delivery --- # Command: order.setDeliveryAddress Sets the Address of a Delivery --- --- url: /resources/actors/order/commands/set-delivery-dynamic-fields.md description: > Sets dynamic fields of a delivery. Non specified fields will be left unchanged. Set value to null to remove field. --- # Command: order.setDeliveryDynamicFields Sets dynamic fields of a delivery. Non specified fields will be left unchanged. Set value to null to remove field. --- --- url: /resources/actors/order/commands/set-delivery-tags.md description: | Replaces all tags on a Delivery --- # Command: order.setDeliveryTags Replaces all tags on a Delivery --- --- url: /resources/actors/order/commands/set-discount-description.md description: > Sets description of discount. Should be meaningful to the customer as it's used on invoices and customer communication. --- # Command: order.setDiscountDescription Sets description of discount. Should be meaningful to the customer as it's used on invoices and customer communication. --- --- url: /resources/actors/order/commands/set-discount-dynamic-fields.md description: > Sets dynamic fields of a discount. Non specified fields will be left unchanged. Set value to null to remove field. --- # Command: order.setDiscountDynamicFields Sets dynamic fields of a discount. Non specified fields will be left unchanged. Set value to null to remove field. --- --- url: /resources/actors/order/commands/set-invoice-address.md description: | Sets the invoice address of an Order which will be used for new invoices --- # Command: order.setInvoiceAddress Sets the invoice address of an Order which will be used for new invoices --- --- url: /resources/actors/order/commands/set-invoice-recipient.md description: | Sets the invoice recipient of an Order which will be used for new invoices --- # Command: order.setInvoiceRecipient Sets the invoice recipient of an Order which will be used for new invoices --- --- url: /resources/actors/order/commands/set-locale.md description: | Set the locale of the Order. Nothing else will be affected. --- # Command: order.setLocale Set the locale of the Order. Nothing else will be affected. --- --- url: /resources/actors/order/commands/set-notes.md description: | Set the notes of the Order. --- # Command: order.setNotes Set the notes of the Order. --- --- url: /resources/actors/order/commands/set-order-dynamic-fields.md description: > Sets dynamic fields of an order. Non specified fields will be left unchanged. Set value to null to remove field. --- # Command: order.setOrderDynamicFields Sets dynamic fields of an order. Non specified fields will be left unchanged. Set value to null to remove field. --- --- url: /resources/actors/order/commands/set-order-line-dynamic-fields.md description: > Sets dynamic fields of an order line. Non specified fields will be left unchanged. Set value to null to remove field. --- # Command: order.setOrderLineDynamicFields Sets dynamic fields of an order line. Non specified fields will be left unchanged. Set value to null to remove field. --- --- url: /resources/actors/order/commands/set-order-line-product.md description: | Sets the product of an Order Line. --- # Command: order.setOrderLineProduct Sets the product of an Order Line. --- --- url: /resources/actors/order/commands/set-order-line-quantity.md description: | Sets the quantity of an Order Line --- # Command: order.setOrderLineQuantity Sets the quantity of an Order Line --- --- url: /resources/actors/order/commands/set-order-line-skus.md description: | Sets the required quantities of SKUs on an Order Line --- # Command: order.setOrderLineSkus Sets the required quantities of SKUs on an Order Line --- --- url: /resources/actors/order/commands/set-order-line-tax.md description: > Sets the tax of an Order Line, either by a fixed total (taxTotal) or a percentage (taxFactor). --- # Command: order.setOrderLineTax Sets the tax of an Order Line, either by a fixed total (taxTotal) or a percentage (taxFactor). --- --- url: /resources/actors/order/commands/set-order-line-unit-price.md description: | Sets the unit price of an Order Line. --- # Command: order.setOrderLineUnitPrice Sets the unit price of an Order Line. --- --- url: /resources/actors/order/commands/set-order-state.md description: | Sets the state of an Order --- # Command: order.setOrderState Sets the state of an Order --- --- url: /resources/actors/order/commands/set-promotion-description.md description: | Sets the description on a promotion --- # Command: order.setPromotionDescription Sets the description on a promotion --- --- url: /resources/actors/order/commands/set-promotion-dynamic-fields.md description: | Sets dynamic fields on a promotion --- # Command: order.setPromotionDynamicFields Sets dynamic fields on a promotion --- --- url: /resources/actors/order/commands/set-promotion-parameters.md description: | Sets parameters on a promotion --- # Command: order.setPromotionParameters Sets parameters on a promotion --- --- url: /resources/actors/order/commands/set-return-dynamic-fields.md description: > Sets dynamic fields of a return. Non specified fields will be left unchanged. Set value to null to remove field. --- # Command: order.setReturnDynamicFields Sets dynamic fields of a return. Non specified fields will be left unchanged. Set value to null to remove field. --- --- url: /resources/actors/order/commands/set-shipping-price.md description: | Sets the shipping price of a Delivery. --- # Command: order.setShippingPrice Sets the shipping price of a Delivery. --- --- url: /resources/actors/order/commands/set-shipping-product.md description: | Sets the shipping product of a Delivery. --- # Command: order.setShippingProduct Sets the shipping product of a Delivery. --- --- url: /resources/actors/order/commands/set-shipping-tax.md description: | Sets the tax percentage factor of a a Delivery's shipping cost. --- # Command: order.setShippingTax Sets the tax percentage factor of a a Delivery's shipping cost. --- --- url: /resources/actors/order/commands/set-static-discount-value.md description: | Updates the value of an existing static discount --- # Command: order.setStaticDiscountValue Updates the value of an existing static discount --- --- url: /resources/actors/order/commands/set-tags.md description: | Replaces all tags on an Order --- # Command: order.setTags Replaces all tags on an Order --- --- url: /resources/actors/order/commands/split-order-line.md description: > Splits an existing order line into two by moving a portion of its quantity onto a new order line. The new line inherits product, price, dynamic fields and per-unit SKU composition from the original. Discounts and totals are left to be recomputed by the order pipeline. --- # Command: order.splitOrderLine Splits an existing order line into two by moving a portion of its quantity onto a new order line. The new line inherits product, price, dynamic fields and per-unit SKU composition from the original. Discounts and totals are left to be recomputed by the order pipeline. --- --- url: /resources/actors/payment/commands/add-activity-log.md description: | Adds an activity log to a Payment --- # Command: payment.addActivityLog Adds an activity log to a Payment ## Examples --- --- url: /resources/actors/payment/commands/add-tag.md description: | Adds a tag to a Payment --- # Command: payment.addTag Adds a tag to a Payment ## Examples --- --- url: /resources/actors/payment/commands/create-authorization.md description: | Creates a new Authorization. --- # Command: payment.createAuthorization Creates a new Authorization. --- --- url: /resources/actors/payment/commands/generate-payment-number-by-prefix.md description: | Generates a payment number for a payment based on the given prefix --- # Command: payment.generatePaymentNumberByPrefix Generates a payment number for a payment based on the given prefix ## Examples --- --- url: /resources/actors/payment/commands/remove-tag.md description: | Removes a tag from a Payment --- # Command: payment.removeTag Removes a tag from a Payment ## Examples --- --- url: /resources/actors/payment/commands/set-authorization-amount.md description: | Sets the amount of an Authorization. --- # Command: payment.setAuthorizationAmount Sets the amount of an Authorization. --- --- url: /resources/actors/payment/commands/set-authorization-state.md description: | Sets the state of an Authorization. --- # Command: payment.setAuthorizationState Sets the state of an Authorization. --- --- url: /resources/actors/payment/commands/set-dynamic-fields.md description: > Sets dynamic fields of a payment. Non specified fields will be left unchanged. Set value to null to remove field. --- # Command: payment.setDynamicFields Sets dynamic fields of a payment. Non specified fields will be left unchanged. Set value to null to remove field. ## Examples --- --- url: /resources/actors/payment/commands/set-tags.md description: | Replaces all tags on a Payment --- # Command: payment.setTags Replaces all tags on a Payment --- --- url: /resources/actors/sku/commands/reserve.md description: > Reserve stock. If not enough stock is available, a back order will automatically be created and it will be fulfilled once enough stock is available. --- # Command: sku.reserve Reserve stock. If not enough stock is available, a back order will automatically be created and it will be fulfilled once enough stock is available. --- --- url: /resources/actors/sku/commands/set-allocation.md description: | Creates or updates a stock allocation of SKU for a given inventory. --- # Command: sku.setAllocation Creates or updates a stock allocation of SKU for a given inventory. --- --- url: /resources/actors/sku/commands/set-dynamic-fields.md description: > Sets dynamic fields of a sku. Non specified fields will be left unchanged. Set value to null to remove field. --- # Command: sku.setDynamicFields Sets dynamic fields of a sku. Non specified fields will be left unchanged. Set value to null to remove field. --- --- url: /resources/actors/sku/commands/set-incoming-stock.md description: > Creates or updates incoming stock of SKU for a given inventory. Reference can be used to map incoming stock to an external entity. --- # Command: sku.setIncomingStock Creates or updates incoming stock of SKU for a given inventory. Reference can be used to map incoming stock to an external entity. --- --- url: /resources/actors/sku/commands/set-physical-stock.md description: > Sets the physical stock quantity of SKU for a given inventory. If there is no existing StockPosition for the SKU/inventory it will be created. --- # Command: sku.setPhysicalStock Sets the physical stock quantity of SKU for a given inventory. If there is no existing StockPosition for the SKU/inventory it will be created. --- --- url: /resources/actors/sku/commands/unreserve.md description: | Removes any reservation or back order of the specified order line --- # Command: sku.unreserve Removes any reservation or back order of the specified order line --- --- url: /resources/actors/custom/ticket/commands/add-activity-log.md description: | Adds an activity log to a Ticket --- # Command: ticket.addActivityLog Adds an activity log to a Ticket --- --- url: /resources/actors/custom/ticket/commands/add-tag.md description: | Adds a tag to a Ticket --- # Command: ticket.addTag Adds a tag to a Ticket --- --- url: /resources/actors/custom/ticket/commands/create-item.md description: | Adds a new Ticket Item to a Ticket --- # Command: ticket.createItem Adds a new Ticket Item to a Ticket --- --- url: /resources/actors/custom/ticket/commands/create-item-relation.md description: | Adds a new graph node relation to a Ticket Item --- # Command: ticket.createItemRelation Adds a new graph node relation to a Ticket Item --- --- url: /resources/actors/custom/ticket/commands/create-relation.md description: | Adds a new graph node relation to a Ticket --- # Command: ticket.createRelation Adds a new graph node relation to a Ticket --- --- url: /resources/actors/custom/ticket/commands/delete-item.md description: | Removes a Ticket Item from a Ticket --- # Command: ticket.deleteItem Removes a Ticket Item from a Ticket --- --- url: /resources/actors/custom/ticket/commands/delete-item-relation.md description: | Removes a graph node relation from a Ticket Item --- # Command: ticket.deleteItemRelation Removes a graph node relation from a Ticket Item --- --- url: /resources/actors/custom/ticket/commands/delete-relation.md description: | Removes a graph node relation from a Ticket --- # Command: ticket.deleteRelation Removes a graph node relation from a Ticket --- --- url: /resources/actors/custom/ticket/commands/generate-ticket-number-by-prefix.md description: | Generates a ticket number for a Ticket based on the given prefix --- # Command: ticket.generateTicketNumberByPrefix Generates a ticket number for a Ticket based on the given prefix --- --- url: /resources/actors/custom/ticket/commands/remove-tag.md description: | Removes a tag from a Ticket --- # Command: ticket.removeTag Removes a tag from a Ticket --- --- url: /resources/actors/custom/ticket/commands/set-channel-key.md description: | Sets the channel key of a ticket. --- # Command: ticket.setChannelKey Sets the channel key of a ticket. --- --- url: /resources/actors/custom/ticket/commands/set-dynamic-fields.md description: > Sets dynamic fields of a ticket. Non specified fields will be left unchanged. Set value to null to remove field. --- # Command: ticket.setDynamicFields Sets dynamic fields of a ticket. Non specified fields will be left unchanged. Set value to null to remove field. --- --- url: /resources/actors/custom/ticket/commands/set-item-dynamic-fields.md description: > Sets dynamic fields of a ticket item. Non specified fields will be left unchanged. Set value to null to remove field. --- # Command: ticket.setItemDynamicFields Sets dynamic fields of a ticket item. Non specified fields will be left unchanged. Set value to null to remove field. --- --- url: /resources/actors/custom/ticket/commands/set-tags.md description: | Replaces all tags on a Ticket --- # Command: ticket.setTags Replaces all tags on a Ticket --- --- url: /resources/components/runtimes/modules/crypt.md --- # crypt See official [Filtrera Documentation](https://www.filtrera.io/modules/crypt/). ## Availability --- --- url: /resources/registry/reference/currencies.md description: Contains currencies --- # currencies Currencies are defined in the registry and used throughout the system for filtering and display purposes. Each currency must have a unique key (typically the ISO 4217 currency code) at the path `/currencies/`. ## Properties | Property | Type | Description | |----------|------|-------------| | `label` | `string` or `object` | Display label for the currency. Can be a simple string or a localized object with locale keys (e.g., `default`, `sv`). | Currencies automatically provide enum values for `currencyCode` fields on orders. The `label` is used as the dropdown display text. Custom graph fields may use currencies too by specifying `values: "currencies"`. ## Example ```yaml uri: /registry/currencies/SEK spec: value: label: Swedish Krona ``` With localization: ```yaml uri: /registry/currencies/EUR spec: value: label: default: Euro sv: Euro ``` --- --- url: /resources/components/runtimes/rule-effects/custom.md --- # custom A pass-through effect that is **not processed by the platform**. Custom effects are designed for inter-rule communication via `triggerHook` — hook listeners emit custom effects, and the calling rule collects them to decide what action to take. ## Type ```filtrera { effect: 'custom' type: text } ``` The record is open — additional fields beyond `effect` and `type` are allowed. ::: tip Custom effects are silently ignored by the platform's effect processor. They only have meaning when collected by a calling rule via `triggerHook`. ::: ## Usage A hook listener emits a custom effect: ```filtrera param input: { hook: 'OnClaimResolve' orderId: uuid lines: [{ resolution: text, productNumber: text, quantity: number }] } import 'iterators' from input.lines where l => l.resolution == 'replace' select l => { effect = 'custom' type = 'createReplacementOrder' productNumber = l.productNumber quantity = l.quantity } ``` The parent rule collects the custom effects via `triggerHook`: ```filtrera let hookEffects = { orderId = orderId, lines = resolvedLines } triggerHook 'OnClaimResolve' let replacements = hookEffects where e is { effect: 'custom', type: 'createReplacementOrder' } ``` --- --- url: /learn/guides/customer-registration.md --- # Customer Registration for E-Commerce Learn how to build a customer registration system for your e-commerce website using Hantera's IAM APIs. This guide covers creating customer identities, email verification, and querying customer-scoped data. ## Architecture Overview **Key Pattern:** * Your **e-commerce website** is an OAuth client with full Hantera permissions * **Customer login accounts** are IAM principals (for authentication) * **Customer business entities** are Asset actors (for orders, data) * Principal links to Customer asset via `actorId` property * Website uses **its own** credentials to query customer's asset and orders ::: tip **Two Separate Entities:** * **Principal** = Login account (email, password, authentication) * **Customer Asset** = Business entity (orders, customer number, relations) Customers have NO direct Hantera permissions. The website acts as a proxy, querying the customer's linked asset. ::: ### Why Separate Principal from Customer Asset? **Security & Flexibility:** * Email verification required before asset access (`activated: true`) * Prevents querying orders by guessing email addresses * Principal can exist before Customer asset (registration → verification → asset creation) * Customer asset can exist before Principal (import existing customers, then allow registration) * One Customer asset can have multiple Principals (family accounts, B2B users) **Data Architecture:** * Customer assets have auto-generated customer numbers (e.g., CUST100001) * Customer assets have relations to orders, payments, etc. * Principals store minimal data (name, email, phone, actorId link) ## Prerequisites * E-commerce website/app with backend API * Hantera access token with IAM permissions * Understanding of OAuth 2.0 flows * [Sendings API](/resources/sendings) for email verification * [Asset Actors](/resources/actors/custom/asset/) - Understanding custom asset types ## Step 1: Define Customer Asset Type Before you can create Customer assets, you must define the `customer` type in the Registry. **Create Registry manifest** at `/registry/actors/custom/asset/types/customer`: ```yaml uri: /registry/actors/custom/asset/types/customer spec: value: graphSetName: customer defaultNumberPrefix: "CUST" relations: orders: node: order cardinality: many ``` **Apply the manifest:** ```bash h_ apply customer-type.yaml ``` This defines: * **graphSetName**: `customer` - Creates `asset.customer` graph node * **defaultNumberPrefix**: `CUST` - Auto-generates customer numbers like CUST100001 * **Relations**: Links to orders for querying customer's order history ::: warning Run `h_ manage signals` after applying to check for errors in your type definition. ::: ## Step 2: Create OAuth Client for Website First, register your e-commerce website as an OAuth client with full order access. ```http PUT /resources/iam/clients/018c5f3a-1234-5678-9abc-def012345678 Content-Type: application/json Authorization: Bearer {adminToken} { "properties": { "name": "E-Commerce Website", "redirectUris": [ "https://mystore.com/auth/callback" ], "grantTypes": [ "authorization_code", "refresh_token", "client_credentials" ] } } ``` **Grant the client permissions:** ```http PUT /resources/iam/clients/018c5f3a-1234-5678-9abc-def012345678 Content-Type: application/json Authorization: Bearer {adminToken} { "properties": { ... }, "acl": { "entries": [ { "resource": "orders:*", "permission": "read" }, { "resource": "graph:*", "permission": "query" }, { "resource": "iam:principals:*", "permission": "read" } ] } } ``` **Generate client secret:** ```http POST /resources/iam/clients/018c5f3a-1234-5678-9abc-def012345678/secrets Content-Type: application/json Authorization: Bearer {adminToken} { "expiresAt": "2026-12-31T23:59:59Z" } ``` **Response:** ```json { "secretId": "secret-uuid", "secret": "client_secret_abc123xyz", "expiresAt": "2026-12-31T23:59:59Z" } ``` ::: warning Store the client secret securely. It cannot be retrieved again. ::: ## Step 3: Create Customer Role Create a `customer` role as a marker (no permissions needed). ```http PUT /resources/iam/roles/customer Content-Type: application/json Authorization: Bearer {adminToken} { "description": "Customer role marker for e-commerce users", "acl": { "entries": [] } } ``` The role has no permissions because customers don't directly access Hantera - your website does. ## Step 4: Build Registration Endpoint Create an API endpoint on your website to handle customer registration. ```javascript // POST /api/register app.post('/api/register', async (req, res) => { const { email, name, password } = req.body; // Validate input if (!email || !name || !password) { return res.status(400).json({ error: 'Missing required fields' }); } try { // Generate unique IDs const customerId = crypto.randomUUID(); const customerNumber = `CUST-${Date.now()}`; const activationCode = crypto.randomBytes(32).toString('hex'); // Create principal in Hantera const principal = await fetch(`https://``/resources/iam/principals/${customerId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${adminToken}` }, body: JSON.stringify({ properties: { name, email, customerNumber, activationCode, activated: false }, roles: ['customer'] }) }); if (!principal.ok) { const error = await principal.json(); return res.status(principal.status).json(error); } // Set password await fetch(`https://``/resources/iam/principals/${customerId}/password/reset`, { method: 'POST', headers: { 'Authorization': `Bearer ${adminToken}` } }); const passwordResponse = await passwordReset.json(); // Update with actual password (not temp) await fetch(`https://``/resources/me/password`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${userToken}` // Get token for user }, body: JSON.stringify({ currentPassword: passwordResponse.temporaryPassword, newPassword: password }) }); // Send activation email await fetch('https://``/resources/sendings/email', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${adminToken}` }, body: JSON.stringify({ to: email, subject: 'Activate Your Account', body: { html: `

Welcome ${name}!

Click the link below to activate your account:

Activate Account

`, plainText: `Welcome ${name}!\n\nClick the link below to activate your account:\n\nhttps://mystore.com/activate?id=${customerId}&code=${activationCode}` }, category: 'customer_activation', dynamic: { customerId: customerId, activationCode: activationCode } }) }); res.json({ message: 'Registration successful. Please check your email to activate your account.', customerId }); } catch (error) { console.error('Registration error:', error); res.status(500).json({ error: 'Registration failed' }); } }); ``` ## Step 5: Email Verification Endpoint Handle activation code verification using the IAM API. ```javascript // GET /api/activate?id=``&code=xyz123 app.get('/api/activate', async (req, res) => { const { id, code } = req.query; if (!id || !code) { return res.status(400).json({ error: 'Principal ID and activation code required' }); } try { // Fetch principal directly from IAM API (Graph doesn't expose activationCode) const principalResponse = await fetch(`https://``/resources/iam/principals/${id}`, { headers: { 'Authorization': `Bearer ${clientToken}` // Website's token } }); if (!principalResponse.ok) { return res.status(404).json({ error: 'Invalid activation link' }); } const principal = await principalResponse.json(); // Verify activation code matches if (principal.properties.activationCode !== code) { return res.status(404).json({ error: 'Invalid activation code' }); } // Check if already activated if (principal.properties.activated) { return res.json({ message: 'Account already activated. You can log in.' }); } // Check for existing Customer asset by email const existingCustomerQuery = await fetch('https://``/resources/graph', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${clientToken}` }, body: JSON.stringify([{ edge: 'customers', filter: `dynamic.email == '${principal.properties.email}'`, node: { fields: ['assetId', 'assetNumber'] } }]) }); const existingResult = await existingCustomerQuery.json(); let assetId; if (existingResult[0]?.nodes?.length > 0) { // Link to existing Customer asset assetId = existingResult[0].nodes[0].assetId; console.log('Linking to existing customer asset:', assetId); } else { // Create new Customer asset const assetResponse = await fetch('https://``/resources/actors/custom/asset/new', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${clientToken}` }, body: JSON.stringify([{ type: 'create', body: { typeKey: 'customer', // assetNumber auto-generated (e.g., CUST100001) // Dynamic properties for the customer commands: [{ type: 'setDynamic', body: { fields: { email: principal.properties.email, name: principal.properties.name } } }] } }]) }); if (!assetResponse.ok) { const error = await assetResponse.json(); console.error('Failed to create customer asset:', error); return res.status(500).json({ error: 'Failed to create customer account' }); } const assetResult = await assetResponse.json(); assetId = assetResult.paths[0].split('/').pop(); // Extract asset ID from path } // Update principal: activated + link to asset const updatedProperties = { ...principal.properties, activated: true, activationCode: null, // Remove code after use actorId: assetId // Link principal to Customer asset }; await fetch(`https://``/resources/iam/principals/${id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${clientToken}`, 'If-Match': principal.etag }, body: JSON.stringify({ properties: updatedProperties, roles: principal.roles }) }); res.json({ message: 'Account activated successfully! You can now log in.', assetId: assetId }); } catch (error) { console.error('Activation error:', error); res.status(500).json({ error: 'Activation failed' }); } }); ``` ::: tip **Why use IAM API instead of Graph?** The Identity Graph node only exposes `name`, `email`, and `phone` properties. Custom properties like `activationCode` must be accessed via the IAM API's `/resources/iam/principals/{id}` endpoint. ::: ## Step 6: Customer Login Flow Implement OAuth login for customers. ```javascript // GET /api/auth/login app.get('/api/auth/login', (req, res) => { const authUrl = `https://``/oauth/authorize?` + `client_id=${clientId}&` + `redirect_uri=${encodeURIComponent('https://mystore.com/auth/callback')}&` + `response_type=code&` + `scope=me:*`; res.redirect(authUrl); }); // GET /api/auth/callback app.get('/api/auth/callback', async (req, res) => { const { code } = req.query; try { // Exchange code for token const tokenResponse = await fetch('https://``/oauth/token', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'authorization_code', code, client_id: clientId, client_secret: clientSecret, redirect_uri: 'https://mystore.com/auth/callback' }) }); const tokens = await tokenResponse.json(); // Get customer identity const identityResponse = await fetch('https://``/resources/me', { headers: { 'Authorization': `Bearer ${tokens.access_token}` } }); const identity = await identityResponse.json(); // Check if activated if (!identity.properties.activated) { return res.status(403).json({ error: 'Please activate your account via email before logging in' }); } // Store session req.session.customerId = identity.identityId; req.session.customerNumber = identity.properties.customerNumber; req.session.customerToken = tokens.access_token; res.redirect('/dashboard'); } catch (error) { console.error('Login error:', error); res.status(500).json({ error: 'Login failed' }); } }); ``` ## Step 7: Query Customer Orders Use customer's `customerNumber` to scope order queries. ```javascript // GET /api/my-orders app.get('/api/my-orders', async (req, res) => { if (!req.session.customerNumber) { return res.status(401).json({ error: 'Not authenticated' }); } try { // Website uses ITS OWN token (not customer's) const orders = await fetch('https://``/resources/graph', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${clientToken}` // Website token, NOT customer token }, body: JSON.stringify([{ edge: 'orders', filter: `customerNumber == '${req.session.customerNumber}'`, orderBy: 'createdAt desc', node: { fields: [ 'orderId', 'orderNumber', 'createdAt', 'totalAmount', 'status' ] } }]) }); const results = await orders.json(); res.json({ orders: results[0]?.nodes || [] }); } catch (error) { console.error('Orders query error:', error); res.status(500).json({ error: 'Failed to fetch orders' }); } }); ``` ::: tip The website queries with **its own credentials**, not the customer's token. The customer's `customerNumber` property is used to filter results. ::: ## Complete Flow Diagram ``` Customer Registration & Login Flow: 1. Registration Customer → Website: Submit email, name, password Website → Hantera IAM: Create principal (activated=false) Website → Hantera Sendings: Send activation email 2. Activation Customer → Email: Click activation link Website → Hantera IAM: Verify activation code Website → Hantera Assets: Create Customer asset (auto-generates CUST100001) Website → Hantera IAM: Update principal (activated=true, actorId=``) 3. Login Customer → Website: Click login Website → Hantera OAuth: Redirect to authorization Customer → Hantera: Enter credentials Hantera → Website: Authorization code Website → Hantera OAuth: Exchange code for token Website → Hantera IAM: GET /resources/me Website: Check activated=true, store actorId in session 4. View Orders Customer → Website: Request /my-orders Website → Hantera Graph: Query asset.customer node Website → Hantera Graph: Follow orders relation Filter: Using actorId from session Hantera → Website: Return customer's orders Website → Customer: Display orders ``` **Key Security Point:** Principal cannot access Customer asset until `activated: true`. This prevents email-guessing attacks where someone registers with another person's email. ## Key Concepts ### Customer Has No Permissions ```json { "type": "principal", "properties": { "customerNumber": "CUST-12345" }, "roles": ["customer"], "acl": { "entries": [] } // Empty! No permissions. } ``` The `customer` role is just a marker. All actual data access happens through the website's OAuth client. ### Website Acts as Proxy ```javascript // ❌ WRONG: Using customer's token Authorization: Bearer {customerToken} // ✅ CORRECT: Using website's token Authorization: Bearer {clientToken} ``` The website has full permissions and filters data based on customer identity properties. ### Identity Properties Drive Access ```javascript // Customer's identity { customerNumber: "CUST-12345", activated: true } // Query scoped by property filter: "customerNumber == 'CUST-12345'" ``` ## Security Considerations ### Validate Activation Status Always check `activated: true` before allowing login: ```javascript if (!identity.properties.activated) { return res.status(403).json({ error: 'Please activate your account first' }); } ``` ### Secure Session Management Store customer data in secure, HTTP-only cookies: ```javascript req.session.customerNumber = identity.properties.customerNumber; // Never expose raw Hantera tokens to frontend ``` ### Input Validation Sanitize all user input before creating principals: ```javascript const email = validator.isEmail(req.body.email) ? req.body.email : null; if (!email) { return res.status(400).json({ error: 'Invalid email' }); } ``` ## Troubleshooting ### Customer Can't Log In After Registration **Problem:** User registered but login fails. **Check:** * Is `activated: true` in their principal properties? * Did the activation email send successfully? * Is the activation code correct? ### Orders Not Showing **Problem:** Customer logged in but sees no orders. **Check:** * Is `customerNumber` stored in session? * Do orders have matching `customerNumber` field? * Is website's OAuth client token valid? * Does client have `orders:*` read permission? ### Email Verification Not Working **Problem:** Activation link doesn't work. **Check:** * Does the activation URL include both `id` and `code` parameters? * Is the principal ID valid (exists in IAM)? * Does the `activationCode` in the principal match the URL parameter? * Is the activation code URL-encoded properly? ## Best Practices ### 1. Handle Existing Customer Assets If importing existing customers, check for existing assets by email before creating a new one: ```javascript // Query for existing customer asset by email const existingCustomer = await fetch('https://``/resources/graph', { method: 'POST', body: JSON.stringify([{ edge: 'customers', filter: `email == '${email}'`, node: { fields: ['assetId', 'assetNumber'] } }]) }); if (existingCustomer.nodes.length > 0) { // Link principal to existing asset actorId = existingCustomer.nodes[0].assetId; } else { // Create new asset // ... } ``` ### 2. Expire Activation Codes Store expiration timestamp: ```javascript properties: { activationCode: 'xyz123', activationCodeExpires: new Date(Date.now() + 24*60*60*1000).toISOString() } ``` ### 3. Validate Asset Creation Always check that the Customer asset was created successfully before linking: ```javascript const assetResult = await assetResponse.json(); if (!assetResult.paths || assetResult.paths.length === 0) { console.error('Asset creation failed:', assetResult); return res.status(500).json({ error: 'Failed to create customer account' }); } const assetId = assetResult.paths[0].split('/').pop(); ``` ### 4. Handle Email Uniqueness Check before registration: ```javascript // Query existing customers by email const existing = await fetch('https://``/resources/graph', { method: 'POST', body: JSON.stringify([{ edge: 'identities', filter: `type == 'principal' and properties.email == '${email}'` }]) }); if (existing.nodes.length > 0) { return res.status(409).json({ error: 'Email already registered' }); } ``` ### 5. Rate Limit Registration Prevent spam registrations: ```javascript const rateLimit = require('express-rate-limit'); const registerLimiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes max: 5 // 5 registrations per IP }); app.post('/api/register', registerLimiter, async (req, res) => { // ... }); ``` ## Related Resources * [IAM Domain Model](/resources/iam/) - Understanding identities and roles * [Identity Graph Node](/resources/graph/nodes/identity) - Querying identities * [Sendings API](/resources/sendings) - Sending verification emails * [OAuth 2.0 Authentication](/learn/authentication) - OAuth flows --- --- url: /resources/components/runtimes/keywords/deleteJob.md --- # deleteJob The deleteJob function deletes a pending [Job](/resources/jobs/), effectively cancelling it before it runs. ## Availability ## Return Value Returns `nothing` when the job was deleted. There is no `false` case — every failure is reported as an [Error](/resources/components/runtimes/types/error) record instead. ## Error Handling | Code | Condition | | ------------- | ---------------------------------------------------------------------------------------------------------------- | | `NOT_FOUND` | No job with the given `jobId` exists. | | `NOT_ALLOWED` | The job is currently running, is not in a pending state, or the caller lacks the `jobs/:write` permission for the job's definition. | Only pending jobs can be deleted — a job that has already started or finished cannot be cancelled. ## Examples #### Cancel a scheduled job ```filtrera from deleteJob jobId match Error |> 'Could not cancel the job' |> 'Job cancelled' ``` #### Schedule, then cancel later ```filtrera let scheduled = scheduleJob('myJobDefinition', now + 2 hours) from scheduled match Error |> 'Failed to schedule' (jobId: uuid) |> // ...later, when no longer needed: deleteJob jobId ``` ## See Also * [scheduleJob](/resources/components/runtimes/keywords/scheduleJob) — create a job * [Jobs](/resources/jobs/) — how jobs work --- --- url: /resources/components/runtimes/types/delivery.md --- # Delivery ## Definition ```filtrera let Delivery: { createdAt: instant deliveryAddress: { addressLine1?: nothing|text addressLine2?: nothing|text attention?: nothing|text careOf?: nothing|text city?: nothing|text countryCode?: nothing|text email?: nothing|text name?: nothing|text phone?: nothing|text postalCode?: nothing|text state?: nothing|text } deliveryId: uuid deliveryNumber: text deliveryState: 'open'|'processing'|'completed'|'cancelled'|'cancelledByOrder' dynamic: {text->value} finalizedAt: nothing|instant orderLines: [{ description: text|nothing dynamic: {text->value} image: text|nothing orderLineId: uuid orderLineNumber: text orderLineTotal: number productNumber: text quantity: number releasedToInvoice: boolean returnedQuantity: number skus: [{ orderLineSkuId: uuid reservedQuantity: number skuNumber: text totalQuantity: number totalReturnedQuantity: number unitQuantity: number }] taxFactor: nothing|number taxTotal: number unitPrice: number }] releasedToInvoice: boolean shippingDescription: nothing|text shippingPrice: number shippingProductNumber: nothing|text shippingTax: number shippingTaxFactor: nothing|number shippingTotal: number tags: [text] } ``` ## Availability --- --- url: /resources/graph/nodes/delivery.md description: '' --- # delivery Graph Node Root Set Name: `deliveries` --- --- url: /resources/graph/nodes/discount.md description: '' --- # discount Graph Node Root Set Name: `discounts` --- --- url: /resources/components/runtimes/types/dynamic-fields.md --- # DynamicFields ## Definition ```filtrera let DynamicFields: {text->value} ``` ## Availability --- --- url: /resources/components/runtimes/keywords/dynamicQuery.md --- # dynamicQuery ```filtrera dynamicQuery `` ``` `dynamicQuery` is similar to [`query`](/resources/components/runtimes/keywords/query) except it takes a map or record as argument. This makes it possible to run queries that are constructed at runtime. Similarly, the response value is not known at compile time and must therefor be pattern matched in order to be read. The structure of the query is identical to a regular [Graph navigation](/resources/graph/#query-format). ## Error Handling Failure to parse the query or other errors will be returned as [`QueryError`](/resources/components/runtimes/types/query-error). ## Paging Paging is done transparently and lazily. There's no limit to how many records can be fetched, but watch your memory usage if you buffer the result. ## Examples ```filtrera from dynamicQuery { edge = 'orders' filter = 'createdAt > 2025-01-01' node = { fields = ['orderNumber'] } } ``` --- --- url: /resources/registry/reference/enums_definitions_name_categories.md description: Defines a category for grouping values in an enum definition --- # enums/definitions/``/categories/`` Defines a category for grouping related values within an [enum definition](/resources/registry/reference/enums_definitions_name_values). Categories organize values into labeled groups in the Portal UI. To assign a value to a category, set the `setKey` property on the value entry. The schema is the same as [local enum categories](/resources/registry/reference/enums_graph_node_field_categories). ## Properties | Property | Type | Description | |----------|------|-------------| | `label` | `object` | Localized labels for the category. Keys are locale codes (e.g., `default`, `sv-SE`, `en-US`). | ## Example Value ```json { "label": { "default": "Express Carriers", "sv": "Expressfrakt" } } ``` ## Example ```yaml # Define categories uri: /resources/registry/enums/definitions/shipping-carriers/categories/express spec: value: label: default: Express Carriers --- uri: /resources/registry/enums/definitions/shipping-carriers/categories/standard spec: value: label: default: Standard Carriers --- # Assign values to categories uri: /resources/registry/enums/definitions/shipping-carriers/values/dhl-express spec: value: label: default: DHL Express setKey: express --- uri: /resources/registry/enums/definitions/shipping-carriers/values/postnord spec: value: label: default: PostNord setKey: standard ``` --- --- url: /resources/registry/reference/enums_definitions_name_values.md description: Defines a value in an enum definition --- # enums/definitions/``/values/`` Defines a value in a named enum definition. Enum definitions allow you to define a set of enum values once and reference them from one or more [custom fields](/resources/graph/custom-fields) using the `enumDefinition` property. The value schema is the same as [local enum values](/resources/registry/reference/enums_graph_node_field_values). ## Properties | Property | Type | Description | |----------|------|-------------| | `label` | `object` | Localized labels for the value. Keys are locale codes (e.g., `default`, `sv-SE`, `en-US`). | | `hue` | `number` | Color hue for the value tag (0-360 on the HSL color wheel). | | `setKey` | `string` | Key of the category to group this value under. See [categories](/resources/registry/reference/enums_definitions_name_categories). | ## Example Value ```json { "label": { "default": "High", "sv": "Hög" }, "hue": 0 } ``` ## Example: Shared Enum Definition Define an enum and reference it from custom fields on multiple nodes: ```yaml # Define the enum values uri: /resources/registry/enums/definitions/priority-levels/values/low spec: value: label: default: Low hue: 133 --- uri: /resources/registry/enums/definitions/priority-levels/values/medium spec: value: label: default: Medium hue: 45 --- uri: /resources/registry/enums/definitions/priority-levels/values/high spec: value: label: default: High hue: 0 --- # Reference the definition from a custom field on orders uri: /resources/registry/graph/order/fields/priority spec: value: type: enum source: dynamic->'priority' enumDefinition: priority-levels --- # Same definition on tickets uri: /resources/registry/graph/ticket/fields/priority spec: value: type: enum source: dynamic->'priority' enumDefinition: priority-levels ``` Both fields will share the same dropdown values. Adding a new value at `enums/definitions/priority-levels/values/critical` automatically updates both. ## Local Overrides Fields that reference an enum definition can still have [local enum value overrides](/resources/registry/reference/enums_graph_node_field_values) at the field level. Local values can override labels or hues for specific fields, or add field-specific values not in the definition. ## System-Provided Enum Definitions Some enum definitions are provided automatically by the system: | Definition | Populated From | |------------|---------------| | `channels` | [Channel definitions](/resources/registry/reference/channels) | | `currencies` | [Currency definitions](/resources/registry/reference/currencies) | --- --- url: /resources/registry/reference/enums_graph_node_field_categories.md description: Defines a category for grouping enum field values --- Defines a category (also called a "value set") for grouping related enum values together. Categories help organize enum values into logical groups in the Portal UI. To assign an enum value to a category, set the `setKey` property on the [enum value configuration](/resources/registry/reference/enums_graph_node_field_values). ## Properties | Property | Type | Description | |----------|------|-------------| | `label` | `object` | Localized labels for the category. Keys are locale codes (e.g., `default`, `sv-SE`, `en-US`). | ## Example Value ```json { "label": { "default": "Customer Tags", "sv-SE": "Kundtaggar" } } ``` ## Example Manifest Create a category for customer-related order tags: ```yaml uri: /registry/enums/graph/order/tags/categories/customer spec: value: label: default: Customer Tags sv-SE: Kundtaggar ``` ## Usage with Values Values that don't have a `setKey` assigned, or where the `setKey` doesn't match any defined category, will appear in a default "uncategorized" group. Example of a complete setup with a category and values: ```yaml # Define the category uri: /registry/enums/graph/order/tags/categories/customer spec: value: label: default: Customer Tags --- # Assign values to the category uri: /registry/enums/graph/order/tags/values/vip spec: value: label: default: VIP Customer hue: 280 setKey: customer --- uri: /registry/enums/graph/order/tags/values/returning spec: value: label: default: Returning Customer hue: 200 setKey: customer ``` --- --- url: /resources/registry/reference/enums_graph_node_field_values.md description: Configures display properties for an enum field value --- # enums/graph/``/``/values/`` Configures how an enum field value is displayed in the Portal, including its label and color. Enum values are automatically discovered from the data stored in the system. This registry key allows you to customize how each value appears in the UI. ## Properties | Property | Type | Description | |----------|------|-------------| | `label` | `object` | Localized labels for the value. Keys are locale codes (e.g., `default`, `sv-SE`, `en-US`). | | `hue` | `number` | Color hue for the value tag (0-360 on the HSL color wheel). | | `setKey` | `string` | Key of the category to group this value under. See [categories](/resources/registry/reference/enums_graph_node_field_categories). | ## Example Value ```json { "label": { "default": "VIP Customer", "sv-SE": "VIP-kund" }, "hue": 280, "setKey": "customer" } ``` ## Example Manifest Configure an order tag value with a label and color: ```yaml uri: /registry/enums/graph/order/tags/values/vip spec: value: label: default: VIP Customer sv-SE: VIP-kund hue: 280 setKey: customer ``` ## Enum Definitions For enum values shared across multiple fields, consider using [enum definitions](/resources/registry/reference/enums_definitions_name_values) instead. Local values at this path can still be used alongside an enum definition to override labels/hues for a specific field or add field-specific values. --- --- url: /resources/components/runtimes/types/error.md --- # Error ## Definition ```filtrera let Error: { error: { code: text details?: {text->value} message?: text } } ``` ## Availability --- --- url: /resources/components/runtimes/types/event.md --- # Event ## Definition ```filtrera let Event: { data: value event: text } ``` ## Availability --- --- url: /learn/event-streaming.md description: >- Stream real-time events from Hantera using WebSocket connections for live dashboards, job monitoring, and resource change tracking --- # Event Streaming Hantera's Event Streaming API allows you to receive real-time notifications about changes in your system. This enables building live dashboards, monitoring job execution, and reacting to resource changes as they happen. ::: warning **Preview API**: The Event Streaming API is currently in preview and is subject to change. While functional, the message formats and subscription paths may evolve before the final release. ::: ## Overview The Event Streaming system uses WebSocket connections to push events to clients in real-time. Key features include: * **Real-time notifications**: Receive events immediately as they occur * **Subscription-based filtering**: Subscribe only to the events you need * **Multiple resource types**: Track jobs, job statistics, and actor state changes * **Best-effort delivery**: Events are delivered as they happen without persistence ::: tip Event streaming is designed for UI reactivity and monitoring use cases. For critical workflows that require guaranteed delivery, use [Rules](/resources/rules/) with webhooks instead. ::: ## Endpoint Connect to the events endpoint using a WebSocket connection: ``` wss://{hostname}/events ``` Replace `{hostname}` with your tenant hostname. ## Connection Lifecycle 1. **Open WebSocket connection** Connect to the `/events` endpoint with a WebSocket client: ```typescript const ws = new WebSocket('wss://{hostname}/events') ``` 2. **Authenticate** The first message must be an `auth` message with your access token: ```json { "type": "auth", "token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..." } ``` ::: info Send the token without the "Bearer " prefix. Authentication must complete within 10 seconds or the connection will be closed. ::: 3. **Receive authentication confirmation** On success, the server responds with: ```json { "type": "authenticated" } ``` 4. **Subscribe to events** Subscribe to the resource paths and event types you want to receive: ```json { "type": "subscribe", "subscriptions": [ { "id": "my-subscription", "path": "jobs", "events": ["jobScheduled", "jobStarted", "jobCompleted", "jobFailed"] } ] } ``` 5. **Receive events** Events are pushed as they occur: ```json { "type": "event", "subscriptionId": "my-subscription", "eventType": "jobStarted", "path": "jobs", "data": { ... }, "timestamp": "2025-12-08T21:45:00.000Z" } ``` 6. **Respond to keep-alive** The server sends `ping` messages every 30 seconds. Respond with `pong` within 30 seconds to keep the connection alive: ```json { "type": "pong" } ``` ## Authentication Authentication uses message-based authentication rather than headers, ensuring compatibility with browser and Node.js WebSocket clients. ### Auth Message ```typescript interface AuthMessage { type: 'auth' token: string // Bearer token without "Bearer " prefix } ``` ### Auth Response On success: ```json { "type": "authenticated" } ``` On failure, an error is returned and the connection is closed: ```json { "type": "error", "code": "UNAUTHORIZED", "message": "Invalid or expired token" } ``` ## Subscribing to Events Use the `subscribe` message to register for events on specific resource paths. ### Subscribe Message ```typescript interface SubscribeMessage { type: 'subscribe' requestId?: string // Optional correlation ID subscriptions: Subscription[] } interface Subscription { id: string // Client-assigned unique identifier path: string // Resource path to subscribe to events: string[] // Event types to receive } ``` ### Subscription Response ```json { "type": "subscribed", "requestId": "req-1", "subscriptions": [ { "id": "my-subscription", "path": "jobs", "events": ["jobScheduled", "jobStarted", "jobCompleted", "jobFailed"] } ] } ``` ### Unsubscribing Remove subscriptions when you no longer need them: ```json { "type": "unsubscribe", "ids": ["my-subscription"] } ``` ## Supported Resources and Events ### Jobs Track job lifecycle events across all jobs or specific jobs. | Path | Description | | ---------------- | -------------------------- | | `jobs` | All job lifecycle events | | `jobs/{jobId}` | Events for a specific job | **Available Events:** | Event | Description | | -------------- | ---------------------------- | | `jobScheduled` | Job created in pending state | | `jobStarted` | Job execution began | | `jobCompleted` | Job finished successfully | | `jobFailed` | Job execution failed | **Example: Subscribe to all job events** ```json { "type": "subscribe", "subscriptions": [{ "id": "all-jobs", "path": "jobs", "events": ["jobScheduled", "jobStarted", "jobCompleted", "jobFailed"] }] } ``` **Example: Subscribe to job failures only** ```json { "type": "subscribe", "subscriptions": [{ "id": "job-failures", "path": "jobs", "events": ["jobFailed"] }] } ``` ### Job Statistics Track aggregated statistics per job definition with live bucket updates. | Path | Description | | ---------------------------------- | -------------------------------------- | | `job-definitions` | Statistics for all job definitions | | `job-definitions/{jobDefinitionId}`| Statistics for a specific job type | **Available Events:** | Event | Description | | --------------- | ------------------------------------------- | | `jobStatistics` | Live bucket update with aggregated counters | **Statistics Payload:** ```json { "type": "event", "subscriptionId": "stats", "eventType": "jobStatistics", "path": "job-definitions/sync-inventory", "data": { "jobDefinitionId": "sync-inventory", "bucketTime": "2025-12-08T22:00:00.000Z", "scheduled": 45, "successful": 40, "failed": 2, "minExecution": 120.5, "maxExecution": 1250.0, "avgExecution": 450.3 }, "timestamp": "2025-12-08T22:30:00.000Z" } ``` ### Actors Track state changes (checkpoints) for domain actors: Orders, Payments, SKUs, Tickets, and Assets. | Path | Description | | ------------------------------ | ----------------------------------------- | | `actors` | All actor checkpoint events | | `actors/{actorType}` | Checkpoints for a specific actor type | | `actors/{actorType}/{actorId}` | Checkpoints for a specific actor instance | **Actor Types:** * `orders` - Order actors * `payments` - Payment actors * `skus` - SKU actors * [Custom actors](/resources/actors/custom/) **Available Events:** | Event | Description | | ----------------- | ----------------------------------------- | | `checkpoint` | Checkpoint created in actor | **Example: Subscribe to all order changes** ```json { "type": "subscribe", "subscriptions": [{ "id": "order-updates", "path": "actors/orders", "events": ["checkpoint"] }] } ``` **Example: Subscribe to a specific order** ```json { "type": "subscribe", "subscriptions": [{ "id": "order-detail", "path": "actors/orders/550e8400-e29b-41d4-a716-446655440000", "events": ["checkpoint"] }] } ``` **Checkpoint Payload:** ```json { "type": "event", "subscriptionId": "order-updates", "eventType": "checkpoint", "path": "actors/orders/550e8400-e29b-41d4-a716-446655440000", "data": { "checkpointId": "770e8400-e29b-41d4-a716-446655440002", "actorType": "orders", "actorId": "550e8400-e29b-41d4-a716-446655440000", "identityId": "880e8400-e29b-41d4-a716-446655440003", "timestamp": "2025-12-08T21:47:00.000Z" }, "timestamp": "2025-12-08T21:47:00.000Z" } ``` ::: info The checkpoint event intentionally does not include mutation details. To see what changed, query the actor's state via the Graph API. ::: ## Keep-Alive The server sends `ping` messages every 30 seconds to verify the connection is alive: ```json { "type": "ping", "timestamp": "2025-12-08T21:30:00.000Z" } ``` Respond with `pong` within 30 seconds: ```json { "type": "pong" } ``` If no `pong` is received within the timeout, the server closes the connection. ## Error Handling Errors are returned as error messages: ```json { "type": "error", "code": "INVALID_PATH", "message": "Unknown resource path: invalid/path", "requestId": "req-5" } ``` **Common Error Codes:** | Code | Description | | ------------------------ | ------------------------------------ | | `UNAUTHORIZED` | Invalid or expired token | | `INVALID_PATH` | Subscription path not recognized | | `INVALID_SCOPE` | Invalid event types for path | | `SUBSCRIPTION_NOT_FOUND` | Unsubscribe referenced unknown ID | | `RATE_LIMITED` | Too many requests | ## Backpressure and Message Drops The Event Streaming system uses best-effort delivery. When clients consume events slower than they're produced, events may be dropped. ### How Backpressure Works Each connection has a server-side queue that buffers events. When events are produced faster than the client consumes them: 1. Events accumulate in the queue 2. When the queue fills up, oldest events are dropped 3. The server sends a `warning` message notifying the client ### Warning Message When events are dropped, you'll receive a warning: ```json { "type": "warning", "code": "QUEUE_OVERFLOW", "message": "5 events dropped for subscription 'all-jobs' due to slow consumption", "subscriptionId": "all-jobs" } ``` ### Handling Backpressure ::: warning Dropped events are permanently lost. Event streaming does not provide replay or acknowledgment mechanisms. ::: **To minimize dropped events:** 1. **Process events quickly**: Avoid blocking operations (network calls, heavy computation) in your event handler. Offload processing to a background queue if needed. 2. **Subscribe selectively**: Only subscribe to events you actually need. Use specific paths (`actors/orders/{orderId}`) instead of broad ones (`actors`) when possible. 3. **Handle warnings gracefully**: When you receive a `QUEUE_OVERFLOW` warning, sync your state by querying the [Graph API](/resources/graph/) to recover any missed changes. **Example: Handling overflow warnings** ```typescript this.ws.onmessage = (event) => { const message = JSON.parse(event.data) if (message.type === 'warning' && message.code === 'QUEUE_OVERFLOW') { console.warn(`Events dropped for ${message.subscriptionId}`) // Re-sync state from Graph API to recover missed events this.resyncState(message.subscriptionId) } // ... handle other message types } ``` ### When to Use Webhooks Instead For workflows where every event must be processed, use [Rules](/resources/rules/) with webhooks instead of event streaming. Webhooks provide: * Guaranteed delivery with retries * Persistent event history * Acknowledgment-based processing Event streaming is designed for real-time UI updates and monitoring where occasional missed events are acceptable. ## Complete Example Here's a complete example in TypeScript showing how to connect, authenticate, and subscribe to events: ```typescript class HanteraEventsClient { private ws: WebSocket | null = null private token: string constructor(token: string) { this.token = token } connect(url: string): Promise`` { return new Promise((resolve, reject) => { this.ws = new WebSocket(url) this.ws.onopen = () => { // Send auth message immediately this.send({ type: 'auth', token: this.token }) } this.ws.onmessage = (event) => { const message = JSON.parse(event.data) switch (message.type) { case 'authenticated': console.log('Authenticated successfully') resolve() break case 'subscribed': console.log('Subscribed:', message.subscriptions) break case 'event': this.handleEvent(message) break case 'ping': this.send({ type: 'pong' }) break case 'error': console.error('Error:', message.code, message.message) if (message.code === 'UNAUTHORIZED') { reject(new Error(message.message)) } break } } this.ws.onerror = (error) => reject(error) this.ws.onclose = () => console.log('Connection closed') }) } subscribe(subscriptions: Array<{id: string, path: string, events: string[]}>) { this.send({ type: 'subscribe', subscriptions }) } unsubscribe(ids: string[]) { this.send({ type: 'unsubscribe', ids }) } private send(message: object) { this.ws?.send(JSON.stringify(message)) } private handleEvent(message: any) { console.log(`Event [${message.eventType}]:`, message.data) // Handle specific event types switch (message.eventType) { case 'jobCompleted': console.log(`Job ${message.data.jobId} completed in ${message.data.elapsedMs}ms`) break case 'jobFailed': console.error(`Job ${message.data.jobId} failed: ${message.data.error}`) break case 'checkpoint': console.log(`${message.data.actorType} ${message.data.actorId} updated`) break } } close() { this.ws?.close() } } // Usage async function main() { const client = new HanteraEventsClient('your-access-token') await client.connect('wss://your-tenant.core.ams.hantera.cloud/events') // Subscribe to job events client.subscribe([ { id: 'jobs', path: 'jobs', events: ['jobScheduled', 'jobStarted', 'jobCompleted', 'jobFailed'] }, { id: 'orders', path: 'actors/orders', events: ['checkpoint'] } ]) } main() ``` ## Connection Limits Connection limits are configurable for enterprise tenants. These are the defaults: | Limit | Default | | ---------------------------------- | ------- | | Max connections | 500 | | Max subscriptions/conn | 50 | | Auth timeout | 10s | | Ping interval | 30s | | Ping timeout | 30s | | Max message size (Authenticated) | 512 KB | ## Best Practices 1. **Reconnect on disconnect**: Implement automatic reconnection with exponential backoff for network interruptions. 2. **Subscribe selectively**: Only subscribe to the events you need to reduce message volume. 3. **Handle backpressure**: If the server sends a `warning` with code `QUEUE_OVERFLOW`, your client is consuming events too slowly. 4. **Use request correlation**: Include `requestId` in subscribe/unsubscribe messages to correlate responses. 5. **Clean up subscriptions**: Unsubscribe when you no longer need events to free up server resources. --- --- url: /resources/components/runtimes/keywords/events.md --- # events ``` events( paths: text | [text] events: text | [text] ): [{ event: text data: value }] ``` The `events` function subscribes to Hantera's [event bus](/learn/event-streaming) and returns an iterator that yields events as they occur. This enables real-time streaming in HTTP ingresses when used with [Server-Sent Events (SSE)](/resources/ingresses/http/sse). ## Availability ## Parameters ### paths The event path(s) to subscribe to. Can be a single text value or an array of paths. Paths typically follow the resource pattern (for example `actors/{actorType}/{actorId}` for actor-related events), but other path patterns may be used by different event sources. ```filtrera // Single path events('actors/order/91ba49b2-14a4-41e7-8c1c-b02597edc169', 'checkpoint') // Multiple paths events(['actors/order/91ba49b2-14a4-41e7-8c1c-b02597edc169', 'actors/payment/2ef75af8-4383-4b0b-9ae2-e4645dbea0fb'], 'checkpoint') // Path with interpolation events($'actors/order/{orderId}', 'checkpoint') ``` ### events The event type(s) to filter by. Can be a single text value or an array of event types. Common event types include: * `checkpoint` - Emitted when an actor's state changes ```filtrera // Single event type events('actors/order/3cd8554b-6150-4cda-9766-f62fd2825f2e', 'checkpoint') // Multiple event types events('jobs', ['jobStarted', 'jobCompleted']) ``` ## Return Value Returns an iterator of Event records. Each event has: | Field | Type | Description | |-------|------|-------------| | `event` | text | The event type name | | `data` | value | The event payload, including the path where the event originated | The `data` field contains the full event payload which varies by event type. For `checkpoint` events, it typically contains actor state information. ## Usage with SSE The `events` function is designed to work with [Server-Sent Events (SSE)](/resources/ingresses/http/sse). When an HTTP ingress returns an iterator (like the one from `events`), and the client requests SSE with `Accept: text/event-stream`, Hantera streams each yielded value as an SSE message. ```filtrera param orderId: uuid // Send initial state immediately from { event = 'init', data = { orderId } } // Stream updates as they occur from events ($'actors/order/{orderId}', 'checkpoint') select e => { event = 'updated', data = e.data } ``` ## Examples ### Basic Order Tracking Stream updates for a single order: ```filtrera param orderId: uuid from events ($'actors/order/{orderId}', 'checkpoint') ``` ### Multi-Entity Streaming Subscribe to events from multiple actors: ```filtrera param orderId: uuid param paymentId: uuid from { event = 'init', data = { orderId, paymentId } } from events ($'actors/order/{orderId}', 'checkpoint') select e => { event = 'orderUpdated', data = e.data } from events ($'actors/payment/{paymentId}', 'checkpoint') select e => { event = 'paymentUpdated', data = e.data } ``` ### SKU Stock Updates A complete example that queries for a SKU and streams stock changes: ```filtrera param skuNumber: text let safeSkuNumber = skuNumber replace("''", "''''") let skuQuery = query skus(skuId, skuNumber) filter $'skuNumber == ''{safeSkuNumber}''' from skuQuery match (e: QueryError) |> { error = { code = 'QUERY_ERROR', message = e.message } } |> let sku = skuQuery first from sku match nothing |> { error = { code = 'NOT_FOUND', message = 'SKU not found' } } |> let initialStock = messageActor( 'sku' sku.skuId [{ type = 'calculateAvailableStock' }] ) from { event = 'init', data = initialStock } from events ($'actors/sku/{sku.skuId}', 'checkpoint') select e => let stock = messageActor( 'sku' sku.skuId [{ type = 'calculateAvailableStock' }] ) from { event = 'stockUpdated', data = stock } ``` ## See Also * [Server-Sent Events (SSE)](/resources/ingresses/http/sse) - How to use events with HTTP streaming * [Event Streaming](/learn/event-streaming) - Overview of Hantera's event system * [messageActor](/resources/components/runtimes/keywords/messageActor) - Sending messages to actors --- --- url: /resources/components/runtimes/types/extended-relation.md --- # ExtendedRelation ## Definition ```filtrera let ExtendedRelation: { nodeId: uuid relationKey: text } ``` ## Availability --- --- url: /resources/graph/nodes/file.md description: '' --- # file Graph Node Root Set Name: `files` --- --- url: /resources/registry/reference/graph_node_edges.md --- # graph/``/edges/`` Defines a [Custom Edge](/resources/graph/custom-edges). ## Example Value ```json { "field": "productNumber", "relatedSet": "skus", "relatedField": "skuNumber", "cardinality": "single" } ``` --- --- url: /resources/registry/reference/graph_node_fields.md --- # graph/``/fields/`` Defines a [Custom Field](/resources/graph/custom-fields). ## Example Value ```json { "type": "text", "source": "dynamic->'name'", "dimension": "locale" } ``` For enum fields, you can reference an [enum definition](/resources/registry/reference/enums_definitions_name_values) using the `enumDefinition` property: ```json { "type": "enum", "source": "dynamic->'priority'", "enumDefinition": "priority-levels" } ``` --- --- url: /resources/registry/reference/graph_node_search_edges.md --- # graph/``/search/edges Contains a list of edges on the graph node to include in the search index. By including edges in the node's search index, it's possible to return the parent node when the hit matches a related entity. For example, by adding `orderLine` to the `order` node search index, users are able to search for orders based on related `orderLine` data, such as product numbers. You can also specify nested edges using period, for example `payments.authorizations`. For more information about search, refer to [Graph Phrase Search](/resources/graph/phrase-search). ## Example Value ``` ["deliveries","orderLine"] ``` --- --- url: /resources/registry/reference/graph_node_search_fields.md --- # graph/``/search/fields Contains a list of fields on the graph node to include in the search index. Expected format is an array of objects, each object containing key `field` and optional `isKeyword` (default=false). If `isKeyword` is set to true, the field's content will only match on exact or partial wildcard searches. A non-keyword field can also make special characters such as dash or underscore optional. Additionally, double subsequent letters are removed, so "johansson" and "johanson" are identical. Any queryable field can be indexed, including dynamic fields: `dynamic->'field'`. For more information about search, refer to [Graph Phrase Search](/resources/graph/phrase-search). ## Example Value ```json [{ "field": "orderNumber", "isKeyword": true }] ``` --- --- url: /resources/components/runtimes/modules/web/http.md --- # http `http` function allows a Reactor to make external HTTP requests. ## Availability ## Security All external requests must be done using HTTPS. It's possible to restrict which hostnames are allowed on a system level by setting the [`reactors/effects/httpRequest/allowedHosts`](/resources/registry/reference/reactors_effects_http-request_allowed-hosts) ## Examples ### GET request ```filtrera from http( 'https://example.com' { headers = { 'Authorization' -> 'Bearer ``' } } ) ``` ### POST request If `Content-Type` is set to `application/json`, any non-text value will be converted into JSON automatically. ```filtrera from http( 'https://example.com' { method = 'POST' headers = { 'Authorization' -> 'Bearer ``' 'Content-Type' -> 'application/json' } body = { prop1 = 'value' } } ) ``` --- --- url: /resources/components/runtimes/types/http-request-options.md --- # HttpRequestOptions ## Definition ```filtrera let HttpRequestOptions: { body?: value headers?: {text->text} method?: 'GET'|'HEAD'|'POST'|'PUT'|'DELETE'|'PATCH' } ``` ## Availability ## Default Method If `method` is omitted, the request will use the verb `GET` by default. --- --- url: /resources/components/runtimes/types/http-response.md --- # HttpResponse ## Definition ```filtrera let HttpResponse: { content: value headers: {text->[text]} statusCode: number } ``` ## Availability ## Sub-types Other types can be used to easily match different types of responses: * [HttpResponseOk](./http-response-ok) --- --- url: /resources/components/runtimes/types/http-response-ok.md --- # HttpResponseOk ## Definition ```filtrera let HttpResponseOk: { content: value headers: {text->[text]} statusCode: 200|201|202|204 } ``` ## Availability --- --- url: /resources/graph/nodes/identity.md description: '' --- # identity Graph Node Root Set Name: `identities` ## What is an Identity? An identity represents an authentication entity in Hantera. There are three types: * **Principal** (`type: "principal"`) - Human users * **Client** (`type: "client"`) - OAuth applications and integrations * **System** (`type: "system"`) - Internal system processes ::: tip Identities are **read-only** in the Graph API. To create or modify identities, use the [IAM REST API](/resources/iam/). ::: ## Properties Object The `properties` field exposes a limited set of identity properties: * `name` - Display name * `email` - Email address (unique for principals) * `phone` - Phone number **Note:** Identities may have additional properties in the database, but only these three are exposed via the Graph API. ## Common Query Patterns ### Query All Principals Returns all human identities in the system. ### Find Identity by Email Email addresses are unique across all principals. ### Query Clients Returns OAuth client identities (system clients are excluded). ## Managing Identities Identity management (create, update, delete, suspend, password reset) is done via the [IAM REST API](/resources/iam/), not through Graph queries. **See also:** * [IAM Domain Model](/resources/iam/) - Understanding identity management * [IAM Principals](/resources/iam/principals) - Managing identity principals --- --- url: /learn/guides/importing-shopify-orders.md description: >- This guide walks you through cleaning, mapping, and reconstructing a Shopify order within Hantera’s Order Actor model. The steps are shown manually so you can see each part of the process before moving to a fully automated setup using Ingress. --- # Importing Shopify Orders Into Hantera as a Unified Commerce Backend You know it, we know it: Shopify is not a great logistics manager. It works well as a storefront and checkout system, but once orders are placed, order management becomes complex. ERPs receive the orders through batch syncs, and because these updates are slow, teams spend a lot of time reconciling missing or outdated inventory. As business grows, the gap widens with the use of multiple stores, various payment gateways, and international warehouses. Customer service agents end up checking several ERPs, switching across payment dashboards and spreadsheets, trying to trace which warehouse owns which order, all to answer a simple customer question: “What is the status of my order?” Connecting a storefront like Shopify to a slow ERP and several warehouse systems will always create friction. What teams need is a system in the middle acting as a traffic controller. It listens to the storefront, receives the order the moment it is created, detects the payment state, and coordinates the work that must happen downstream. Everything moves in real time through a single operational layer. [Hantera](https://www.hantera.io/) fills that role. Before any automation happens, you need to understand how an order is represented inside Hantera. This guide walks through that foundation. We will pull an order from Shopify, clean the payload, and rebuild the order inside Hantera using its public API. By the end, you will see how delivery, order lines, inventory positions, discounts, and authorized payments fit together within the actor model. ## How Shopify Orders Flow Into Hantera The workflow of importing orders is fully automated in production. To give you a clear view of what happens behind the scenes, this guide breaks the process into manual steps so you can see each part of the system in motion. In a real integration, Shopify creates the order, authorizes the payment, and sends the payload to your backend through a webhook. The backend receives a large JSON object that must be trimmed, cleaned, and mapped to the structure Hantera uses. We simulate that flow. ```mermaid graph LR subgraph PROD["Production flow – automated"] direction LR %% invisible top spacer P0[ ]:::invisible P1[Shopify
• Customer places order
• Payment authorized] P2[Webhook → backend
Receives JSON payload] P3[Backend / Hantera ingress
• Clean + map JSON
• Build commands
• Send via ingress] P4[Hantera
• Create order actor
• Add lines / deliveries
• Add discounts
• Create payment actor
• Link payment] P5[Hantera
Order + payment actors] P1 --> P2 --> P3 --> P4 --> P5 end classDef invisible fill:none,stroke:none; ``` ```mermaid graph LR subgraph GUIDE["Simulated Manual flow"] direction LR %% invisible top spacer to push boxes down G0[ ]:::invisible G1[Shopify Admin API
• Fetch order via curl
• Save raw JSON] G2[Manual mapping
• Clean payload
•Produce cleaned JSON] G3[Request Commands via Hantera public API
• Create order actor
• Add orderlines / delivery
• Create payment actor
• Link payment
• Set state] G4[Hantera
Order + payment actors] G1 --> G2 --> G3 --> G4 end classDef invisible fill:none,stroke:none; ``` Hantera stores the final representation of the order within the Order Actor. The Order Actor holds the core information and additional commands for adding delivery, order lines, inventory positions, and discount values. The Payment Actor manages the authorized payment and can be linked to the order, so both actors stay connected and traceable. Shopify identifiers such as `orderId` and `orderNumber` can be added as dynamic fields so the order’s source remains traceable in Hantera. ## Prerequisites This walkthrough is a show-and-tell. You do not need to run any commands to follow along, but it helps if you have some experience working with: * JSON and YAML * basic HTTP API calls, such as sending requests and reading responses * Shopify development store to pull a test order Let's get started. ## Step 1: Importing and Cleaning the Shopify Order Data To work with realistic data, we create a development store in Shopify, create a test order, and mark it as paid. ![Shopify Dev store environment](https://paper-attachments.dropboxusercontent.com/s_740D398CE75E5347850342016086EF5F32F9D127D2F3C573B19B5F1C1FB5AADF_1765219464653_Screenshot+2025-12-08+at+19.43.55.png) This gives us the same JSON payload that a real Shopify order would produce. The first step is to extract that payload and trim it into a smaller structure that is easier to map into Hantera. ### a. Extract the Shopify raw order ```bash curl -X GET \ "https://hantera-integration-test.myshopify.com/admin/api/2025-01/orders/``.json" \ -H "X-Shopify-Access-Token: shpat_``" \ -H "Content-Type: application/json" \ -o raw-order.json ``` This command writes the full Shopify payload into a `raw-order.json` file. The file is large and not very friendly to work with directly. A shortened version looks like this: ```json {"order":{"id":6337965293665,"admin_graphql_api_id":"gid:\/\/shopify\/Order\/6337965293665","app_id":1354745,"browser_ip":"181.109.120.35","buyer_accepts_marketing":false,"cancel_reason":null,"cancelled_at":null,"..."}} ``` ### b. Clean the Shopify raw order Next, we create a small Node script that reads `raw-order.json`, picks out the fields we need, and writes a trimmed structure to `cleaned-order.json`. ```javascript const fs = require("fs"); // 1. Read raw Shopify JSON const raw = fs.readFileSync("./raw-order.json", "utf8"); const data = JSON.parse(raw); const order = data.order; // 2. Mapping function function mapShopifyOrder(order) { return { // Order-level orderId: order.id, orderNumber: order.order_number, currency: order.currency, createdAt: order.created_at, customerLocale: order.customer_locale, customer: { id: order.customer?.id, defaultAddress: order.customer?.default_address, billingAddress: order.billing_address, shippingAddress: order.shipping_address, }, lineItems: order.line_items.map(item => ({ sku: item.sku || String(item.variant_id), title: item.title, quantity: item.quantity, unitPrice: Number(item.price), taxable: item.taxable, discountAllocations: (item.discount_allocations || []).map(a => ({ amount: Number(a.amount), currency: a.amount_set?.shop_money?.currency_code, })), })), discounts: { codes: order.discount_codes || [], applications: order.discount_applications || [], }, shipping: order.shipping_lines[0] ? { title: order.shipping_lines[0].title, price: Number(order.shipping_lines[0].price), code: order.shipping_lines[0].code, } : null, payment: { amount: Number(order.total_price), currency: order.currency, financialStatus: order.financial_status, gateway: order.payment_gateway_names?.[0], transactionId: order.confirmation_number, // temp auth ID }, meta: { note: order.note, tags: order.tags, fulfillmentStatus: order.fulfillment_status, subtotal: Number(order.subtotal_price), totalDiscounts: Number(order.total_discounts), totalLineItemsPrice: Number(order.total_line_items_price), }, }; } // 3. Run mapping const cleaned = mapShopifyOrder(order); // 4. Write cleaned JSON fs.writeFileSync( "./cleaned-order.json", JSON.stringify(cleaned, null, 2), "utf8" ); console.log("Wrote cleaned-order.json"); ``` Running: ```bash node map.js ``` Produces the cleaned structure: ```json { "orderId": 6337965293665, "orderNumber": 1002, "currency": "USD", "createdAt": "2025-12-03T07:51:44-05:00", "customerLocale": "en-ca", "customer": { "id": 7847164346465, "defaultAddress": { "id": 9056603373665, "customer_id": 7847164346465, "company": "Company Name", "province": "Ontario", "country": "Canada", "province_code": "ON", "country_code": "CA", "country_name": "Canada", "default": true }, "billingAddress": { "province": null, "country": "Canada", "country_code": "CA", "province_code": null }, "shippingAddress": { "province": "Ontario", "country": "Canada", "country_code": "CA", "province_code": "ON" } }, "lineItems": [ { "sku": "42657653129313", "title": "The Videographer Snowboard", "quantity": 1, "unitPrice": 841.65, "taxable": true, "discountAllocations": [ { "amount": 84.16, "currency": "USD" } ] } ], "discounts": { "codes": [ { "code": "ORDER10", "amount": "84.16", "type": "percentage" } ], "applications": [ { "target_type": "line_item", "type": "discount_code", "value": "10.0", "value_type": "percentage", "allocation_method": "across", "target_selection": "all", "code": "ORDER10" } ] }, "shipping": { "title": "International Shipping", "price": 30, "code": "International Shipping" }, "payment": { "amount": 787.49, "currency": "USD", "financialStatus": "paid", "gateway": "manual", "transactionId": "N8A4RXG8H" }, "meta": { "note": null, "tags": "", "fulfillmentStatus": null, "subtotal": 757.49, "totalDiscounts": 84.16, "totalLineItemsPrice": 841.65 } } ``` This cleaned object is what a real backend would pass on to Hantera. It pulls out the important fields and leaves the rest behind. In the next step, we start mapping these fields into Hantera’s order and payment actors. For example: **Mapping summary** | Shopify field | Hantera target | | ------------------------ | --------------------------------------------- | | order.id | order.dynamic.shopifyOrderId | | order.order\_number | order.dynamic.shopifyOrderNumber | | order.currency | order.currencyCode / payment.currencyCode | | total\_price | payment.amount | | line\_items\[n].price | orderLine.unitPrice | | line\_items\[n].quantity | orderLine.quantity | | variant\_id / sku | orderLine.productNumber | | payment\_gateway\_names\[0] | payment.providerKey or payment dynamic fields | | confirmation\_number | payment.authorizationNumber | | shipping\_lines\[0].price | delivery.shippingPrice | ## Step 2: Creating the Order Actor in Hantera After cleaning the Shopify order, we create a new [Order Actor](https://developer.hantera.io/resources/actors/order/) in Hantera to hold the mapped data from Shopify. To do this, we send a request to create the order with its currency and tax settings, and add the Shopify identifiers as dynamic fields: ```http POST https://{tenant-id}.core.ams.hantera.cloud/resources/actors/order/new Authorization: Bearer `` Content-Type: application/json [ { "type": "create", "body": { "currencyCode": "USD", "taxIncluded": false, "commands": [ { "type": "setOrderDynamicFields", "fields": { "shopifyOrderId": 6337965293665, "shopifyOrderNumber": 1002 } } ] } } ] ``` A successful response looks like this: ```json { "paths": [ "resources/actors/order/019b03be-e202-76ab-8810-3313cfb237eb", "resources/actors/order/O100127" ], "data": { "create": "OK" } } ``` Hantera returns two identifiers for the same order: * a UUID (`019ae966-57f1-7c93-86b6-7e3f90cd9273`), used internally by the API * a short order handle (`O100127`), which appears on Hantera’s dashboard and is easier to reference during testing. If you look at the `commands` section in the request, you can see where the Shopify identifiers are stored: ```json { "type": "setOrderDynamicFields", "fields": { "shopifyOrderId": 6337965293665, "shopifyOrderNumber": 1002 } } ``` These dynamic fields give Hantera a place to hold Shopify-specific values that do not exist as built-in fields. To surface them in the UI, we add graph mappings in Hantera’s registry: ```yaml #h_manifest.yml --- uri: /registry/graph/order/fields/shopifyOrderId spec: value: type: 'text' source: "dynamic->'shopifyOrderId'" --- uri: /registry/graph/order/fields/shopifyOrderNumber spec: value: type: 'text' source: "dynamic->'shopifyOrderNumber'" ``` Apply the manifest: ```bash h_ manage apply h_manifest.yml ``` After this, the Shopify order id and order number appear as columns on the Hantera order view, which makes it easy to trace `O100127` back to the original Shopify order. ![Shopify fields mapped in Hantera](https://paper-attachments.dropboxusercontent.com/s_2D1EF25E20F51D8D049AA846AB58C0DBBF7643DEF22E460A0E80AABE8154C2B1_1765294520961_Screenshot+2025-12-09+at+16.34.58.png) ## Step 3: Adding Delivery, Order Lines, Inventory, and Discounts To prepare this step, we take the cleaned Shopify order and break it into the parts Hantera expects. These parts include a delivery, one or more order lines, the conventional inventory fields for that delivery, and any discounts applied to the order. These components mirror how Shopify represents an order, but now we express them through Hantera’s order actor. We add these structures by sending an `applyCommands` request to the order actor. These commands can also be included in the initial `create` message, but separating them here makes the flow easier to understand. ```http POST https://demo-tech1.core.ams.hantera.cloud/resources/actors/order/`` Authorization: Bearer `` Content-Type: application/json [ { "type": "applyCommands", "body": { "commands": [ { "type": "createDelivery", "deliveryId": "00000000-0000-0000-0000-000000000001", "shippingPrice": 30, "shippingProductNumber": "SHIP_INTL", "shippingDescription": "International Shipping" }, { "type": "setDeliveryDynamicFields", "deliveryId": "00000000-0000-0000-0000-000000000001", "fields": { "inventoryKey": "INV_CA", "inventoryDate": "2025-12-03" } }, { "type": "createOrderLine", "deliveryId": "00000000-0000-0000-0000-000000000001", "orderLineId": "11111111-1111-1111-1111-000000000001", "orderLineNumber": "2", "productNumber": "42657653129313", "description": "The Videographer Snowboard", "quantity": 1, "unitPrice": 841.65, }, { "type": "createComputedOrderDiscountBySource", "discountId": "22222222-2222-2222-2222-000000000001", "source": "from percentage(target(e => e is OrderLine), 10%)", "description": "ORDER10 - 10% off", "dynamic": { "shopifyDiscountCode": "ORDER10" } } ] } } ] ``` Notice how the UUIDs follow predictable patterns for readability during testing. * all deliveries use the prefix `00000000-0000-0000-0000-…` * all order lines use `11111111-1111-1111-1111-…` A production workflow would generate fully random UUIDs, but this pattern helps show the relationship between a single delivery and its order components: * Delivery 1 → `000…001` * OrderLine 1 → `111…001` This mirrors Shopify’s structure: one order may have multiple deliveries, and each delivery may contain multiple order lines. A second delivery would use `000…002`, while a second order line would use `111…002`. To make the flow clearer, here is what each command contributes to the order. 1. `createDelivery` ```json { "type": "createDelivery", "deliveryId": "00000000-0000-0000-0000-000000000001", "shippingPrice": 30, "shippingProductNumber": "SHIP_INTL", "shippingDescription": "International Shipping" } ``` This command creates the delivery container in Hantera. It carries the shipping cost, the shipping method, and the product code taken from Shopify’s `shipping_lines`. 2. `setDeliveryDynamicFields` ```json { "type": "setDeliveryDynamicFields", "deliveryId": "00000000-0000-0000-0000-000000000001", "fields": { "inventoryKey": "INV_CA", "inventoryDate": "2025-12-03" } } ``` This stamps the delivery with the warehouse it ships from and the date stock availability was evaluated. Deliveries carry inventory data as conventional dynamic fields rather than built-in system fields — apps such as [inventory-routing](https://developer.hantera.io/official-apps/inventory-routing/) act on these conventions (for example, resolving `inventoryKey: "auto_assign"` into a real warehouse). 3. `createOrderLine` ```json { "type": "createOrderLine", "deliveryId": "00000000-0000-0000-0000-000000000001", "orderLineId": "11111111-1111-1111-1111-000000000001", "orderLineNumber": "1", "productNumber": "42657653129313", "description": "The Videographer Snowboard", "quantity": 1, "unitPrice": 841.65 } ``` This creates the item itself and ties it to the delivery. If a Shopify order contained multiple items or partial shipments, additional deliveries and order lines would be created the same way. 4. `createComputedOrderDiscountBySource` ```json { "type": "createComputedOrderDiscountBySource", "discountId": "22222222-2222-2222-2222-000000000001", "source": "from percentage(target(e => e is OrderLine), 10%)", "description": "ORDER10 - 10% off", "dynamic": { "shopifyDiscountCode": "ORDER10" } } ``` This creates a computed discount that applies ten percent across all order lines. The Filtrera expression in the `source` field defines the logic, and the `dynamic` field keeps the original Shopify discount code for later reference. Using a computed discount keeps Hantera’s pricing aligned with Shopify without requiring a separate discount component. After these commands are applied, Hantera displays the delivery, order line, and discount exactly as Shopify structures them, but in Hantera’s operational model. ![Hantera visualized version of the delivery, order line and discount ](https://paper-attachments.dropboxusercontent.com/s_2D1EF25E20F51D8D049AA846AB58C0DBBF7643DEF22E460A0E80AABE8154C2B1_1765294975267_Screenshot+2025-12-09+at+16.42.40.png) ## Step 4: Creating a Payment Actor That Mirrors Shopify’s Authorization Since Shopify already authorizes the payment, we create a payment actor in Hantera that mirrors that state instead of charging the customer again. We send the following request to create the [Payment Actor](https://developer.hantera.io/resources/actors/payment/): ```http POST https://{tenant-id}.core.ams.hantera.cloud/resources/actors/payment/new Authorization: Bearer `` Content-Type: application/json [ { "type": "create", "body": { "providerKey": "shopify-manual", "currencyCode": "USD", "amount": 787.49, "commands": [ { "type": "createAuthorization", "authorizationNumber": "N8A4RXG8H", "amount": 787.49, "authorizationState": "successful" } ] } } ] ``` The `providerKey`, `currencyCode`, and `amount` come directly from the Shopify order (`payment_gateway_names`, `currency`, and `total_price`). The `createAuthorization` command records Shopify’s authorization number and marks it as successful. ## Step 5: Linking the Payment to the Order and Updating Order Status With the payment authorized inside Hantera, we link the payment actor to the order actor and update the order status to `confirmed`. The linking command looks like this: ```http POST https://{tenant-id}.core.ams.hantera.cloud/resources/actors/order/O100126 Authorization: Bearer `` Content-Type: application/json [ { "type": "applyCommands", "body": { "commands": [ { "type": "linkPayment", "paymentId": "019afe87-d0ff-74fe-86c6-53db790995d0" } ] } } ] ``` This associates the payment actor with the order actor, so the authorization and balance show up directly on the order view in Hantera. Next, we set the order state to `confirmed`: ```http POST https://{tenant-id}.core.ams.hantera.cloud/resources/actors/order/O100126 Authorization: Bearer `` Content-Type: application/json [ { "type": "applyCommands", "body": { "commands": [ { "type": "setOrderState", "orderState": "confirmed" } ] } } ] ``` After these commands, the order screen in Hantera shows the products, shipping, total, and authorized amount in a single frame. The payment appears as linked, and the order is marked as confirmed, just like the paid order in Shopify. ![Hantera's representation of the imported Shopify order structure](https://paper-attachments.dropboxusercontent.com/s_2D1EF25E20F51D8D049AA846AB58C0DBBF7643DEF22E460A0E80AABE8154C2B1_1765295025638_Screenshot+2025-12-09+at+16.43.32.png) These steps give a high-level picture of how a backend can use Hantera’s actors and commands to mirror Shopify orders, payments, and status. ## From High-Level Walkthrough to Full Automation We took a step-by-step approach to cleaning a Shopify order and rebuilding its structure in Hantera. The goal was to show how Hantera adapts to data from different systems and how each part of the order maps into the order actor model. Once you see this flow clearly, the next idea that comes to mind is automation. In a real setup, the entire process runs without manual calls. Hantera exposes a harmonized API and an [ingress](https://developer.hantera.io/resources/ingresses/) layer that allows Shopify’s webhooks to send order events into Hantera directly. --- --- url: /resources/graph/nodes/incoming-stock.md description: '' --- # incomingStock Graph Node Root Set Name: `incomingStock` --- --- url: /resources/registry/reference/inventories.md description: Contains inventory definitions --- # inventories Inventories are defined in the registry and represent physical or logical stock locations. Each inventory has a unique key and a display label. Each inventory key can only contain a-z, 0-9, and \_. The first character must be non-numeric. ## Properties | Property | Type | Description | |----------|------|-------------| | `label` | `string` or `object` | Display label for the inventory. Can be a simple string or a localized object with locale keys (e.g., `default`, `sv`). | | `address` | `object` | Optional postal address for the inventory location. Uses the conventional [Address](/resources/components/runtimes/types/address) shape. All sub-fields are optional and partial addresses are allowed. Useful for shipping labels, customs documentation, and carrier integrations. | Additional properties may be added by installed apps. By convention, apps prefix their fields with their app name to avoid collisions (e.g., `nshift_warehouseId`). Inventories automatically provide enum values for the `inventoryKey` system field on stock positions. The `label` is used as the dropdown display text. Custom graph fields may also reference inventories by specifying `values: "inventories"` — this is how conventional delivery fields such as the `inventoryKey` contributed by the [inventory-routing](/official-apps/inventory-routing/) app get their dropdown options. ## Address The optional `address` field follows the conventional Hantera [Address](/resources/components/runtimes/types/address) shape. All fields are strings and all are optional: | Field | Description | |-------|-------------| | `name` | Recipient or location name (e.g. "Hantera Logistics AB") | | `careOf` | "C/O" line | | `addressLine1` | Primary street address | | `addressLine2` | Secondary street address | | `postalCode` | Postal or ZIP code | | `city` | City | | `state` | State or region (where applicable) | | `countryCode` | ISO 3166-1 alpha-2 country code (uppercase) | | `email` | Contact email for this location | | `phone` | Contact phone for this location | Partial addresses are allowed — only set the fields that are known. If every field is empty, omit `address` entirely rather than storing an empty object. ## Example ```yaml uri: /registry/inventories/wh_stockholm spec: value: label: Warehouse Stockholm ``` With localization: ```yaml uri: /registry/inventories/wh_stockholm spec: value: label: default: Warehouse Stockholm sv: Lager Stockholm ``` With address: ```yaml uri: /registry/inventories/wh_stockholm spec: value: label: Warehouse Stockholm address: name: Hantera Logistics AB addressLine1: Storgatan 1 postalCode: '111 22' city: Stockholm countryCode: SE email: warehouse@example.com phone: '+46 8 123 45 67' ``` --- --- url: /resources/components/runtimes/types/invoice.md --- # Invoice ## Definition ```filtrera let Invoice: { capturedTotal: number createdAt: instant invoiceId: uuid invoiceLines: [{ description: nothing|text discounts: [{ amount: number description: nothing|text invoiceLineDiscountId: uuid referenceId: uuid referenceType: 'staticDiscount'|'promotion' }] invoiceLineId: uuid invoiceLineNumber: number net: number originalInvoiceLineId: nothing|uuid productNumber: nothing|text quantity: nothing|number referenceId: uuid referenceType: 'none'|'orderLine'|'return'|'delivery' tax: number total: number }] invoiceNetTotal: number invoiceNumber: text invoiceRecipient: { addressLine1: text addressLine2: text attention: text careOf: text city: text countryCode: text email: text name: text phone: text postalCode: text state: text taxCountryCode: nothing|text taxId: nothing|text taxIdType: nothing|text } invoiceTaxTotal: number invoiceTotal: number isCancelled: boolean } ``` ## Availability --- --- url: /resources/graph/nodes/invoice.md description: '' --- # invoice Graph Node Root Set Name: `invoices` --- --- url: /resources/components/runtimes/types/invoice-line.md --- # InvoiceLine ## Definition ```filtrera let InvoiceLine: { description: nothing|text discounts: [{ amount: number description: nothing|text invoiceLineDiscountId: uuid referenceId: uuid referenceType: 'staticDiscount'|'promotion' }] invoiceLineId: uuid invoiceLineNumber: number net: number originalInvoiceLineId: nothing|uuid productNumber: nothing|text quantity: nothing|number referenceId: uuid referenceType: 'none'|'orderLine'|'return'|'delivery' tax: number total: number } ``` ## Availability --- --- url: /resources/graph/nodes/invoice-line.md description: '' --- # invoiceLine Graph Node Root Set Name: `invoiceLines` --- --- url: /resources/components/runtimes/types/invoice-line-discount.md --- # InvoiceLineDiscount ## Definition ```filtrera let InvoiceLineDiscount: { amount: number description: nothing|text invoiceLineDiscountId: uuid referenceId: uuid referenceType: 'staticDiscount'|'promotion' } ``` ## Availability --- --- url: /resources/graph/nodes/invoice-line-discount.md description: '' --- # invoiceLineDiscount Graph Node Root Set Name: `invoiceLineDiscounts` --- --- url: /resources/components/runtimes/types/invoice-recipient.md --- # InvoiceRecipient ## Definition ```filtrera let InvoiceRecipient: { addressLine1: text addressLine2: text attention: text careOf: text city: text countryCode: text email: text name: text phone: text postalCode: text state: text taxCountryCode: nothing|text taxId: nothing|text taxIdType: nothing|text } ``` ## Availability --- --- url: /resources/components/runtimes/keywords/invokeReactor.md --- # invokeReactor ``` invokeReactor( reactorId: text method: text argument: { }|nothing ): value | Error ``` The invokeReactor function allows Reactors to call methods from other (or current) Reactors and receive the result. ## Availability ## Return Value The return value from the call is the value returned from the given reactor. ## Recursions and Loops By having a reactor call itself it's possible to introduce recursion, and more specifically infinite recursion. To prevent this and ensure stable operations, there's a max call depth that will trigger an error. The limit for cloud instances are 10 calls, while enterprise instances may configure this. ## Error Handling [Error](/resources/components/runtimes/types/error) may be returned if the specified `reactorId` or `method` doesn't exist, or the given `argument` is invalid. ## Examples ```filtrera from invokeReactor( 'myReactor', 'myMethod' ) match Error |> 'An error occurred' (result: value) |> result ``` --- --- url: /resources/components/runtimes/modules/iterators.md --- # iterators See official [Filtrera Documentation](https://www.filtrera.io/modules/iterators/). ## Availability --- --- url: /resources/graph/nodes/job.md description: '' --- # job Graph Node Root Set Name: `jobs` --- --- url: /resources/components/runtimes/modules/json.md --- # json See official [Filtrera Documentation](https://www.filtrera.io/modules/json/). ## Availability --- --- url: /resources/registry/reference/locales.md description: Contains Locales --- # locales Locales are defined in the registry and used throughout the system. Refer to [Dimensions](/learn/dimensions) for more info. Each locale must have a unique key that can only contain a-z 0-9 and \_. And the first character has to be non-numeric. For example: ```yaml uri: /registry/locales/sweden spec: value: language: sv formatting: sv-SE ``` The value can contain any properties. The `language` and `formatting` values are recommended. --- --- url: /resources/components/runtimes/modules/maps.md --- # maps See official [Filtrera Documentation](https://www.filtrera.io/modules/maps/). ## Availability --- --- url: /resources/components/runtimes/modules/math.md --- # math See official [Filtrera Documentation](https://www.filtrera.io/modules/math/). ## Availability --- --- url: /resources/actors/custom/asset/messages/apply-commands.md description: > Applies a list of [Asset commands](/resources/actors/custom/asset/commands/) to a given `ASSET ID`. Commands are [batch processed](/resources/actors/#:~:text=Batch%20Processing%20of%20Commands,-Commands%20are%20always), which means that if one command fails, the entire message fails. --- # Message: asset.applyCommands Applies a list of [Asset commands](/resources/actors/custom/asset/commands/) to a given `ASSET ID`. Commands are [batch processed](/resources/actors/#:~:text=Batch%20Processing%20of%20Commands,-Commands%20are%20always), which means that if one command fails, the entire message fails. **Schema** ## Related Rule Hooks During an `applyCommands` message, the following rule hook is run: 1. [`OnAssetCommands`](/resources/components/runtimes/rule-hooks/onAssetCommands) ## Example: Add an Item and a Relation to an Asset ```json [{ "type": "applyCommands", "body": { "commands": [ { "type": "createItem", "itemTypeKey": "localVendor", "dynamic": { "distance": "close" } }, { "type": "createRelation", "relation": "order", "nodeId": "" } ] } }] ``` **Response** ``` HTTP/1.1 200 OK --- { "paths": [ "resources/actors/custom/asset/{assetId}", "resources/actors/custom/asset/vendor/{assetNumber}", "resources/actors/custom/asset/vendor/{externalReference}" ], "data": { "applyCommands": "OK" } } ``` ## Ensure you enter the correct id to avoid getting a [NOT\_FOUND](/resources/actors/errors/#:~:text=NOT_FOUND-,NOT_FOUND) error. You will also get an [INVALID\_COMMAND](/resources/actors/errors/#:~:text=INVALID_COMMAND-,INVALID_COMMAND) response if you don't send a properly formed request body. --- --- url: /resources/actors/custom/asset/messages/create.md description: > Creates a new instance of an asset actor. You must specify an asset type. If not, you'll get an [INVALID_MESSAGE_BODY](/resources/actors/errors/#:~:text=INVALID_MESSAGE_BODY-,INVALID_MESSAGE_BODY) error. --- # Message: asset.create Creates a new instance of an asset actor. You must specify an asset type. If not, you'll get an [INVALID\_MESSAGE\_BODY](/resources/actors/errors/#:~:text=INVALID_MESSAGE_BODY-,INVALID_MESSAGE_BODY) error. **Schema** --- --- url: /resources/actors/custom/asset/messages/query.md description: | Queries the asset from the graph. --- # Message: asset.query Queries the asset from the graph. **Schema** ## Example: Query a Vendor **Response** ``` HTTP/1.1 200 OK --- { "paths": [ "resources/actors/custom/asset/{assetId}", "resources/actors/custom/asset/vendor/``", "resources/actors/custom/asset/vendor/``" ], "data": { "query": {} } } ``` --- --- url: /resources/actors/common/messages/delete.md description: | Deletes the actor --- # Message: common.delete Deletes the actor **Schema** --- --- url: /resources/actors/common/messages/get-checkpoints.md description: > Returns available checkpoints for the actor, which can be used for rewind operations. --- # Message: common.getCheckpoints Returns available checkpoints for the actor, which can be used for rewind operations. **Schema** --- --- url: /resources/actors/common/messages/rewind.md description: > Rewinds the actor to a specific checkpoint. This operation appends a new mutation set that transforms the current state back to the checkpoint state. Activity logs are preserved with a rewind entry added. --- # Message: common.rewind Rewinds the actor to a specific checkpoint. This operation appends a new mutation set that transforms the current state back to the checkpoint state. Activity logs are preserved with a rewind entry added. **Schema** --- --- url: /resources/actors/order/messages/apply-commands.md description: | Applies a list of commands to an Order --- # Message: order.applyCommands Applies a list of commands to an Order **Schema** ## Rule Hooks * [`OnOrderCommands`](/resources/components/runtimes/rule-hooks/onOrderCommands) * [`OnOrderCalculate`](/resources/components/runtimes/rule-hooks/onOrderCalculate) * [`OnOrderValidate`](/resources/components/runtimes/rule-hooks/onOrderValidate) ## Examples --- --- url: /resources/actors/order/messages/capture-payments.md description: | Captures payments for all unpaid invoices on the Order. --- # Message: order.capturePayments Captures payments for all unpaid invoices on the Order. **Schema** --- --- url: /resources/actors/order/messages/create.md description: | Creates an new Order. --- # Message: order.create Creates an new Order. **Schema** ## Rule Hooks During the `create` message, the following [Rule Hooks](/resources/rules/hooks) are run in the given order: 1. [`OnOrderBeforeCreated`](/resources/components/runtimes/rule-hooks/onOrderBeforeCreated) 2. [`OnOrderCreated`](/resources/components/runtimes/rule-hooks/onOrderCreated) 3. [`OnOrderCommands`](/resources/components/runtimes/rule-hooks/onOrderCommands) 4. [`OnOrderCalculate`](/resources/components/runtimes/rule-hooks/onOrderCalculate) 5. [`OnOrderValidate`](/resources/components/runtimes/rule-hooks/onOrderValidate) ## Examples --- --- url: /resources/actors/order/messages/query.md description: | Queries the order from the graph. --- # Message: order.query Queries the order from the graph. **Schema** --- --- url: /resources/actors/payment/messages/apply-commands.md description: | Applies a list of commands to a Payment --- # Message: payment.applyCommands Applies a list of commands to a Payment **Schema** ## Rule Hooks * [`OnPaymentCommands`](/resources/components/runtimes/rule-hooks/onPaymentCommands) ## Examples #### Add Tag to Payment \ ```json [{ "type": "applyCommands", "body": { "commands": [{ "type": "addTag", "key": "card" } ] } }] ``` --- --- url: /resources/actors/payment/messages/charge.md description: > Registers a charge for a given amount. If the payment maps an external payment provider, this message should be called after the charge has been approved by the provider. --- # Message: payment.charge Registers a charge for a given amount. If the payment maps an external payment provider, this message should be called after the charge has been approved by the provider. **Schema** --- --- url: /resources/actors/payment/messages/complete-capture.md description: > Completes pending capture and assigns its amount to credit balance. This can be used to force a capture to complete even if there's no underlying charge. Note that this can cause a payment to get a negative credit amount. --- # Message: payment.completeCapture Completes pending capture and assigns its amount to credit balance. This can be used to force a capture to complete even if there's no underlying charge. Note that this can cause a payment to get a negative credit amount. **Schema** --- --- url: /resources/actors/payment/messages/create.md description: | Creates a new Payment. --- # Message: payment.create Creates a new Payment. **Schema** ## Rule Hooks During the `create` message, the following [Rule Hooks](/resources/rules/hooks) are run in the given order: 1. [`OnPaymentBeforeCreated`](/resources/components/runtimes/rule-hooks/onPaymentBeforeCreated) 2. [`OnPaymentCreated`](/resources/components/runtimes/rule-hooks/onPaymentCreated) 3. [`OnPaymentCommands`](/resources/components/runtimes/rule-hooks/onPaymentCommands) ## Examples #### Create New Order \ ```json [{ "type": "create", "body": { "providerKey": "stripe", "commands": [{ "type": "setDynamicFields", "fields": { "paymentIntentId": "pi_3MtwBwLkdIwHu7ix28a3tqPa" } } ] } }] ``` --- --- url: /resources/actors/payment/messages/query.md description: | Queries the payment from the graph. --- # Message: payment.query Queries the payment from the graph. **Schema** --- --- url: /resources/actors/payment/messages/refund.md description: > Registers a refund for a given amount. If the payment maps an external payment provider, this message should be called after the refund has been approved by the provider. --- # Message: payment.refund Registers a refund for a given amount. If the payment maps an external payment provider, this message should be called after the refund has been approved by the provider. **Schema** --- --- url: /resources/actors/sku/messages/apply-commands.md description: | Applies a list of commands to a Sku --- # Message: sku.applyCommands Applies a list of commands to a Sku **Schema** --- --- url: /resources/actors/sku/messages/calculate-available-stock.md description: | Calculates available stock --- # Message: sku.calculateAvailableStock Calculates available stock **Schema** --- --- url: /resources/actors/sku/messages/create.md description: | Creates a new SKU. --- # Message: sku.create Creates a new SKU. **Schema** --- --- url: /resources/actors/sku/messages/query.md description: | Queries the SKU from the graph. --- # Message: sku.query Queries the SKU from the graph. **Schema** --- --- url: /resources/actors/custom/ticket/messages/apply-commands.md description: > Applies a list of commands to a Ticket. You can send in several commands in one request for [batch processing](/resources/actors/#:~:text=Commands%20are%20always%20processed). However, if one command fails, the entire message fails. --- # Message: ticket.applyCommands Applies a list of commands to a Ticket. You can send in several commands in one request for [batch processing](/resources/actors/#:~:text=Commands%20are%20always%20processed). However, if one command fails, the entire message fails. **Schema** ## Relevant Rule Hook This Rule Hook is run every time an applyCommands message is sent to a Ticket: * [`OnTicketCommands`](/resources/components/runtimes/rule-hooks/onTicketCommands) ## Example: Add a Tag to a Ticket ## An improperly formed request will raise an [INVALID\_COMMAND](/resources/actors/errors/#:~:text=INVALID_COMMAND-,INVALID_COMMAND) error, while an incorrect TICKET ID or invalid URL will raise a [NOT\_FOUND](/resources/actors/errors/#:~:text=NOT_FOUND-,NOT_FOUND) error. --- --- url: /resources/actors/custom/ticket/messages/complete.md description: | Completes a ticket --- # Message: ticket.complete Completes a ticket **Schema** ## Relevant Rule Hooks This rule hook runs every time a ticket receives a complete message. * [`OnTicketComplete`](/resources/components/runtimes/rule-hooks/onTicketComplete) ## Example: Complete a Ticket ## Ensure you use the correct {ticketId} so you don't get a [NOT\_FOUND](/resources/actors/errors/#:~:text=NOT_FOUND-,NOT_FOUND) error. You can also use the [preview mode](/resources/actors/#:~:text=the%20preview%20mode.,-Messages%20sent%20as) to test the message before applying it live. --- --- url: /resources/actors/custom/ticket/messages/create.md description: | Creates a new Ticket. --- # Message: ticket.create Creates a new Ticket. **Schema** --- --- url: /resources/actors/custom/ticket/messages/query.md description: | Queries the ticket from the graph. --- # Message: ticket.query Queries the ticket from the graph. **Schema** ## Example: Query a Ticket **Response** ``` HTTP/1.1 200 OK --- { "paths": [ "resources/actors/custom/asset/{ticketId}", "resources/actors/custom/asset/return/``", "resources/actors/custom/asset/return/``" ], "data": { "query": {} } } ``` ## Using an incorrect or non-existent {ticketId} returns a [NOT\_FOUND](/resources/actors/errors/#:~:text=NOT_FOUND-,NOT_FOUND) error. --- --- url: /resources/actors/custom/ticket/messages/reject.md description: > Closes a Ticket and marks it as `rejected`. It also updates its `closedAt` property. --- # Message: ticket.reject Closes a Ticket and marks it as `rejected`. It also updates its `closedAt` property. **Schema** ## Relevant Rule Hooks This rule hook runs every time a ticket receives a reject message. * [`OnTicketRejected`](/resources/components/runtimes/rule-hooks/onTicketRejected) ## Example: Reject a Ticket --- --- url: /resources/components/runtimes/keywords/messageActor.md --- # messageActor ``` messageActor( actorType: text actorId: uuid|text messages: [{ type: text alias: text | nothing body: value }] preview: boolean | nothing ): { actorId: uuid|nothing paths: [text] data: { \`[type or alias]\`: value } errors: [Error] preview: boolean } ``` The messageActor function enables messaging [Actors](/resources/actors/) and receiving the response. Multiple messages can be sent in a single call, allowing preview of multiple messages in a single transaction. The messages are processed by the target actor in the order they are specified. ## Availability ## Message Response The return value contains information about the actor, whether the response was previewed or not, and the actual response values for each individual message (`data`) The response data from the actor is a record where each field maps to the original message type (or alias if specified). Refer to the message reference for details of each message response type. ## Error Handling Multiple errors can occur during a single batch of messages, therefor inspect the `error` field in the response. Errors that are returned by the actor itself has a detail property `fromMessage` allowing you to infer which message caused the error. Some errors, such as invalid `actorId` will result in the response `actorId` field being `nothing`. ## Examples From the Cookbook: ```filtrera from orderState match 'pending' |> messageActor ( 'order' orderId [{ type = 'applyCommands' body = { commands = [{ type = 'setOrderState' orderState = 'cancelled' }] } }] ) 'confirmed' |> 'Order is confirmed, no cancellation' 'cancelled' |> 'Order is already cancelled' |> orderState ``` --- --- url: /resources/components/runtimes/rule-effects/messageActor.md --- # messageActor Return this effect to trigger a message to another actor. ## Type ```filtrera { effect: 'messageActor' actorType: text actorId: text messageType: text body: value }|{ effect: 'messageActor' path: //?(resources/)(actors/)?[^\/]+/[^\/]+/?/i messageType: text body: value } ``` --- --- url: /resources/graph/nodes/mutation-set.md description: '' --- # mutationSet Graph Node Root Set Name: `checkpoints` --- --- url: /resources/components/runtimes/rule-hooks/onAssetBeforeCreated.md description: >- Runs before any command have been applied to an [Asset Actor](/resources/actors/custom/asset/) from the `create` message --- # OnAssetBeforeCreated This hook runs before any command have been applied to an [Asset Actor](/resources/actors/custom/asset/) from the [`create`](/resources/actors/custom/asset/messages/create) message. Commands returned will be applied to the asset before the [`create`](/resources/actors/custom/asset/messages/create) message's commands. ## Type ```filtrera { hook: 'OnAssetBeforeCreated' asset: Asset } ``` ## Effects --- --- url: /resources/components/runtimes/rule-hooks/onAssetBeforeDeleted.md description: 'Runs before an [Asset Actor](/resources/actors/custom/asset/) is deleted' --- # OnAssetBeforeDeleted This hook runs before an [Asset Actor](/resources/actors/custom/asset/) is deleted. It can be used to perform tasks or even block deletion using a [`validationError`](/resources/components/runtimes/rule-effects/validationError) effect. ## Type ```filtrera { hook: 'OnAssetBeforeDeleted' asset: Asset } ``` ## Effects --- --- url: /resources/components/runtimes/rule-hooks/onAssetCommands.md --- # OnAssetCommands This hook runs after commands have been applied to an [Asset Actor](/resources/actors/custom/asset/) from the [`create`](/resources/actors/custom/asset/messages/create) or [`applyCommands`](/resources/actors/custom/asset/messages/apply-commands) message. Commands returned from rules does not trigger the hook again. For invariants that can only be checked **after** every `OnAssetCommands` rule has run and its commands have been applied — for example rules that depend on data set by other rules — use the [OnAssetValidate](/resources/components/runtimes/rule-hooks/onAssetValidate) hook instead. ## Type ```filtrera { hook: 'OnAssetCommands' before: Asset // The asset before the commands were applied asset: Asset // The asset after the commands were applied } ``` ## Effects --- --- url: /resources/components/runtimes/rule-hooks/onAssetCreated.md --- # OnAssetCreated This hook runs after commands have been applied to an [Asset Actor](/resources/actors/custom/asset/) from the [`create`](/resources/actors/custom/asset/messages/create) message, but before the [OnAssetCommands](/resources/components/runtimes/rule-hooks/onAssetCommands) hook. Commands returned will be applied to the asset and changes are available to the the [OnAssetCommands](/resources/components/runtimes/rule-hooks/onAssetCommands) hook. ## Type ```filtrera { hook: 'OnAssetCreated' asset: Asset } ``` ## Effects --- --- url: /resources/components/runtimes/rule-hooks/onAssetDeleted.md description: 'Runs after an [Asset Actor](/resources/actors/custom/asset/) has been deleted' --- # OnAssetDeleted This hook runs after an [Asset Actor](/resources/actors/custom/asset/) has been deleted. ## Type ```filtrera { hook: 'OnAssetDeleted' asset: Asset // State of the asset before deletion } ``` ## Effects --- --- url: /resources/components/runtimes/rule-hooks/onAssetValidate.md --- # OnAssetValidate This hook runs immediately after the [OnAssetCommands](/resources/components/runtimes/rule-hooks/onAssetCommands) hook and any commands it returned have been applied to the [Asset Actor](/resources/actors/custom/asset/). It is the place to enforce invariants that can only be checked once all `OnAssetCommands` rules have settled, and to either reject the asset with a [`validationError`](/resources/components/runtimes/rule-effects/validationError) effect or auto-fix it by emitting more [`assetCommand`](/resources/components/runtimes/rule-effects/assetCommand) effects. Commands returned from this hook are applied to the asset, but do **not** re-trigger [OnAssetCommands](/resources/components/runtimes/rule-hooks/onAssetCommands) or `OnAssetValidate`. The hook runs once per message. ## Type ```filtrera { hook: 'OnAssetValidate' before: Asset // The asset before the incoming message commands were applied asset: Asset // The asset after OnAssetCommands has run } ``` ## Effects --- --- url: /official-apps/shipping/nshift-checkout/shipping-option-tax-hook.md --- # `OnNShiftCheckoutShippingOptionTax` Hook Shipping options returned by nShift carry a price but not always a tax rate. The nShift Checkout app needs a tax amount (or factor) per option so the delivery's `shippingTax` lands correctly when an option is selected. The app resolves this per option from several sources. The `OnNShiftCheckoutShippingOptionTax` hook lets your rules compute custom shipping tax — for example a flat rate for a tax jurisdiction that nShift doesn't price, or a carrier-specific override. ## When it fires The hook fires **once per call to `getOptions`** (not once per option), right after options are fetched from nShift and before they're persisted on the session ticket. Firing once lets a listener that wants to price several options do it with a single registry or graph traversal. The hook only fires when the caller passes a `context` to `getOptions`. The portal's **Select shipping product** action always supplies one; a bare module caller that passes `context = nothing` skips the hook entirely (see [Shipping Module — Context](./shipping-module#context)). ## Resolution order For each option, the app picks the effective tax from the first source that yields a value: 1. **nShift's own `taxRate`** (when present on the option) — used as a `shippingTaxFactor`. 2. **This hook** — a listener emission for that `optionId`. An emitted `shippingTax` (absolute) wins over an emitted `shippingTaxFactor`. 3. **Line-derived default** — `shippingTaxFactor = max(taxFactor)` across the context's order lines. This matches European VAT markets where shipping inherits the highest line tax rate. Skipped when no line has a positive `taxFactor`. 4. **Otherwise** — both `shippingTax` and `shippingTaxFactor` stay `nothing` and no shipping tax is set on the delivery. Because nShift's own rate takes precedence, your hook only changes the outcome for options where nShift returned no `taxRate`. ## Listening to the hook Declare a rule with a `param input` that matches `OnNShiftCheckoutShippingOptionTax`. The input carries the same delivery-rooted context as [`OnNShiftCheckoutVariables`](./variables-hook) plus the freshly fetched options. Only declare the fields your rule reads. ```filtrera param input: { hook: 'OnNShiftCheckoutShippingOptionTax' delivery: { deliveryId: uuid | nothing deliveryAddress: { countryCode: text | nothing postalCode: text | nothing state: text | nothing } dynamic: { text -> value } lines: [{ orderLineId: uuid | nothing productNumber: text | nothing quantity: number dynamic: { text -> value } taxFactor: number | nothing }] order: { orderId: uuid | nothing channelKey: text currencyCode: text locale: text | nothing dynamic: { text -> value } } } options: [{ optionId: uuid carrierId: text carrierProductId: text name: text price: number nShiftTaxRate: number | nothing }] } ``` The `delivery` branch is identical to the [variables hook context](./variables-hook#listening-to-the-hook) — same caveats about `deliveryId` / `order.orderId` being `uuid | nothing`. | `options[]` field | Description | | ------------------ | ------------------------------------------------------------------------------------------------- | | `optionId` | The option's id. Echo it back on each effect so the app can map your value to the right option. | | `carrierId` | nShift carrier id — useful for carrier-specific rules. | | `carrierProductId` | nShift carrier product id within the carrier. | | `name` | Display name of the option. | | `price` | The option's price, in the request's currency — multiply by your factor to compute an amount. | | `nShiftTaxRate` | nShift's own tax rate for the option, when present. If set, it already takes precedence over your emission for that option. | ## Emitting tax Emit one effect per option you want to set tax for. Two effect types are accepted: ```filtrera // Absolute amount in the request's currency from { effect = 'custom' type = 'shippingTax' optionId = value = 39 } // Or a factor (e.g. 0.25 == 25%) from { effect = 'custom' type = 'shippingTaxFactor' optionId = value = 0.25 } ``` | Field | Description | | ---------- | ------------------------------------------------------------------------------------------------- | | `effect` | Must be `'custom'`. | | `type` | `'shippingTax'` (absolute amount) or `'shippingTaxFactor'` (factor in `[0, 1]`). Other types are ignored. | | `optionId` | The `optionId` from the matching `input.options[]` entry. | | `value` | The amount or factor. Must be a `number`. | For the same `optionId`, an emitted `shippingTax` wins over a `shippingTaxFactor`. Within a single type, the **last** listener emission wins. Options you don't emit for fall through to the line-derived default. ## Example: flat US shipping tax `apps.nshift-checkout`'s default is `max(line.taxFactor)`, appropriate for European VAT markets. For US destinations we want a flat factor instead, applied to every option. ```filtrera import 'iterators' param input: { hook: 'OnNShiftCheckoutShippingOptionTax' delivery: { deliveryAddress: { countryCode: text | nothing } } options: [{ optionId: uuid }] } let isUs = input.delivery.deliveryAddress.countryCode match 'US' |> true |> false from isUs match true |> input.options select o => { effect = 'custom' type = 'shippingTaxFactor' optionId = o.optionId value = 0.08 } buffer |> [] ``` ## Example: carrier-specific absolute amount Override a single carrier's options with a fixed tax amount, leaving the rest to fall through to the default. ```filtrera import 'iterators' param input: { hook: 'OnNShiftCheckoutShippingOptionTax' options: [{ optionId: uuid carrierId: text price: number }] } from input.options where o => o.carrierId == 'postnord' select o => { effect = 'custom' type = 'shippingTax' optionId = o.optionId value = o.price * 0.25 } buffer ``` ## See Also * [nShift Checkout overview](./) — Set up the app and configure channels. * [Shipping Module reference](./shipping-module) — `getOptions`, the `context` parameter, and the resolved `shippingTax` / `shippingTaxFactor` fields. * [`OnNShiftCheckoutVariables` Hook](./variables-hook) — Customize the request with merchant rules from the same context. * [Custom Hooks](/resources/rules/trigger-hook) — How custom hooks work. --- --- url: /resources/components/runtimes/rule-hooks/onOrderBeforeCreated.md --- # OnOrderBeforeCreated This hook runs before any command have been applied to an [Order Actor](/resources/actors/order/) from the [`create`](/resources/actors/order/messages/create) message. Commands returned will be applied to the order before the [`create`](/resources/actors/order/messages/create) message's commands. The `order` field in the `input` will contain only basic information, such as `orderId`, `currencyCode`. `taxIncluded` and `createdAt`. Note that `orderNumber` is not yet set, and `generateOrderNumberByPrefix` command can be used to set a different the order number serie than the default. ## Type ```filtrera { hook: 'OnOrderBeforeCreated' order: Order } ``` ## Effects ## Examples ### Custom Order Number Prefix ```filtrera param input: OnOrderBeforeCreated from effects.order.command.generateOrderNumberByPrefix { prefix = 'CUSTOM' } ``` --- --- url: /resources/components/runtimes/rule-hooks/onOrderBeforeDeleted.md description: 'Runs before a [Order Actor](/resources/actors/order/) is deleted' --- # OnOrderBeforeDeleted This hook runs before an [Order Actor](/resources/actors/order/) is deleted. It can be used to perform tasks or even block deletion using a [`validationError`](/resources/components/runtimes/rule-effects/validationError) effect. ## Type ```filtrera { hook: 'OnOrderBeforeDeleted' order: Order } ``` ## Effects --- --- url: /resources/components/runtimes/rule-hooks/onOrderCalculate.md --- # OnOrderCalculate This hook runs after the [OnOrderCommands](/resources/components/runtimes/rule-hooks/onOrderCommands) hook and any commands it returned have been applied to the [Order Actor](/resources/actors/order/), and before the [OnOrderValidate](/resources/components/runtimes/rule-hooks/onOrderValidate) hook. It is intended for derived calculations that depend on the enriched state produced by `OnOrderCommands` rules — for example calculating tax after another rule has enriched order line prices. Its output is in turn visible to `OnOrderValidate` rules. Commands returned from this hook are applied to the order, but do **not** re-trigger [OnOrderCommands](/resources/components/runtimes/rule-hooks/onOrderCommands), `OnOrderCalculate` or `OnOrderValidate`. The hook runs once per message. ## Type ```filtrera { hook: 'OnOrderCalculate' before: Order // The order before the incoming message commands were applied order: Order // The order after OnOrderCommands has run } ``` ## Effects --- --- url: /resources/components/runtimes/rule-hooks/onOrderCommands.md --- # OnOrderCommands This hook runs after commands have been applied to an [Order Actor](/resources/actors/order/) from the [`create`](/resources/actors/order/messages/create) or [`applyCommands`](/resources/actors/order/messages/apply-commands) message. Commands returned from rules does not trigger the hook again. For derived calculations that depend on the enriched state produced by every `OnOrderCommands` rule — for example tax calculation after price enrichment — use the [OnOrderCalculate](/resources/components/runtimes/rule-hooks/onOrderCalculate) hook, which runs immediately after this one. For invariants that can only be checked **after** every `OnOrderCommands` rule has run and its commands have been applied — for example rules that depend on data set by other rules — use the [OnOrderValidate](/resources/components/runtimes/rule-hooks/onOrderValidate) hook instead. ## Type ```filtrera { hook: 'OnOrderCommands' before: Order // The order before the commands were applied order: Order // The order after the commands were applied } ``` ## Effects --- --- url: /resources/components/runtimes/rule-hooks/onOrderCreated.md --- # OnOrderCreated This hook runs after commands have been applied to an [Order Actor](/resources/actors/order/) from the [`create`](/resources/actors/order/messages/create) message, but before the [OnOrderCommands](/resources/components/runtimes/rule-hooks/onOrderCommands) hook. Commands returned will be applied to the order and changes are available to the the [OnOrderCommands](/resources/components/runtimes/rule-hooks/onOrderCommands) hook. ## Type ```filtrera { hook: 'OnOrderCreated' order: Order } ``` ## Effects --- --- url: /resources/components/runtimes/rule-hooks/onOrderDeleted.md description: 'Runs after an [Order Actor](/resources/actors/order/) has been deleted' --- # OnOrderDeleted This hook runs after an [Order Actor](/resources/actors/order/) has been deleted. ## Type ```filtrera { hook: 'OnOrderDeleted' order: Order // State of the order before deletion } ``` ## Effects --- --- url: /resources/components/runtimes/rule-hooks/onOrderInvoiceCancelled.md --- # OnOrderInvoiceCancelled This hook runs after an actor message has transitioned one or more of the order's invoices to cancelled. It is the right place to react to *reversed* invoicing activity, such as voiding the corresponding document in an external accounting system or notifying the customer that an invoice no longer applies. An invoice is cancelled either by the [`cancelInvoice`](/resources/components/runtimes/rule-effects/orderCommand) order command, or by [rewinding](/resources/actors/checkpoints) the order to a checkpoint that predates the invoice. Because invoices are never removed from an order — only cancelled — both paths produce the same transition and fire this hook. Apps therefore don't need to know anything about checkpoints to stay consistent with the order. ## Firing semantics The hook fires **once per actor message** that cancelled invoices, regardless of how many were cancelled during that message. The `invoices` field carries only the invoices whose `isCancelled` flipped `false` → `true` in this message; invoices that were already cancelled before the message never fire again. All invoices on the order, cancelled or not, can be accessed through `order.invoices`. State is already committed when this hook runs, so it cannot change the order. Returning [`orderCommand`](/resources/components/runtimes/rule-effects/orderCommand) or [`validationError`](/resources/components/runtimes/rule-effects/validationError) effects has no effect and produces a warning in the application logs. ::: warning Re-activation is not a creation Rewinding *forward* — to a checkpoint after an earlier rewind — can flip an invoice back to `isCancelled = false`. This re-activation fires neither `OnOrderInvoiceCancelled` nor [`OnOrderInvoiceCreated`](/resources/components/runtimes/rule-hooks/onOrderInvoiceCreated). See the note on the [`OnOrderInvoiceCreated`](/resources/components/runtimes/rule-hooks/onOrderInvoiceCreated) page. ::: ## Type ```filtrera { hook: 'OnOrderInvoiceCancelled' order: Order // The order, including the cancelled invoices invoices: [Invoice] // Only the invoices that were cancelled in this message } ``` ## Effects --- --- url: /resources/components/runtimes/rule-hooks/onOrderInvoiceCreated.md --- # OnOrderInvoiceCreated This hook runs after an actor message (either `create` or `applyCommands`) has produced one or more new invoices on the order — for example via the [`invoice`](/resources/components/runtimes/rule-effects/orderCommand) order command. It is the right place to react to *new* invoicing activity, such as capturing payments, sending the customer a copy of the invoice, or pushing it to an external accounting system. ## Firing semantics The hook fires **once per actor message** that resulted in new invoices, regardless of how many invoices were created during that message. The `invoices` field carries only the *new* invoices for this message; previously-existing invoices (incl. the new ones) can all be accessed through `order.invoices`. ::: warning Rewind re-activation does not fire this hook Invoices are never removed from an order — [rewinding](/resources/actors/checkpoints) to a checkpoint that predates an invoice cancels it, which fires [`OnOrderInvoiceCancelled`](/resources/components/runtimes/rule-hooks/onOrderInvoiceCancelled). Rewinding *forward* again, to a checkpoint after that rewind, flips the invoice back to `isCancelled = false`. That re-activation deliberately does **not** fire `OnOrderInvoiceCreated`. The invoice was never re-created: it keeps its original `invoiceId` and `invoiceNumber`, so treating it as new would make apps create the same external document twice. ::: ## Type ```filtrera { hook: 'OnOrderInvoiceCreated' order: Order // The order, including the newly created invoices invoices: [Invoice] // Only the invoices that were created in this message } ``` ## Effects --- --- url: /resources/components/runtimes/rule-hooks/onOrderJournal.md --- # OnOrderJournal This hook runs after any order journal entries have been created. ## Type ```filtrera { hook: 'OnOrderJournal' order: Order // The order, including the newly added journal entries entries: [OrderJournalEntry] // The journal entries that were added } ``` ## Effects --- --- url: /resources/components/runtimes/rule-hooks/onOrderValidate.md --- # OnOrderValidate This hook runs immediately after the [OnOrderCalculate](/resources/components/runtimes/rule-hooks/onOrderCalculate) hook — which itself runs after [OnOrderCommands](/resources/components/runtimes/rule-hooks/onOrderCommands) — and any commands those hooks returned have been applied to the [Order Actor](/resources/actors/order/). It is the place to enforce invariants that can only be checked once all `OnOrderCommands` and `OnOrderCalculate` rules have settled, and to either reject the order with a [`validationError`](/resources/components/runtimes/rule-effects/validationError) effect or auto-fix it by emitting more [`orderCommand`](/resources/components/runtimes/rule-effects/orderCommand) effects. Commands returned from this hook are applied to the order, but do **not** re-trigger [OnOrderCommands](/resources/components/runtimes/rule-hooks/onOrderCommands), `OnOrderCalculate` or `OnOrderValidate`. The hook runs once per message. ## Type ```filtrera { hook: 'OnOrderValidate' before: Order // The order before the incoming message commands were applied order: Order // The order after OnOrderCommands has run } ``` ## Effects --- --- url: /resources/components/runtimes/rule-hooks/onPaymentBeforeCreated.md --- # OnPaymentBeforeCreated This hook runs before any command have been applied to a [Payment Actor](/resources/actors/payment/) from the [`create`](/resources/actors/payment/messages/create) message. Commands returned will be applied to the payment before the [`create`](/resources/actors/payment/messages/create) message's commands. ## Type ```filtrera { hook: 'OnPaymentBeforeCreated' payment: Payment } ``` ## Effects --- --- url: /resources/components/runtimes/rule-hooks/onPaymentBeforeDeleted.md description: 'Runs before a [Payment Actor](/resources/actors/payment/) is deleted' --- # OnPaymentBeforeDeleted This hook runs before a [Payment Actor](/resources/actors/payment/) is deleted. It can be used to perform tasks or even block deletion using a [`validationError`](/resources/components/runtimes/rule-effects/validationError) effect. ## Type ```filtrera { hook: 'OnPaymentBeforeDeleted' payment: Payment } ``` ## Effects --- --- url: /resources/components/runtimes/rule-hooks/onPaymentCapture.md --- # OnPaymentCapture This hook runs after a payment capture has been created or updated. A capture can update if an existing pending capture exists and a new charge is made on the payment to cover for the capture. ## Type ```filtrera { hook: 'OnPaymentCapture' payment: Payment // The payment, including the added/updated capture capture: PaymentCapture // The capture that was added/updated } ``` ## Effects --- --- url: /resources/components/runtimes/rule-hooks/onPaymentCommands.md --- # OnPaymentCommands This hook runs after commands have been applied to a [Payment Actor](/resources/actors/payment/) from the [`create`](/resources/actors/payment/messages/create) or [`applyCommands`](/resources/actors/payment/messages/apply-commands) message. Commands returned from rules does not trigger the hook again. For invariants that can only be checked **after** every `OnPaymentCommands` rule has run and its commands have been applied — for example rules that depend on data set by other rules — use the [OnPaymentValidate](/resources/components/runtimes/rule-hooks/onPaymentValidate) hook instead. ## Type ```filtrera { hook: 'OnPaymentCommands' before: Payment // The payment before the commands were applied payment: Payment // The payment after the commands were applied } ``` ## Effects --- --- url: /resources/components/runtimes/rule-hooks/onPaymentCreated.md --- # OnPaymentCreated This hook runs after commands have been applied to a [Payment Actor](/resources/actors/payment/) from the [`create`](/resources/actors/payment/messages/create) message, but before the [OnPaymentCommands](/resources/components/runtimes/rule-hooks/onPaymentCommands) hook. Commands returned will be applied to the payment and changes are available to the the [OnPaymentCommands](/resources/components/runtimes/rule-hooks/onPaymentCommands) hook. ## Type ```filtrera { hook: 'OnPaymentCreated' payment: Payment } ``` ## Effects --- --- url: /resources/components/runtimes/rule-hooks/onPaymentDeleted.md description: 'Runs after a [Payment Actor](/resources/actors/payment/) has been deleted' --- # OnPaymentDeleted This hook runs after a [Payment Actor](/resources/actors/payment/) has been deleted. ## Type ```filtrera { hook: 'OnPaymentDeleted' payment: Payment // State of the payment before deletion } ``` ## Effects --- --- url: /resources/components/runtimes/rule-hooks/onPaymentJournal.md --- # OnPaymentJournal This hook runs after any payment journal entries have been created. ## Type ```filtrera { hook: 'OnPaymentJournal' payment: Payment // The payment, including the newly added journal entries entries: [PaymentJournalEntry] // The journal entries that were added } ``` ## Effects --- --- url: /resources/components/runtimes/rule-hooks/onPaymentValidate.md --- # OnPaymentValidate This hook runs immediately after the [OnPaymentCommands](/resources/components/runtimes/rule-hooks/onPaymentCommands) hook and any commands it returned have been applied to the [Payment Actor](/resources/actors/payment/). It is the place to enforce invariants that can only be checked once all `OnPaymentCommands` rules have settled, and to either reject the payment with a [`validationError`](/resources/components/runtimes/rule-effects/validationError) effect or auto-fix it by emitting more [`paymentCommand`](/resources/components/runtimes/rule-effects/paymentCommand) effects. Commands returned from this hook are applied to the payment, but do **not** re-trigger [OnPaymentCommands](/resources/components/runtimes/rule-hooks/onPaymentCommands) or `OnPaymentValidate`. The hook runs once per message. ## Type ```filtrera { hook: 'OnPaymentValidate' before: Payment // The payment before the incoming message commands were applied payment: Payment // The payment after OnPaymentCommands has run } ``` ## Effects --- --- url: /resources/components/runtimes/rule-hooks/onRuleCreated.md description: Runs after a rule has been created --- # OnRuleCreated This hook runs after a rule has been created. ## Type ```filtrera { hook: 'OnRuleCreated' rule: { ruleId: text label: text | { text -> text } | nothing activeFrom: instant activeTo: instant components: [{ componentId: text parameters: { text -> value } }] } } ``` ## Effects --- --- url: /resources/components/runtimes/rule-hooks/onRuleDeleted.md description: Runs after a rule has been deleted --- # OnRuleDeleted This hook runs after a rule has been deleted. ## Type ```filtrera { hook: 'OnRuleDeleted' rule: { ruleId: text label: text | { text -> text } | nothing activeFrom: instant activeTo: instant components: [{ componentId: text parameters: { text -> value } }] } } ``` ## Effects --- --- url: /resources/components/runtimes/rule-hooks/onRuleUpdated.md description: Runs after a rule has been updated --- # OnRuleUpdated This hook runs after a rule has been updated. ## Type ```filtrera { hook: 'OnRuleUpdated' rule: { ruleId: text label: text | { text -> text } | nothing activeFrom: instant activeTo: instant components: [{ componentId: text parameters: { text -> value } }] } before: { ruleId: text label: text | { text -> text } | nothing activeFrom: instant activeTo: instant components: [{ componentId: text parameters: { text -> value } }] } } ``` ## Effects --- --- url: /resources/components/runtimes/rule-hooks/onSkuBeforeCreated.md --- # OnSkuBeforeCreated This hook runs before any command have been applied to a [Sku Actor](/resources/actors/sku/) from the [`create`](/resources/actors/sku/messages/create) message. Commands returned will be applied to the sku before the [`create`](/resources/actors/sku/messages/create) message's commands. ## Type ```filtrera { hook: 'OnSkuBeforeCreated' sku: Sku } ``` ## Effects --- --- url: /resources/components/runtimes/rule-hooks/onSkuBeforeDeleted.md description: 'Runs before a [Sku Actor](/resources/actors/sku/) is deleted' --- # OnSkuBeforeDeleted This hook runs before a [Sku Actor](/resources/actors/sku/) is deleted. It can be used to perform tasks or even block deletion using a [`validationError`](/resources/components/runtimes/rule-effects/validationError) effect. ## Type ```filtrera { hook: 'OnSkuBeforeDeleted' sku: Sku } ``` ## Effects --- --- url: /resources/components/runtimes/rule-hooks/onSkuCommands.md --- # OnSkuCommands This hook runs after commands have been applied to a [Sku Actor](/resources/actors/sku/) from the [`create`](/resources/actors/sku/messages/create) or [`applyCommands`](/resources/actors/sku/messages/apply-commands) message. Commands returned from rules does not trigger the hook again. For invariants that can only be checked **after** every `OnSkuCommands` rule has run and its commands have been applied — for example rules that depend on data set by other rules — use the [OnSkuValidate](/resources/components/runtimes/rule-hooks/onSkuValidate) hook instead. ## Type ```filtrera { hook: 'OnSkuCommands' before: Sku // The sku before the commands were applied sku: Sku // The sku after the commands were applied } ``` ## Effects --- --- url: /resources/components/runtimes/rule-hooks/onSkuCreated.md --- # OnSkuCreated This hook runs after commands have been applied to a [Sku Actor](/resources/actors/sku/) from the [`create`](/resources/actors/sku/messages/create) message, but before the [OnSkuCommands](/resources/components/runtimes/rule-hooks/onSkuCommands) hook. Commands returned will be applied to the sku and changes are available to the the [OnSkuCommands](/resources/components/runtimes/rule-hooks/onSkuCommands) hook. ## Type ```filtrera { hook: 'OnSkuCreated' sku: Sku } ``` ## Effects --- --- url: /resources/components/runtimes/rule-hooks/onSkuDeleted.md description: 'Runs after a [Sku Actor](/resources/actors/sku/) has been deleted' --- # OnSkuDeleted This hook runs after a [Sku Actor](/resources/actors/sku/) has been deleted. ## Type ```filtrera { hook: 'OnSkuDeleted' sku: Sku // State of the sku before deletion } ``` ## Effects --- --- url: /resources/components/runtimes/rule-hooks/onSkuValidate.md --- # OnSkuValidate This hook runs immediately after the [OnSkuCommands](/resources/components/runtimes/rule-hooks/onSkuCommands) hook and any commands it returned have been applied to the [Sku Actor](/resources/actors/sku/). It is the place to enforce invariants that can only be checked once all `OnSkuCommands` rules have settled, and to either reject the sku with a [`validationError`](/resources/components/runtimes/rule-effects/validationError) effect or auto-fix it by emitting more [`skuCommand`](/resources/components/runtimes/rule-effects/skuCommand) effects. Commands returned from this hook are applied to the sku, but do **not** re-trigger [OnSkuCommands](/resources/components/runtimes/rule-hooks/onSkuCommands) or `OnSkuValidate`. The hook runs once per message. ## Type ```filtrera { hook: 'OnSkuValidate' before: Sku // The sku before the incoming message commands were applied sku: Sku // The sku after OnSkuCommands has run } ``` ## Effects --- --- url: /resources/components/runtimes/rule-hooks/onTicketBeforeCreated.md description: >- Runs before any command have been applied to a [Ticket Actor](/resources/actors/custom/ticket/) from the [`create`](/resources/actors/custom/ticket/messages/create) message --- # OnTicketBeforeCreated This hook runs before any command have been applied to a [Ticket Actor](/resources/actors/custom/ticket/) from the [`create`](/resources/actors/custom/ticket/messages/create) message. Commands returned will be applied to the ticket before the [`create`](/resources/actors/custom/ticket/messages/create) message's commands. The `ticket` field in the `input` will contain only basic information, such as `ticketId` and `createdAt`. Note that `ticketNumber` is not yet set, and `generateTicketNumberByPrefix` command can be used to set a different the order number serie than the default. ## Type ```filtrera { hook: 'OnTicketBeforeCreated' ticket: Ticket } ``` ## Effects ## Examples ### Custom Order Number Prefix ```filtrera param input: OnTicketBeforeCreated from effects.ticket.command.generateTicketNumberByPrefix { prefix = 'CUSTOM' } ``` --- --- url: /resources/components/runtimes/rule-hooks/onTicketBeforeDeleted.md description: 'Runs before an [Ticket Actor](/resources/actors/custom/ticket/) is deleted' --- # OnTicketBeforeDeleted This hook runs before a [Ticket Actor](/resources/actors/custom/ticket/) is deleted. It can be used to perform tasks or even block deletion using a [`validationError`](/resources/components/runtimes/rule-effects/validationError) effect. ## Type ```filtrera { hook: 'OnTicketBeforeDeleted' ticket: Ticket } ``` ## Effects --- --- url: /resources/components/runtimes/rule-hooks/onTicketCommands.md description: >- Runs after commands have been applied to a [Ticket Actor](/resources/actors/custom/ticket/) from the [`create`](/resources/actors/custom/ticket/messages/create) or [`applyCommands`](/resources/actors/custom/ticket/messages/apply-commands) message --- # OnTicketCommands This hook runs after commands have been applied to a [Ticket Actor](/resources/actors/custom/ticket/) from the [`create`](/resources/actors/custom/ticket/messages/create) or [`applyCommands`](/resources/actors/custom/ticket/messages/apply-commands) message. Commands returned from rules does not trigger the hook again. For invariants that can only be checked **after** every `OnTicketCommands` rule has run and its commands have been applied — for example rules that depend on data set by other rules — use the [OnTicketValidate](/resources/components/runtimes/rule-hooks/onTicketValidate) hook instead. ## Type ```filtrera { hook: 'OnTicketCommands' before: Ticket // The ticket before the commands were applied ticket: Ticket // The ticket after the commands were applied } ``` ## Effects --- --- url: /resources/components/runtimes/rule-hooks/onTicketComplete.md description: >- Runs during the completion of a [Ticket Actor](/resources/actors/custom/ticket/) --- # OnTicketComplete This hook runs during the completion of a [Ticket Actor](/resources/actors/custom/ticket/). `sendMessage` effects can be returned to cause side-effects in other actors. `validationError` may be used to block completion of the ticket. ## Type ```filtrera { hook: 'OnTicketComplete' ticket: Ticket } ``` ## Effects ::: warning sendMessage Behavior In most hooks, `sendMessage` effects are optimistically applied, meaning errors and responses are ignored. In the case of `OnTicketComplete` messages are sent and checked for returned errors before **any** message is applied. This is done by the system previewing all messages before actually sending them. If any message results in an error, the ticket completion is aborted and the error is returned. ::: --- --- url: /resources/components/runtimes/rule-hooks/onTicketCreated.md description: >- Runs after commands have been applied to a [Ticket Actor](/resources/actors/custom/ticket/) from the [`create`](/resources/actors/custom/ticket/messages/create) message, but before the [OnTicketCommands](/resources/components/runtimes/rule-hooks/onTicketCommands) hook --- # OnTicketCreated This hook runs after commands have been applied to a [Ticket Actor](/resources/actors/custom/ticket/) from the [`create`](/resources/actors/custom/ticket/messages/create) message, but before the [OnTicketCommands](/resources/components/runtimes/rule-hooks/onTicketCommands) hook. Commands returned will be applied to the order and changes are available to the the [OnTicketCommands](/resources/components/runtimes/rule-hooks/onTicketCommands) hook. ## Type ```filtrera { hook: 'OnTicketCreated' ticket: Ticket } ``` ## Effects --- --- url: /resources/components/runtimes/rule-hooks/onTicketDeleted.md description: 'Runs after a [Ticket Actor](/resources/actors/custom/ticket/) has been deleted' --- # OnTicketDeleted This hook runs after a [Ticket Actor](/resources/actors/custom/ticket/) has been deleted. ## Type ```filtrera { hook: 'OnTicketDeleted' ticket: Ticket // State of the ticket before deletion } ``` ## Effects --- --- url: /resources/components/runtimes/rule-hooks/onTicketRejected.md description: >- Runs after a [Ticket Actor](/resources/actors/custom/ticket/) has been rejected --- # OnTicketRejected This hook runs after a [Ticket Actor](/resources/actors/custom/ticket/) has been rejected. `validationError` can be used to block a rejection. ## Type ```filtrera { hook: 'OnTicketRejected' ticket: Ticket } ``` ## Effects --- --- url: /resources/components/runtimes/rule-hooks/onTicketValidate.md --- # OnTicketValidate This hook runs immediately after the [OnTicketCommands](/resources/components/runtimes/rule-hooks/onTicketCommands) hook and any commands it returned have been applied to the [Ticket Actor](/resources/actors/custom/ticket/). It is the place to enforce invariants that can only be checked once all `OnTicketCommands` rules have settled, and to either reject the ticket with a [`validationError`](/resources/components/runtimes/rule-effects/validationError) effect or auto-fix it by emitting more [`ticketCommand`](/resources/components/runtimes/rule-effects/ticketCommand) effects. Commands returned from this hook are applied to the ticket, but do **not** re-trigger [OnTicketCommands](/resources/components/runtimes/rule-hooks/onTicketCommands) or `OnTicketValidate`. The hook runs once per message. ## Type ```filtrera { hook: 'OnTicketValidate' before: Ticket // The ticket before the incoming message commands were applied ticket: Ticket // The ticket after OnTicketCommands has run } ``` ## Effects --- --- url: /resources/components/runtimes/keywords/order.md --- # order The `order` keyword provides access to the current order state within a [promotion](/resources/actors/order/promotions) component. It returns the full [`Order`](/resources/components/runtimes/types/order) record, including deliveries, order lines, shipping, and dynamic fields. `order` is a symbol — use it directly without parentheses. ## Availability ## Examples #### Check order total ```filtrera from order.total >= 500 match true |> percentage(order, 10%) false |> nothing ``` #### Access order lines ```filtrera let electronicLines = order.deliveries select d => d.orderLines flatten where line => line.dynamic->'category' == 'electronics' from electronicLines count > 0 match true |> percentage(target(e => e is OrderLine and e.dynamic->'category' == 'electronics'), 15%) false |> nothing ``` #### Use as discount target The `order` symbol can be passed directly to [`percentage`](/resources/components/runtimes/keywords/percentage) or [`absolute`](/resources/components/runtimes/keywords/absolute) as a target that covers all order lines and shipping: ```filtrera from percentage(order, 5%) ``` ## See Also * [Order Promotions](/resources/actors/order/promotions) — how promotions work * [Order Type](/resources/components/runtimes/types/order) — full Order type reference --- --- url: /resources/components/runtimes/types/order.md --- # Order ## Definition ```filtrera let Order: { channelKey: nothing|text createdAt: instant currencyCode: text deliveries: [{ createdAt: instant deliveryAddress: { addressLine1?: nothing|text addressLine2?: nothing|text attention?: nothing|text careOf?: nothing|text city?: nothing|text countryCode?: nothing|text email?: nothing|text name?: nothing|text phone?: nothing|text postalCode?: nothing|text state?: nothing|text } deliveryId: uuid deliveryNumber: text deliveryState: 'open'|'processing'|'completed'|'cancelled'|'cancelledByOrder' dynamic: {text->value} finalizedAt: nothing|instant orderLines: [{ description: text|nothing dynamic: {text->value} image: text|nothing orderLineId: uuid orderLineNumber: text orderLineTotal: number productNumber: text quantity: number releasedToInvoice: boolean returnedQuantity: number skus: [{ orderLineSkuId: uuid reservedQuantity: number skuNumber: text totalQuantity: number totalReturnedQuantity: number unitQuantity: number }] taxFactor: nothing|number taxTotal: number unitPrice: number }] releasedToInvoice: boolean shippingDescription: nothing|text shippingPrice: number shippingProductNumber: nothing|text shippingTax: number shippingTaxFactor: nothing|number shippingTotal: number tags: [text] }] dynamic: {text->value} invoiceRecipient: { addressLine1: text addressLine2: text attention: text careOf: text city: text countryCode: text email: text name: text phone: text postalCode: text state: text taxCountryCode: nothing|text taxId: nothing|text taxIdType: nothing|text } invoices: [{ capturedTotal: number createdAt: instant invoiceId: uuid invoiceLines: [{ description: nothing|text discounts: [{ amount: number description: nothing|text invoiceLineDiscountId: uuid referenceId: uuid referenceType: 'staticDiscount'|'promotion' }] invoiceLineId: uuid invoiceLineNumber: number net: number originalInvoiceLineId: nothing|uuid productNumber: nothing|text quantity: nothing|number referenceId: uuid referenceType: 'none'|'orderLine'|'return'|'delivery' tax: number total: number }] invoiceNetTotal: number invoiceNumber: text invoiceRecipient: { addressLine1: text addressLine2: text attention: text careOf: text city: text countryCode: text email: text name: text phone: text postalCode: text state: text taxCountryCode: nothing|text taxId: nothing|text taxIdType: nothing|text } invoiceTaxTotal: number invoiceTotal: number isCancelled: boolean }] journalEntries: [{ amount: number invoiceId: uuid|nothing orderJournalEntryId: uuid orderJournalEntryType: 'receivable'|'capture'|'clearing'|'fee' paymentId: uuid|nothing timestamp: instant }] locale: nothing|text orderId: uuid orderNumber: text orderState: 'pending'|'confirmed'|'cancelled' orderTaxTotal: number orderTotal: number promotions: [{ componentId: nothing|text description: nothing|text dynamic: {text->value} parameters: {text->value} promotionGroup: text promotionId: uuid }] tags: [text] taxIncluded: boolean } ``` ## Availability --- --- url: /resources/graph/nodes/order.md description: '' --- # order Graph Node Root Set Name: `orders` --- --- url: /resources/components/runtimes/rule-effects/orderCommand.md --- # orderCommand Return this effect to apply an order command from hooks that supports it. ## Type ```filtrera { effect: 'orderCommand' type: text // Command type // Additional command properties are added here } ``` ## Order Commands --- --- url: /resources/components/runtimes/types/order-journal-entry.md --- # OrderJournalEntry ## Definition ```filtrera let OrderJournalEntry: { amount: number invoiceId: uuid|nothing orderJournalEntryId: uuid orderJournalEntryType: 'receivable'|'capture'|'clearing'|'fee' paymentId: uuid|nothing timestamp: instant } ``` ## Availability --- --- url: /resources/graph/nodes/order-journal-entry.md description: '' --- # orderJournalEntry Graph Node Root Set Name: `orderJournalEntries` --- --- url: /resources/components/runtimes/types/order-line.md --- # OrderLine ## Definition ```filtrera let OrderLine: { description: text|nothing dynamic: {text->value} image: text|nothing orderLineId: uuid orderLineNumber: text orderLineTotal: number productNumber: text quantity: number releasedToInvoice: boolean returnedQuantity: number skus: [{ orderLineSkuId: uuid reservedQuantity: number skuNumber: text totalQuantity: number totalReturnedQuantity: number unitQuantity: number }] taxFactor: nothing|number taxTotal: number unitPrice: number } ``` ## Availability --- --- url: /resources/graph/nodes/order-line.md description: '' --- # orderLine Graph Node Root Set Name: `orderLines` --- --- url: /resources/components/runtimes/types/order-line-sku.md --- # OrderLineSku ## Definition ```filtrera let OrderLineSku: { orderLineSkuId: uuid reservedQuantity: number skuNumber: text totalQuantity: number totalReturnedQuantity: number unitQuantity: number } ``` ## Availability --- --- url: /resources/graph/nodes/order-line-sku.md description: '' --- # orderLineSku Graph Node Root Set Name: `orderLineSkus` --- --- url: /resources/components/runtimes/types/payment.md --- # Payment ## Definition ```filtrera let Payment: { authorizations: [{ amount: number authorizationNumber: text authorizationState: 'pending'|'successful'|'failed' createdAt: instant paymentAuthorizationId: uuid }] captures: [{ capturedAmount: number createdAt: instant invoiceId: uuid orderId: uuid paymentCaptureId: uuid remainingAmount: number state: 'pending'|'completed' }] createdAt: instant currencyCode: text dynamic: {text->value} externalReference: text journalEntries: [{ amount: number captureId: uuid|nothing invoiceId: uuid|nothing orderId: uuid|nothing paymentJournalEntryId: uuid paymentJournalEntryType: 'charge'|'capture' timestamp: instant }] paymentId: uuid paymentNumber: text providerKey: text|nothing tags: [text] } ``` ## Availability --- --- url: /resources/graph/nodes/payment.md description: '' --- # payment Graph Node Root Set Name: `payments` --- --- url: /resources/components/runtimes/types/payment-authorization.md --- # PaymentAuthorization ## Definition ```filtrera let PaymentAuthorization: { amount: number authorizationNumber: text authorizationState: 'pending'|'successful'|'failed' createdAt: instant paymentAuthorizationId: uuid } ``` ## Availability --- --- url: /resources/graph/nodes/payment-authorization.md description: '' --- # paymentAuthorization Graph Node Root Set Name: `paymentAuthorizations` --- --- url: /resources/components/runtimes/types/payment-capture.md --- # PaymentCapture ## Definition ```filtrera let PaymentCapture: { capturedAmount: number createdAt: instant invoiceId: uuid orderId: uuid paymentCaptureId: uuid remainingAmount: number state: 'pending'|'completed' } ``` ## Availability --- --- url: /resources/graph/nodes/payment-capture.md description: '' --- # paymentCapture Graph Node Root Set Name: `paymentCaptures` --- --- url: /resources/components/runtimes/rule-effects/paymentCommand.md --- # paymentCommand Return this effect to apply a payment command from hooks that supports it. ## Type ```filtrera { effect: 'paymentCommand' type: text // Command type // Additional command properties are added here } ``` ## Payment Commands --- --- url: /resources/components/runtimes/types/payment-journal-entry.md --- # PaymentJournalEntry ## Definition ```filtrera let PaymentJournalEntry: { amount: number captureId: uuid|nothing invoiceId: uuid|nothing orderId: uuid|nothing paymentJournalEntryId: uuid paymentJournalEntryType: 'charge'|'capture' timestamp: instant } ``` ## Availability --- --- url: /resources/graph/nodes/payment-journal-entry.md description: '' --- # paymentJournalEntry Graph Node Root Set Name: `paymentJournalEntries` --- --- url: /resources/components/runtimes/keywords/percentage.md --- # percentage The `percentage` keyword creates a percentage discount effect. It takes a target (from [`target`](/resources/components/runtimes/keywords/target) or [`order`](/resources/components/runtimes/keywords/order)) and a percentage rate, and returns a discount effect record. The rate uses Filtrera's percentage syntax: `10%` equals 10 percent, `100%` equals 100 percent. ## Availability ## Examples #### 10% off the entire order ```filtrera from percentage(order, 10%) ``` #### Free shipping (100% off deliveries) ```filtrera from percentage(target(e => e is Delivery), 100%) ``` #### 20% off specific products ```filtrera from percentage(target(e => e is OrderLine and e.dynamic->'category' == 'electronics'), 20%) ``` #### Conditional percentage based on order value ```filtrera let rate = order.total match when order.total >= 10000 |> 15% when order.total >= 5000 |> 10% when order.total >= 1000 |> 5% |> 0% from rate > 0% match true |> percentage(order, rate) false |> nothing ``` ## See Also * [absolute](/resources/components/runtimes/keywords/absolute) — apply a fixed amount discount * [target](/resources/components/runtimes/keywords/target) — select which parts of the order to discount * [order](/resources/components/runtimes/keywords/order) — target the entire order * [Order Promotions](/resources/actors/order/promotions) — how promotions work --- --- url: /resources/components/runtimes/types/promotion.md --- # Promotion ## Definition ```filtrera let Promotion: { componentId: nothing|text description: nothing|text dynamic: {text->value} parameters: {text->value} promotionGroup: text promotionId: uuid } ``` ## Availability --- --- url: /resources/graph/nodes/promotion.md description: '' --- # promotion Graph Node Root Set Name: `promotions` --- --- url: /resources/components/runtimes/keywords/query.md --- # query ```filtrera query ``[(``)] [filter '``'] [phrase '``'] [orderBy 'field asc|desc'] [navigate] ``` The query keyword provides access to the [Graph](/resources/graph/) using a simple query definition syntax. ## Query Definition A query is made up of a *root navigation*. Each navigation can have multiple nested navigations that will be performed in a single query and returned as nested records. Each navigation can be filtered and sorted, as well as specify zero or more fields. Filtering is done using the `filter` keyword and takes a text as input. Similary, ordering is done using the `orderBy` keyword. The texts can be interpolated texts that are evaluated at runtime. The format follows Hantera's standard graph filter/order by values. Nested navigations are done using the `navigate` keyword. A simple example fetching orders and order lines could look like this: ```filtrera from query orders(orderNumber) orderBy 'orderNumber desc' navigate orderLines(productNumber, quantity) orderBy 'orderLineNumber' ``` ## Query Result The query result type is constructed dynamically based on the query definition, similar to how the [Graph API](/resources/graph/) works. This means that definitions are checked at compile time, and an invalid query will not be attempted. When working with queries in [Hantera Development Studio](/learn/hantera-development-studio) it's important that your session is connected to a Hantera backend in order for the graph metadata to be available. Querying custom sets will not be allowed by the compiler unless the backend can verify the existence of said sets. ## Error Handling While a query definition can be almost be completely checked at compile time, `filter` and `orderBy` allows dynamic string interpolation and can therefor lead to runtime errors. When an error occurs, a [`QueryError`](/resources/components/runtimes/types/query-error) record is returned. ## Paging Paging is done transparently and lazily. There's no limit to how many records can be fetched, but watch your memory usage if you buffer the result. ## Examples From the Cookbook: ```filtrera from { get = (args) => // Query for orders ordered by latest creation timestamp let orderQuery = query orders(orderNumber) orderBy 'createdAt desc' // Extract order number from query result let orderNumber = orderQuery match QueryError |> 'Error' |> orderQuery select r => r.orderNumber first // first returns first orderNumber or nothing from return ( orderNumber match nothing |> 'No orders' |> orderNumber ) } ``` --- --- url: /resources/components/runtimes/types/query-error.md --- # QueryError ## Definition ```filtrera let QueryError: { message: text } ``` ## Availability --- --- url: /resources/registry/reference/reactors_effects_http-request_allowed-hosts.md --- # reactors/effects/httpRequest/allowedHosts A list of hosts that Reactors are allowed to call. If not set, any hostname is allowed. It's highly recommended to configure this list for any production environment. ## Example Value ``` [ "api.example.com" ] ``` --- --- url: /resources/components/runtimes/keywords/registry.md --- # registry ``` { text -> value | nothing } ``` Provides access to the Registry. Secret keys can be accessed. The type of the Registry value will be converted to a native Filtrera type. If the specified path is invalid, an Error record will be returned to signal that there's a problem. Not just a missing entry. ## Examples #### Fetching a single value ```filtrera from registry->'integrations/my-reactor/key' // Returns the value of the registry key 'integrations/my-reactor/key' ``` #### Fetching all channels ```filtrera import 'maps' import 'text' from registry entries where r => r.key is /^channels\/.+/ select r => r.value with { channelKey = r.key slice 9 } ``` --- --- url: /resources/components/runtimes/modules/resources.md --- # resources ## Availability --- --- url: /resources/graph/nodes/return.md description: '' --- # return Graph Node Root Set Name: `returns` --- --- url: /resources/components/runtimes/keywords/rule.md --- # rule The `rule` keyword provides access to metadata about the currently executing rule. It returns a record with the rule's identity, label, and activation window. `rule` is a symbol — use it directly without parentheses. ## Availability ## Fields | Field | Type | Description | |-------|------|-------------| | `ruleId` | `text` | The unique identifier of the rule | | `label` | `text \| { text -> text }` | The rule's label — either a plain string or a locale map for localized labels | | `activeFrom` | `instant` | When the rule becomes active | | `activeTo` | `instant` | When the rule stops being active | ## Examples #### Access rule metadata ```filtrera param input: OnOrderCreated from [{ effect = 'orderCommand' type = 'createPromotion' componentId = 'free-shipping' promotionGroup = 'shipping' description = rule.label parameters = { threshold = '500' } }] ``` #### Use localized label When the rule's `label` is a locale map, you can resolve it using the order's locale: ```filtrera param input: OnOrderCreated let description = rule.label match text |> rule.label |> rule.label->input.order.locale from [{ effect = 'orderCommand' type = 'createPromotion' componentId = 'volume-discount' promotionGroup = 'campaign' description = description }] ``` ## See Also * [Rules Overview](/resources/rules/) — introduction to rules * [Rule Hooks](/resources/rules/hooks) — available lifecycle hooks * [Rule Effects](/resources/rules/effects) — available effects --- --- url: /resources/components/runtimes/types/rule.md --- # Rule ## Definition ```filtrera let Rule: { activeFrom: nothing|instant activeTo: nothing|instant components: [{ componentId: text parameters: {text->value} }] label: nothing|text|{text->text} ruleId: text } ``` ## Availability --- --- url: /resources/components/runtimes/types/rule-component.md --- # RuleComponent ## Definition ```filtrera let RuleComponent: { componentId: text parameters: {text->value} } ``` ## Availability --- --- url: /resources/components/runtimes/keywords/scheduleJob.md --- # scheduleJob The scheduleJob function allows Reactors to create [Jobs](/resources/jobs/) that runs a Reactor method at a given time in the future. ## Availability ## Return Value The return value is the ID of the [Job](/resources/jobs/) that was created. ## Error Handling [Error](/resources/components/runtimes/types/error) may be returned if the specified `reactorId` or `method` doesn't exist, or the given `argument` is invalid. ## Examples ```filtrera from scheduleJob( 'myJobDefinition' now + 2 hours ) match Error |> 'An error occurred' (jobId: uuid) |> jobId ``` --- --- url: /resources/components/runtimes/rule-effects/scheduleJob.md --- # scheduleJob Return this effect to schedule a job. ## Type ```filtrera { effect: 'scheduleJob' definition: text at: instant|nothing parameters: {text->value|nothing} } ``` --- --- url: /learn/guides/scheduling-jobs-from-rules.md description: Learn how to create powerful automations using Rules and Jobs --- # Scheduling Jobs From Rules [Rules](/resources/rules/) can be combined with Jobs to create advanced automated processes within Hantera based on various events. For example, you might want to send an email to the customer when the Order is confirmed. With a Rule that triggers on an Order state change, this becomes trivial. In the following example, we assume that we already have a Reactor that uses [Mailtrap](https://mailtrap.io) to send e-mails called `mailtrap` with method `sendEmailFromTemplate`. You can find this exact example in the [Cookbook](https://github.com/hantera-io/cookbook/) The Rule effect used to schedule jobs is [`scheduleJob`](/resources/components/runtimes/rule-effects/scheduleJob). Not all Rule Hooks support the `scheduleJob` effect. Refer to the [Rule Runtime Hooks Reference](/resources/components/runtimes/#rule-hooks) for more details. ```filtrera //order-confirmation.hrule param input: OnOrderCommands param templateUuid: text param reactorId = 'mailtrap' // Default value 'mailtrap' but allow override let sendOrderConfirmation = { effect = 'scheduleJob' reactorId = reactorId method = 'sendEmailTemplate' argument = { to = { email = input.order.invoiceAddress.email name = input.order.invoiceAddress.name } templateUuid = templateUuid templateVariables = { customer = { name = input.order.invoiceAddress.name address = { firstName = input.order.invoiceAddress.name street = input.order.invoiceAddress.addressLine1 city = input.order.invoiceAddress.city state = input.order.invoiceAddress.state zip = input.order.invoiceAddress.postalCode country = input.order.invoiceAddress.countryCode } } order = { number = input.order.orderNumber items = input.order.deliveries select d => d.orderLines flatten select l => { name = l.description quantity = l.quantity price = l.unitPrice } isRush = false total = input.order.orderTotal } } } } from input match { before: { orderState: 'pending' } order: { orderState: 'confirmed' } } |> sendOrderConfirmation ``` --- --- url: /resources/components/runtimes/modules/resources/sendEmail.md --- Queue an email for delivery through Hantera's centralized sending system. The email is processed asynchronously with automatic retry logic and status tracking. ## Signature ```filtrera import 'resources' sendEmail { to: text subject: text body: { plainText: text | nothing html: text | nothing } cc: [text] | nothing bcc: [text] | nothing from: text | nothing fromName: text | nothing category: text | nothing replyTo: text | nothing dynamic: { text -> any } | nothing } => uuid ``` ## Parameters | Parameter | Type | Required | Description | | ---------- | ----------------------------- | -------- | -------------------------------------------------------- | | `to` | `text` | Yes | Primary recipient email address | | `subject` | `text` | Yes | Email subject line | | `body` | Record | Yes | Email body (see Body Parameter below) | | `cc` | `[text]` | No | List of CC recipient email addresses | | `bcc` | `[text]` | No | List of BCC recipient email addresses | | `from` | `text` | No | Sender email address (overrides system default) | | `fromName` | `text` | No | Display name for sender | | `category` | `text` | No | Category for filtering/reporting (default: `"reactor"`) | | `replyTo` | `text` | No | Reply-to email address | | `dynamic` | `{ text -> any }` | No | Custom data to store for querying (default: `{}`) | ### Body Parameter The `body` parameter must be a record with at least one of these fields: * `plainText`: Plain text version of the email * `html`: HTML version of the email **You must provide at least one**, but you can provide both for multi-part emails. ## Returns Returns a `uuid` representing the `sendingId` for the primary recipient (`to` address). Each CC and BCC recipient gets their own Sending record with unique IDs. ## Examples ### Simple Text Email ```filtrera import 'resources' from sendEmail { to = 'user@example.com' subject = 'Welcome to Hantera' body = { plainText = 'Thank you for signing up!' } dynamic = {} } ``` ### HTML Email with Interpolation ```filtrera import 'resources' from sendEmail { to = order.customer.email subject = $'Order #{order.orderNumber} Confirmed' body = { plainText = $'Thank you for your order #{order.orderNumber}.' html = $'

Order Confirmed

Thank you for your order #{order.orderNumber}.

Total: {order.total} {order.currencyCode}

' } category = 'order_confirmation' dynamic = { orderId = order.id orderNumber = order.orderNumber } } ``` ### Email with CC and BCC ```filtrera import 'resources' from sendEmail { to = 'customer@example.com' subject = 'Important Update' body = { html = '

This is an important update about your account.

' } cc = ['manager@example.com', 'team@example.com'] bcc = ['archive@example.com'] category = 'account_update' dynamic = { customerId = customer.id } } ``` ### Custom From Address ```filtrera import 'resources' from sendEmail { to = user.email subject = 'Password Reset Request' body = { plainText = $'Click this link to reset your password: {resetLink}' html = $'

Click here to reset your password.

' } from = 'noreply@mycompany.com' fromName = 'MyCompany Support' replyTo = 'support@mycompany.com' category = 'password_reset' dynamic = { userId = user.id resetToken = resetToken } } ``` ## Common Patterns ### Order Confirmation ```filtrera import 'resources' // In an order.confirmed reactor from sendEmail { to = order.customer.email subject = $'Order #{order.orderNumber} Confirmed' body = { html = $'

Thank You for Your Order!

Order Number: {order.orderNumber}

Total: {order.total} {order.currencyCode}

We''ll send you updates as we process your order.

' } category = 'order_confirmation' dynamic = { orderId = order.id orderNumber = order.orderNumber customerId = order.customer.id } } ``` ### Password Reset ```filtrera import 'resources' // In a password reset reactor let resetLink = $'https://myapp.com/reset-password?token={resetToken}' from sendEmail { to = user.email subject = 'Password Reset Request' body = { plainText = $'Click this link to reset your password: {resetLink}' html = $'

You requested a password reset.

Click here to reset your password

This link expires in 1 hour.

' } category = 'password_reset' replyTo = 'support@mycompany.com' dynamic = { userId = user.id } } ``` ### Shipping Notification ```filtrera import 'resources' // In an order.shipped reactor from sendEmail { to = order.customer.email subject = $'Your Order #{order.orderNumber} Has Shipped' body = { html = $'

Your Order Has Shipped!

Order Number: {order.orderNumber}

Tracking Number: {trackingNumber}

Estimated Delivery: {estimatedDelivery}

' } category = 'order_shipped' dynamic = { orderId = order.id orderNumber = order.orderNumber trackingNumber = trackingNumber } } ``` ## Error Handling The function returns errors for invalid input: ```filtrera import 'resources' from sendEmail { to = 'user@example.com' subject = 'Test' body = { plainText = nothing, html = nothing } // ERROR: At least one body required dynamic = {} } match Error |> $'Failed to send email: {result.error.message}' uuid |> $'Email queued with ID: {result}' ``` ### Common Errors * **`INVALID_BODY`**: Neither `plainText` nor `html` provided in body * **`RESERVED_CATEGORY_PREFIX`**: Category starts with `system:` prefix (reserved for internal platform use) * **`INVALID_EMAIL_ADDRESS`**: From address is missing `@` or domain * **`INTERNAL_ERROR`**: Unexpected error occurred ::: warning **Reserved Category Prefix:** The `system:` prefix is reserved for internal platform sendings (password resets, email validations, etc.). Attempting to use categories like `system:anything` will result in a `RESERVED_CATEGORY_PREFIX` error. Use descriptive app-specific categories like `order_confirmation`, `password_reset`, or `campaign_welcome` instead. ::: ## Dynamic Fields for Querying The `dynamic` parameter stores custom data in the Sending record's `dynamic` field (JSON). You can then define custom Graph fields to query this data: ```yaml # Define a custom field uri: /resources/registry/graph/sending/fields/orderId spec: value: type: text source: dynamic->'orderId' ``` ```json // Query sendings by orderId [ { "edge": "sendings", "filter": "orderId == '550e8400-e29b-41d4-a716-446655440000'", "node": { "fields": ["sendingId", "recipient", "status"] } } ] ``` ::: tip Store any data in `dynamic` that you might want to query or report on later. Common examples: `userId`, `orderId`, `customerId`, `orderNumber`, `campaignId`. ::: ## Multi-Recipient Behavior When you provide `cc` or `bcc` lists, the system creates **one Sending record** for the email: * One record for the primary `to` recipient (returned SendingId) * CC and BCC recipients are delivered best-effort * Only the primary `to` recipient has tracked delivery status The `cc` and `bcc` recipients are stored as comma-separated strings in the Sending record's data, but do not have individual status tracking or retry logic. ```filtrera import 'resources' // This creates 1 Sending record for the primary recipient let sendingId = sendEmail { to = 'customer@example.com' // Tracked with this sendingId cc = ['manager@example.com'] // Best-effort delivery, not individually tracked bcc = ['archive@example.com', 'admin@example.com'] // Best-effort delivery, not individually tracked subject = 'Test' body = { plainText = 'Test message' } dynamic = {} } // sendingId tracks only the primary 'to' recipient // CC/BCC delivery success/failure is not tracked individually ``` ## Asynchronous Processing ::: info Emails are **queued** for delivery, not sent immediately. The background processor handles actual delivery with rate limiting and retry logic. ::: **Processing behavior:** * Emails are queued as `pending` status * Background service processes queue every 10 seconds (configurable) * Rate limited to 60 emails/minute by default (configurable) * Failed deliveries are retried up to 3 times with exponential backoff * Final status is either `sent` or `bounced` **Monitor delivery:** ```json // Query sending status [ { "edge": "sendings", "filter": "sendingId == '550e8400-e29b-41d4-a716-446655440000'", "node": { "fields": ["sendingId", "status", "sentAt", "errorMessage"] } } ] ``` ## Best Practices ### 1. Always Include Plain Text Provide both `plainText` and `html` for better compatibility: ```filtrera body = { plainText = 'Your order has shipped.' html = '

Your order has shipped.

' } ``` ### 2. Use Meaningful Categories Categories help with reporting and filtering: ```filtrera category = 'order_confirmation' // Good category = 'email' // Not helpful ``` ### 3. Store Query able Data in Dynamic Store IDs and reference numbers you'll need to query later: ```filtrera dynamic = { orderId = order.id orderNumber = order.orderNumber customerId = order.customer.id // Avoid storing large blobs or sensitive data } ``` ### 4. Set Reply-To for Support Emails Make it easy for customers to respond: ```filtrera replyTo = 'support@mycompany.com' ``` ### 5. Use Text Interpolation Leverage Filtrera's text interpolation for dynamic content: ```filtrera subject = $'Order #{order.orderNumber} Update' body = { html = $'

Hi {customer.firstName},

Your order status: {order.status}

' } ``` ## Related Resources * **[Sending Graph Node](/resources/graph/nodes/sending)** - Query email delivery status * **[Sending Emails Guide](/learn/guides/sending-emails)** - Complete guide with examples * **[Custom Fields](/resources/graph/custom-fields)** - Define queryable fields on dynamic data * **[Components](/resources/components/)** - Using sendEmail in component scripts * **[Ingresses](/resources/ingresses/)** - Expose components as callable endpoints --- --- url: /resources/components/runtimes/rule-effects/sendEmail.md --- # sendEmail Return this effect to queue an email for delivery through the [Sendings system](/resources/sendings). ## Type ```filtrera { effect: 'sendEmail' to: text subject: text body: { plainText: text | nothing html: text | nothing } cc: [text] | nothing bcc: [text] | nothing from: text | nothing fromName: text | nothing replyTo: text | nothing category: text | nothing dynamic: {text->text} | nothing } ``` ## Fields | Field | Type | Required | Description | |-------|------|----------|-------------| | `effect` | `'sendEmail'` | Yes | Must be `'sendEmail'` | | `to` | `text` | Yes | Recipient email address | | `subject` | `text` | Yes | Email subject line | | `body` | `record` | Yes | Email body (at least one of `plainText` or `html` required) | | `body.plainText` | `text` | No | Plain text version of the email | | `body.html` | `text` | No | HTML version of the email | | `cc` | `[text]` | No | CC recipients | | `bcc` | `[text]` | No | BCC recipients | | `from` | `text` | No | Sender email address (uses system default if not specified) | | `fromName` | `text` | No | Sender display name | | `replyTo` | `text` | No | Reply-to email address | | `category` | `text` | No | Email category for tracking (defaults to `'rule'`) | | `dynamic` | `{text->text}` | No | Custom metadata/tracking data | ## Example ```filtrera param input: OnOrderCreated from { effect = 'sendEmail' to = input.order.invoiceAddress.email subject = 'Order Confirmation - ' + input.order.orderNumber body = { plainText = 'Thank you for your order!' html = '

Thank you for your order!

Order number: ' + input.order.orderNumber + '

' } category = 'order-confirmation' dynamic = { orderId = input.order.orderId :: text orderNumber = input.order.orderNumber } } ``` ## Notes * At least one of `body.plainText` or `body.html` must be provided * Emails are queued for delivery and sent asynchronously * The `category` field defaults to `'rule'` if not specified * Email delivery status can be tracked via the [Sendings Graph Node](/resources/graph/nodes/sending) ## See Also * [Sendings API](/resources/sendings) - Email delivery system * [Sending Graph Node](/resources/graph/nodes/sending) - Query email status * [scheduleJob](/resources/components/runtimes/rule-effects/scheduleJob) - Schedule jobs from rules --- --- url: /learn/guides/sending-emails.md description: >- Complete guide to sending emails in Hantera using the resources.sendEmail() function and monitoring delivery through the Graph API --- Learn how to send emails from Hantera using the centralized Sending system. This guide covers everything from basic email sending to advanced patterns like monitoring delivery status and querying by custom data. ::: info **New to Sendings?** See the [Sendings resource documentation](/resources/sendings) for an overview of the Sending system, lifecycle, and permissions. ::: ## What is the Sending System? Hantera's Sending system provides a queue-based approach to email delivery with: * **Asynchronous processing**: Emails are queued and sent in the background * **Individual recipient tracking**: One record per recipient for granular status monitoring * **Automatic retries**: Failed deliveries are retried automatically with exponential backoff * **Rate limiting**: Respects configured limits to avoid overwhelming mail servers * **Status tracking**: Monitor pending, sent, and bounced messages via Graph API ::: tip The Sending system is designed for transactional emails (order confirmations, password resets, notifications). For bulk marketing emails, consider using a dedicated email service provider. ::: ## Quick Start 1. **Send a simple email** Use the `sendEmail()` function in a Component: ```filtrera import 'resources' from sendEmail { to = 'customer@example.com' subject = 'Welcome to Hantera' body = { plainText = 'Thank you for signing up!' } dynamic = {} } ``` 2. **Check delivery status** Query the sending record through the Graph API: 3) **Handle errors** Check for bounced emails: ## Common Use Cases ### Order Confirmation Send an order confirmation email when an order is placed: ```filtrera import 'resources' // In a component that reacts to order.confirmed event from sendEmail { to = order.customer.email subject = $'Order #{order.orderNumber} Confirmed' body = { plainText = $'Thank you for your order #{order.orderNumber}. Total: {order.total} {order.currencyCode}' html = $'

Order Confirmed

Thank you for your order #{order.orderNumber}.

Order Total: {order.total} {order.currencyCode}

Order Details

We''ll send you updates as we process your order.

' } category = 'order_confirmation' dynamic = { orderId = order.id orderNumber = order.orderNumber customerId = order.customer.id } } ``` **Why this works:** * Uses text interpolation for dynamic content * Provides both plain text and HTML versions * Stores order references in `dynamic` for later querying * Uses a meaningful category for filtering ### Invoice/Receipt Email Send an invoice email after payment confirmation: ```filtrera import 'resources' // In a payment.confirmed job from sendEmail { to = order.customer.email subject = $'Invoice for Order #{order.orderNumber}' body = { plainText = $' Invoice #{invoice.invoiceNumber} Date: {invoice.date} Order Number: {order.orderNumber} Customer: {order.customer.name} Total: {order.total} {order.currencyCode} Payment Method: {payment.method} Thank you for your business! ' html = $'

Invoice

Invoice Number: {invoice.invoiceNumber}
Date: {invoice.date}

Item Qty Price Total

Subtotal: {order.subtotal} {order.currencyCode}

Tax: {order.tax} {order.currencyCode}

Total: {order.total} {order.currencyCode}

Payment Method: {payment.method}
Questions about your invoice? Contact Billing

' } category = 'invoice' replyTo = 'billing@mycompany.com' dynamic = { orderId = order.id invoiceNumber = invoice.invoiceNumber customerId = order.customer.id paymentId = payment.id } } ``` **Tip:** Store invoice number in `dynamic` to easily query invoices sent to customers. ### Shipping Notification Notify customers when their order ships: ```filtrera import 'resources' // In an order.shipped job let trackingUrl = $'https://tracking.carrier.com/{trackingNumber}' from sendEmail { to = order.customer.email subject = $'Your Order #{order.orderNumber} Has Shipped!' body = { html = $'

Your Order Has Shipped!

Order Number: {order.orderNumber}

Tracking Number: {trackingNumber}

Estimated Delivery: {estimatedDelivery}

Thank you for shopping with us!

' } category = 'order_shipped' dynamic = { orderId = order.id orderNumber = order.orderNumber trackingNumber = trackingNumber } } ``` ### Account Notifications Send important account updates to customers with CC to support: ```filtrera import 'resources' // In an account update job from sendEmail { to = customer.email cc = ['support@mycompany.com'] subject = 'Important Account Update' body = { plainText = $'Your account settings have been updated. If you didn''t make these changes, please contact support immediately.' html = $'

Account Update Notification

Your account settings have been updated.

If you didn''t make these changes, please contact support immediately.

' } category = 'account_update' replyTo = 'support@mycompany.com' dynamic = { customerId = customer.id } } ``` ## Advanced Patterns ### Custom Fields for Querying Store custom data in `dynamic` and create Graph fields to query it: **1. Send email with custom data:** ```filtrera import 'resources' from sendEmail { to = customer.email subject = $'Campaign: {campaignName}' body = { html = '

Special offer just for you!

' } category = 'marketing_campaign' dynamic = { campaignId = campaign.id customerId = customer.id segmentId = segment.id } } ``` **2. Define custom Graph fields:** ```yaml # Define campaignId field uri: /resources/registry/graph/sending/fields/campaignId spec: value: type: text source: dynamic->'campaignId' ``` **3. Query by campaign:** ### Monitoring Email Delivery Build a dashboard to monitor email health: ### Alert Emails with Graph Queries Monitor for issues and send alerts when problems are detected. This example shows a scheduled job that checks for orders with a "fraud-suspected" tag and alerts the fraud team: ```filtrera import 'resources' // Query for orders tagged with fraud-suspected let suspiciousOrders = query orders(orderId, orderNumber, customer, total, currencyCode, createdAt) filter 'tags anyof ["fraud-suspected"] and createdAt >= today' // Build email body with order details let orderList = suspiciousOrders select o => $' - Order #{o.orderNumber}: {o.total} {o.currencyCode} ({o.customer.email})' join '\n' // Send alert only if suspicious orders found from suspiciousOrders count match when count > 0 |> sendEmail { to = 'fraud-team@mycompany.com' subject = $'ALERT: {count} Suspicious Orders Detected' body = { plainText = $' Fraud Alert {count} orders flagged as suspicious in the last 24 hours: {orderList} Review these orders immediately in the fraud dashboard. ' html = $'

⚠️ Fraud Alert

{count} orders flagged as suspicious in the last 24 hours:

    {suspiciousOrders select o => $'
  • Order #{o.orderNumber}: {o.total} {o.currencyCode} ({o.customer.email})
  • ' join ''}

Review in Dashboard

' } category = 'fraud_alert' replyTo = 'alerts@mycompany.com' dynamic = { alertType = 'fraud_suspected' orderCount = count triggerDate = now } } |> nothing // Do nothing if no suspicious orders ``` **Key patterns demonstrated:** * **Graph integration**: Query orders with specific criteria * **Conditional sending**: Only send if count > 0 * **Dynamic content**: Build email body from query results * **Pattern matching**: Handle both alert and no-alert cases * **Alert metadata**: Store alert details in `dynamic` for tracking This pattern works for any monitoring scenario: * Low inventory alerts (stock < threshold) * High-value orders (total > threshold) * Failed payment attempts (status = failed) * Abandoned carts (lastActivity < threshold) ### Multi-Recipient Emails Send to multiple recipients with best-effort CC/BCC delivery: ```filtrera import 'resources' // Send to customer with CC to sales team and BCC to archive from sendEmail { to = customer.email cc = ['sales@mycompany.com', 'account-manager@mycompany.com'] bcc = ['archive@mycompany.com'] subject = 'Account Review Summary' body = { html = '

Your account review summary...

' } category = 'account_review' dynamic = { customerId = customer.id reviewId = review.id } } ``` **Important:** Only the primary `to` recipient gets a Sending record with status tracking. CC and BCC recipients receive the email as best-effort delivery without individual tracking. The returned `sendingId` tracks only the primary recipient. ### Error Handling Handle errors gracefully in your components and jobs: ```filtrera import 'resources' from sendEmail { to = user.email subject = 'Test Email' body = { plainText = 'Test message' } dynamic = {} } match Error |> { // Log the error logError $'Failed to queue email: {result.error.message}' // You might want to: // - Trigger an alert // - Store failure in a separate system // - Retry with different parameters } uuid |> { // Success - sendingId returned logInfo $'Email queued successfully: {result}' } ``` ## Best Practices ### 1. Always Provide Plain Text Email clients vary in HTML support. Always include a plain text version: ```filtrera body = { plainText = 'Order confirmed. Order number: 12345' html = '

Order Confirmed

Order number: 12345

' } ``` ### 2. Use Meaningful Categories Categories make filtering and reporting easier: ```filtrera // Good categories category = 'order_confirmation' category = 'password_reset' category = 'shipping_notification' // Avoid generic categories category = 'email' category = 'notification' ``` **Reserved Prefix:** The `system:` prefix is reserved for internal platform sendings (password resets, email validations, etc.). Using categories like `system:anything` will result in an error. Use descriptive app-specific categories instead. ### 3. Store Queryable Data Put anything you might query in `dynamic`: ```filtrera dynamic = { orderId = order.id // For finding order-related emails customerId = customer.id // For finding customer emails orderNumber = order.orderNumber // For search by order number campaignId = campaign.id // For campaign analysis } ``` ### 4. Set Reply-To for Support Emails Make it easy for customers to respond: ```filtrera replyTo = 'support@mycompany.com' ``` ### 5. Use Text Interpolation Leverage Filtrera's text interpolation for dynamic, readable content: ```filtrera subject = $'Order #{order.orderNumber} Update' body = { html = $'

Hi {customer.firstName},

Your order {order.orderNumber} status: {order.status}

' } ``` ### 6. Monitor Bounce Rates Regularly check for bounced emails to maintain list health: A bounce rate above 5% may indicate email list quality issues. ## Troubleshooting ### Emails Not Sending **Problem:** Emails stuck in `pending` status **Solutions:** 1. Check queue depth - if >100, processing may be backed up 2. Verify SMTP configuration in system settings 3. Check for background service errors in logs 4. Ensure rate limits aren't too restrictive ### High Bounce Rate **Problem:** Many emails bouncing **Common causes:** * Invalid email addresses * SMTP authentication failures * Spam filter issues * Domain reputation problems **Solutions:** 1. Validate email addresses before sending 2. Verify SMTP credentials 3. Check sender domain reputation 4. Review email content for spam triggers ### Delivery Delays **Problem:** Emails taking too long to send **Factors:** * Queue depth (check pending count) * Rate limiting settings (default: 60/minute) * Processing interval (default: 10 seconds) * Network latency to SMTP server **Monitor with:** ## Performance Considerations ### Rate Limiting The system enforces rate limits to prevent overwhelming mail servers: * **Default:** 60 emails/minute * **Processing interval:** 10 seconds * **Configurable:** Contact system administrator ### Queue Management Monitor queue depth to prevent backlogs: ```json // Healthy queue depth: < 50 pending // Warning: 50-100 pending // Critical: > 100 pending ``` ### Best Practices for High Volume If sending many emails: 1. **Batch operations**: Send in chunks rather than all at once 2. **Use categories**: Group related emails for better monitoring 3. **Monitor bounces**: Remove invalid addresses quickly 4. **Stagger sends**: Spread large campaigns over time ## Next Steps * **[resources.sendEmail() Reference](/resources/components/runtimes/modules/resources/sendEmail)** - Complete function documentation * **[Sending Graph Node](/resources/graph/nodes/sending)** - Query email delivery status * **[Custom Fields](/resources/graph/custom-fields)** - Create queryable fields on dynamic data * **[Jobs](/resources/jobs/)** - Build event-driven email workflows ## Complete Example Here's a complete example combining everything: ```filtrera import 'resources' // Component that sends order confirmation with proper tracking // 1. Send the email let sendingId = sendEmail { to = order.customer.email cc = ['sales@mycompany.com'] subject = $'Order #{order.orderNumber} Confirmed - Thank You!' body = { plainText = $' Hi {order.customer.firstName}, Thank you for your order #{order.orderNumber}! Order Total: {order.total} {order.currencyCode} We''ll send you updates as we process your order. Thanks, The Team ' html = $'

Order Confirmed!

Hi {order.customer.firstName},

Thank you for your order #{order.orderNumber}!

Item Qty Price

Total: {order.total} {order.currencyCode}

We''ll send you updates as we process your order.

Questions? Contact Support

' } category = 'order_confirmation' replyTo = 'support@mycompany.com' dynamic = { orderId = order.id orderNumber = order.orderNumber customerId = order.customer.id orderTotal = order.total currencyCode = order.currencyCode } } // 2. Log the sending ID for reference logInfo $'Order confirmation email queued: {sendingId}' ``` --- --- url: /resources/graph/nodes/sending.md description: '' --- # sending Graph Node Root Set Name: `sendings` Query Sending records through the Graph API to monitor email delivery status, track failures, and analyze communication patterns. ::: info **New to Sendings?** See the [Sendings resource documentation](/resources/sendings) for an overview of what Sendings are, how they work, and how to create them. ::: ::: tip For a complete field reference, use the Graph metadata endpoint: `GET /resources/graph/meta` and look for the `sending` node type. ::: ## Common Query Patterns ### Monitor Recent Activity Track recently sent emails across your system: **What this does:** Returns the 50 most recent sendings from October 29th onwards, ordered by creation time. ### Find Failed Deliveries Identify emails that failed to deliver: **What this does:** Returns all bounced emails with their error messages and retry counts. ### Query by Category Filter sendings by their category (e.g., password resets, order confirmations): **What this does:** Returns the 100 most recently sent order confirmation emails. ### Monitor Pending Queue Check the current queue depth to monitor system health: **What this does:** Returns two counts: pending emails in queue and successfully sent emails today. ## Integration with Custom Fields Sending records include a `dynamic` field that stores custom data passed when creating the sending. You can define custom Graph fields to query this data: ### Define a Custom Field ```yaml uri: /resources/registry/graph/sending/fields/orderId spec: value: type: text source: dynamic->'orderId' ``` ### Query Using Custom Field **What this does:** Finds all emails sent related to a specific order. ## Working with Status Values The `status` field tracks the lifecycle of each sending: * **`pending`**: Queued, waiting to be processed * **`sent`**: Successfully delivered to mail server * **`bounced`**: Delivery failed after retries exhausted * **`cancelled`**: Cancelled before processing (via REST API) The `cancelledAt` field stores the timestamp when a sending was cancelled. Only pending sendings can be cancelled using the `DELETE /resources/sendings/{id}` REST endpoint. **Example: Monitoring delivery rates** ## Transport Types The `transport` field indicates the communication channel: * **`email`**: Email delivery (currently supported) * **`sms`**: SMS delivery (future) * **`push`**: Push notification (future) Currently, only `email` transport is implemented. ## Authorization **Required Permissions:** ``` graph/sending:query # Query sendings graph/sending:field # Access individual fields ``` ::: info See [Access Control](/learn/access-control) for more on permission patterns. ::: ## Cancelled Sendings Query cancelled sendings to track cancellation activity: **What this does:** Returns the 20 most recently cancelled sendings. ## Related Resources * **[Sendings Resource](/resources/sendings)** - Overview of the Sending resource and REST API * **[Sending Emails Guide](/learn/guides/sending-emails)** - Complete guide to sending emails * **[resources.sendEmail() Function](/resources/components/runtimes/modules/resources/sendEmail)** - Filtrera function for sending emails * **[Graph API Overview](/resources/graph/)** - Learn more about querying with Graph --- --- url: /resources/components/runtimes/modules/resources/setRegistryKey.md --- # setRegistryKey ``` setRegistryKey( key: text value: value ): value | Error ``` Sets a value in the registry. Returns true if the value was updated. Error is returned if the value could not be set. ## Availability ## Examples ```filtrera from setRegistryKey('my/value', { field: 'Test' }) ``` --- --- url: /resources/registry/reference/signals.md --- # signals/\* `signals` registry path is used for reporting status, warnings and errors throughout Hantera. It's used by internal services but is also available for external integrations to write to using the API. Authorization apply as usual. It's a common convention for integrations and auxiliary services to report to signals to simplify monitoring of your Hantera environment. Ensure your custom signals do not overlap with system Signals by using unique paths. ::: warning Signals are not persisted. They are meant to report on current issues, and therefor there's no point in persisting them. This separates the /signal/ path in the registry from all other paths. Do **not** rely on signals for persistant configuration. This is generally not an issue but should your Hantera environment restart, all signals will be lost. If you're using Signals to report errors, make sure to remove the Signal when the problem has been fixed, or replace it with a lesser `severity`. ::: ## Schema In order for your Signals to be interpreted by other tools, such as [Hantera CLI](/learn/hantera-cli), they should contain certain fields. ### Signal Body | Field | Type | Description | |-------|------|-------------| | `severity` | `'information' \| 'warning' \| 'error'` | The severity of the signal. Allows users to prioritize and take appropriate action. | | `properties` | map of string to any value (optional) | Structured data that will be added to the signal. | | `code` | string | A code for tools to recognize the Signal value/state. | | `messageTemplate` | string | An explanation of the current Signal status. Preferably in [messagetemplates.org](https://messagetemplates.org/) format with placeholders referencing `properties`. | --- --- url: /resources/components/runtimes/types/sku.md --- # Sku ## Definition ```filtrera let Sku: { dynamic: {text->value} skuId: uuid skuNumber: text } ``` ## Availability --- --- url: /resources/graph/nodes/sku.md description: '' --- # sku Graph Node Root Set Name: `skus` --- --- url: /resources/components/runtimes/rule-effects/skuCommand.md --- # skuCommand Return this effect to apply a sku command from hooks that supports it. ## Type ```filtrera { effect: 'skuCommand' type: text // Command type // Additional command properties are added here } ``` ## Sku Commands --- --- url: /resources/graph/nodes/stock-allocation.md description: '' --- # stockAllocation Graph Node Root Set Name: `stockAllocations` --- --- url: /resources/graph/nodes/stock-position.md description: '' --- # stockPosition Graph Node Root Set Name: `stockPositions` --- --- url: /resources/graph/nodes/stock-reservation.md description: '' --- # stockReservation Graph Node Root Set Name: `stockReservations` --- --- url: /resources/graph/nodes/stock-reservation-request.md description: '' --- # stockReservationRequest Graph Node Root Set Name: `stockReservationRequests` --- --- url: /resources/registry/reference/system.md --- # system/\* `system` path is used by Hantera itself to store internal settings and state. It's not publicly documented and is subject to change with any given release. To ensure system integrity, it's not possible to update the `system` container or any child container/keys through manifests. It is however possible to set individual values through the registry API, useful for maintenance and troubleshooting. --- --- url: /resources/components/runtimes/keywords/target.md --- # target The `target` keyword selects which parts of an order should be affected by a discount effect. It takes a filter function that receives each order line and delivery, returning `true` for items that should be targeted. The result is passed to [`percentage`](/resources/components/runtimes/keywords/percentage) or [`absolute`](/resources/components/runtimes/keywords/absolute) as the first argument. ## Availability ## Filter Function The filter function receives each targetable entity on the order. You can use `is` pattern matching to distinguish between entity types: * `Delivery` — a shipping/delivery entity * `OrderLine` — an individual order line ## Examples #### Target all deliveries (free shipping) ```filtrera from percentage(target(e => e is Delivery), 100%) ``` #### Target specific order lines ```filtrera from absolute(target(e => e is OrderLine and e.productNumber == 'GIFT-WRAP'), 0) ``` #### Target by dynamic field ```filtrera from percentage(target(e => e is OrderLine and e.dynamic->'category' == 'clearance'), 30%) ``` #### Target everything To target the entire order (all order lines and shipping), use the [`order`](/resources/components/runtimes/keywords/order) symbol directly instead: ```filtrera from percentage(order, 10%) ``` ## See Also * [percentage](/resources/components/runtimes/keywords/percentage) — apply a percentage discount to targets * [absolute](/resources/components/runtimes/keywords/absolute) — apply an absolute discount to targets * [order](/resources/components/runtimes/keywords/order) — target the entire order * [Order Promotions](/resources/actors/order/promotions) — how promotions work --- --- url: /resources/components/runtimes/modules/text.md --- # text See official [Filtrera Documentation](https://www.filtrera.io/modules/text/). ## Availability --- --- url: /resources/components/runtimes/types/ticket.md --- # Ticket ## Definition ```filtrera let Ticket: { channelKey: nothing|text createdAt: instant dynamic: {text->value} externalReference: nothing|text items: [{ createdAt: instant dynamic: {text->value} relations: [{ nodeId: uuid relationKey: text }] ticketItemId: uuid typeKey: text }] relations: [{ nodeId: uuid relationKey: text }] tags: [text] ticketId: uuid ticketNumber: text ticketState: 'open'|'completed'|'rejected' typeKey: nothing|text } ``` ## Availability --- --- url: /resources/components/runtimes/rule-effects/ticketCommand.md --- # ticketCommand Return this effect to apply a ticket command from hooks that supports it. ## Type ```filtrera { effect: 'ticketCommand' type: text // Command type // Additional command properties are added here } ``` ## Ticket Commands --- --- url: /resources/components/runtimes/types/ticket-item.md --- # TicketItem ## Definition ```filtrera let TicketItem: { createdAt: instant dynamic: {text->value} relations: [{ nodeId: uuid relationKey: text }] ticketItemId: uuid typeKey: text } ``` ## Availability --- --- url: /resources/components/runtimes/keywords/triggerHook.md --- # triggerHook ```json { text -> value } triggerHook ( hook: text ): [{ text -> value }] ``` The `triggerHook` filter triggers a secondary [Rule](/resources/rules/) evaluation with the given data and hook name. All rules whose `param input` type matches the constructed hook input are evaluated, and their effects are returned as an iterator. This enables apps to define custom hook points that other rules can listen to. For full documentation, see [Custom Hooks - triggerHook](/resources/rules/trigger-hook). ## Availability ## Input Any record value. The runtime adds a `hook` field with the hook name before evaluating rules: ```filtrera // What you write: { orderId = '...' } triggerHook 'OnOrderValidated' // What rules receive: { hook = 'OnOrderValidated', orderId = '...' } ``` ## Return Value An iterator of records — the [effects](/resources/rules/effects) emitted by all matching rules. Returns an empty iterator when called inside a secondary evaluation (recursion prevention). ## Recursion Prevention `triggerHook` is single-level only. If a rule triggered by `triggerHook` calls `triggerHook` itself, it immediately returns `[]`. This prevents infinite loops. ## Examples ### In Rules Effects returned by `triggerHook` can be forwarded, filtered, or batched by the calling rule: ```filtrera param input: OnOrderCreated let hookEffects = { orderId = input.order.orderId } triggerHook 'OnOrderValidated' // Forward only validation errors from hookEffects where is { effect: 'validationError' } ``` ### In Reactors (Ingresses / Jobs) In the reactor runtime, returned effects are not automatically processed. The caller must apply them explicitly: ```filtrera import 'iterators' let hookEffects = { cartId = cartId, cart = cart } triggerHook 'OnCartMutation' let effects = hookEffects buffer let commandEffects = effects where is { effect: 'ticketCommand' } buffer from commandEffects count > 0 match true |> from messageActor ('ticket', cartId, [{ type = 'applyCommands' body = { commands = commandEffects select e => { type = e.type, fields = e.fields } as `list` } }]) ``` ## See Also * [triggerHook — Custom Hooks](/resources/rules/trigger-hook) — Full documentation * [Rule Effects](/resources/rules/effects) — Available effect types * [Rule Hooks](/resources/rules/hooks) — Built-in lifecycle hooks --- --- url: /resources/components/runtimes/rule-effects/validationError.md --- # validationError Return this effect to prevent saving of an actor's state and return an error. ## Type ```filtrera { effect: 'validationError' code: text message: nothing|text details: nothing|value } ``` --- --- url: '/api/http/post-resources-actors-{type}-{externalId}.md' --- --- --- url: /api/http/get-resources-apps.md --- --- --- url: /api/http/put-resources-apps.md --- --- --- url: '/api/http/get-resources-apps-{appId}.md' --- --- --- url: '/api/http/post-resources-apps-{appId}.md' --- --- --- url: '/api/http/get-resources-apps-{appId}-files.md' --- --- --- url: '/api/http/get-resources-apps-{appId}-files-{path}.md' --- --- --- url: '/api/http/get-resources-apps-{appId}-settings.md' --- --- --- url: '/api/http/patch-resources-apps-{appId}-settings.md' --- --- --- url: /api/http/get-resources-apps-dev-connect.md --- --- --- url: /api/http/get-audit.md --- --- --- url: '/api/http/get-audit-{id}.md' --- --- --- url: /api/http/get-resources-iam-clients.md --- --- --- url: '/api/http/get-resources-iam-clients-{id}.md' --- --- --- url: '/api/http/put-resources-iam-clients-{id}.md' --- --- --- url: '/api/http/delete-resources-iam-clients-{id}.md' --- --- --- url: '/api/http/patch-resources-iam-clients-{id}.md' --- --- --- url: '/api/http/put-resources-iam-clients-{id}-acl.md' --- --- --- url: '/api/http/put-resources-iam-clients-{id}-roles.md' --- --- --- url: '/api/http/delete-resources-iam-clients-{id}-roles-{roleKey}.md' --- --- --- url: '/api/http/get-resources-iam-clients-{id}-sessions.md' --- --- --- url: '/api/http/delete-resources-iam-clients-{id}-sessions-{sessionId}.md' --- --- --- url: '/api/http/get-resources-iam-clients-{id}-secrets.md' --- --- --- url: '/api/http/post-resources-iam-clients-{id}-secrets.md' --- --- --- url: '/api/http/delete-resources-iam-clients-{id}-secrets-{secretId}.md' --- --- --- url: /api/http/get-resources-components.md --- --- --- url: '/api/http/get-resources-components-{idOrPrefix}.md' --- --- --- url: '/api/http/post-resources-components-{componentId}.md' --- --- --- url: '/api/http/delete-resources-components-{componentId}.md' --- --- --- url: /api/http/get-events.md --- --- --- url: '/api/http/get-resources-files-{spaceKey}-{fileKey}.md' --- --- --- url: '/api/http/put-resources-files-{spaceKey}-{fileKey}.md' --- --- --- url: '/api/http/delete-resources-files-{spaceKey}-{fileKey}.md' --- --- --- url: /api/http/get-resources-graph.md --- --- --- url: /api/http/post-resources-graph.md --- --- --- url: '/api/http/get-resources-graph-{node}.md' --- --- --- url: /api/http/get-resources-ingresses.md --- --- --- url: '/api/http/get-resources-ingresses-{ingressId}.md' --- --- --- url: '/api/http/put-resources-ingresses-{ingressId}.md' --- --- --- url: '/api/http/delete-resources-ingresses-{ingressId}.md' --- --- --- url: '/api/http/get-resources-job-definitions-{definitionId}.md' --- --- --- url: '/api/http/put-resources-job-definitions-{definitionId}.md' --- --- --- url: '/api/http/delete-resources-job-definitions-{definitionId}.md' --- --- --- url: /api/http/get-resources-job-definitions.md --- --- --- url: /api/http/post-resources-jobs.md --- --- --- url: '/api/http/get-resources-jobs-{jobId}.md' --- --- --- url: '/api/http/delete-resources-jobs-{jobId}.md' --- --- --- url: /api/http/get-resources-jobs-statistics.md --- --- --- url: /api/http/post-resources-jobs-batch.md --- --- --- url: /api/http/get-resources-me-profile.md --- --- --- url: /api/http/post-resources-me-profile.md --- --- --- url: /api/http/post-resources-me-password.md --- --- --- url: /api/http/post-resources-me-email-change.md --- --- --- url: /api/http/post-resources-me-email-validate.md --- --- --- url: /api/http/get-resources-me-settings.md --- --- --- url: /api/http/post-resources-me-settings.md --- --- --- url: /api/http/get-resources-me-pat.md --- --- --- url: /api/http/post-resources-me-pat.md --- --- --- url: '/api/http/delete-resources-me-pat-{id}.md' --- --- --- url: /api/http/get-resources-iam-principals.md --- --- --- url: '/api/http/get-resources-iam-principals-{id}.md' --- --- --- url: '/api/http/put-resources-iam-principals-{id}.md' --- --- --- url: '/api/http/delete-resources-iam-principals-{id}.md' --- --- --- url: '/api/http/patch-resources-iam-principals-{id}.md' --- --- --- url: '/api/http/post-resources-iam-principals-{id}-password-reset.md' --- --- --- url: '/api/http/post-resources-iam-principals-{id}-suspend.md' --- --- --- url: '/api/http/post-resources-iam-principals-{id}-reactivate.md' --- --- --- url: '/api/http/delete-resources-iam-principals-{id}-roles-{roleKey}.md' --- --- --- url: '/api/http/get-resources-iam-principals-{id}-sessions.md' --- --- --- url: '/api/http/delete-resources-iam-principals-{id}-sessions-{sessionId}.md' --- --- --- url: '/api/http/put-resources-iam-principals-{id}-acl.md' --- --- --- url: '/api/http/put-resources-iam-principals-{id}-roles.md' --- --- --- url: /api/http/get-resources-registry.md --- --- --- url: /api/http/post-resources-registry.md --- --- --- url: '/api/http/get-resources-registry-{key}.md' --- --- --- url: '/api/http/post-resources-registry-{key}.md' --- --- --- url: '/api/http/delete-resources-registry-{key}.md' --- --- --- url: /api/http/get-resources-iam-roles.md --- --- --- url: '/api/http/get-resources-iam-roles-{roleKey}.md' --- --- --- url: '/api/http/put-resources-iam-roles-{roleKey}.md' --- --- --- url: '/api/http/delete-resources-iam-roles-{roleKey}.md' --- --- --- url: '/api/http/patch-resources-iam-roles-{roleKey}.md' --- --- --- url: '/api/http/put-resources-iam-roles-{roleKey}-acl.md' --- --- --- url: /api/http/get-resources-rules.md --- --- --- url: '/api/http/get-resources-rules-{idOrPrefix}.md' --- --- --- url: '/api/http/put-resources-rules-{ruleId}.md' --- --- --- url: '/api/http/delete-resources-rules-{ruleId}.md' --- --- --- url: /api/http/post-resources-sendings-email.md --- --- --- url: '/api/http/get-resources-sendings-{sendingId}.md' --- --- --- url: '/api/http/delete-resources-sendings-{sendingId}.md' --- --- --- url: /resources/components/runtimes/modules/web.md --- # web ## Availability --- --- url: /resources/components/runtimes/modules/xml.md --- # xml See official [Filtrera Documentation](https://www.filtrera.io/modules/xml/). ## Availability