Portals Platform

Technical flows

ReBound Portals Platform — technical flows

What actually happens behind every interaction in the platform. Each flow is traced from the user’s action through the component that handles it, the configuration it reads, the request it makes, the service that receives it, the processing that occurs, the response, how the front end reacts, and what changes downstream — including how each failure surfaces.

Real

Reproduces current ReBound behaviour. Contracts, field names and rules match production.

Simulated

The contract is real and enforced, but this platform answers it locally instead of calling the live service.

Proposed

Does not exist in ReBound today. Shown so the idea can be evaluated concretely.

Nothing in this document presents an assumption as existing architecture. Where a flow is a proposal, it is labelled as one and the work it would require is stated.

Configuration

How a change in the configuration panel reaches the systems that actually run a portal.

Editing a setting

Simulated

Every keystroke updates the preview immediately and is persisted to the platform's own backend on a short debounce. Validation runs locally so it is instant, and again on the server so the client is never the only guard.

In this platform:
Any configuration section, e.g. /consumer-portal/configure/return-reasons
In production:
rebound-admin-app (Consumer Portal), MyReBound (Customer Support Portal), retail-portal admin (Retail Portal), Postman (Headless API — no UI exists today)
  1. 1

    Types into a field, or toggles a switch.

    Handled by
    The section page calls useWorkspace.update(recipe), which structurally clones the workspace and applies the change.
    Configuration read
    • The whole workspace document
    Received by
    Browser only — no request yet
    Processing
    • buildSetupReport() re-runs against the new document
    • Section status, blocking issues and the sidebar dots all recompute
    Front end reacts
    Preview re-renders from the new configuration on the same frame. The save indicator shows “Unsaved changes”.
  2. 2

    Stops typing for 700 ms.

    Handled by
    The debounced writer inside the workspace store.
    Configuration read
    • The whole workspace document
    Request
    PUT/api/workspace

    The complete Workspace object

    Received by
    Portals Platform (Next.js route handler)
    Processing
    • configRepository.put() writes the document and stamps updatedAt
    • buildSetupReport() runs server-side and is returned alongside
    Response
    { workspace, report }
    Front end reacts
    Save indicator shows “Saved”. The server's report replaces the local one.
    Downstream
    • Nothing outside the platform — editing is not publishing

Whole-document writes are deliberate: the setup report spans every portal, so partial writes would let the client observe an inconsistent state.

Persistence is in-memory in this deployment. On a serverless runtime each cold start re-seeds from the baseline. Mounting a database-backed ConfigRepository is a single-file change.

Publishing configuration

Simulated

Publishing fans out to three services in dependency order. The Headless API portal is written first because the Consumer Portal store validates its return window against it. Each call is returned so the user can see that Save means several requests, not one.

In this platform:
The Publish button on any configuration section, or /setup
In production:
Today these are four separate manual steps in four different systems.
  1. 1

    Presses Publish.

    Handled by
    PublishButton → useWorkspace.publishConfig(scope)
    Configuration read
    • Which portals are enabled
    • The scope passed by the current section
    Request
    POST/api/config/publish

    { workspace, scope? }

    Received by
    Portals Platform
    Processing
    • Blocked entirely if buildSetupReport() reports any blocking issue
    Front end reacts
    The button enters its loading state.
  2. 2

    —

    Handled by
    hapiAdapter.savePortal()
    Configuration read
    • hapi.name
    • hapi.enabledFeatures
    • hapi.expirationPolicy.maxAge
    • hapi.parcelTypes
    • hapi.clientId
    Request
    PUT/api/portals/{name}/

    The HapiPortal object

    Received by
    Headless API
    Processing
    • validateFeatures() re-runs server-side — the same mutual-exclusion rules the UI enforces
    • A missing client application ID is rejected
    Response
    The stored portal
    Front end reacts
    An ApiEvent is appended to the activity log.
    Downstream
    • Any front end using this client application ID picks up the new configuration on its next request
    Failure states

    409 PORTAL_ENABLED_FEATURES_INVALID

    Cause: PAYMENT with DEDUCT_FROM_REFUND, or PAYMENT with CANCEL_RETURN

    Surfaced as: Publish stops, the message appears next to the button, and the events so far are kept so the user can see what already succeeded.

  3. 3

    —

    Handled by
    consumerAdminAdapter.saveStore()
    Configuration read
    • Everything under consumer.*
    • hapi.expirationPolicy.maxAge (for the cross-check)
    Request
    PUT/api/retailers/{id}

    The AdminRetailer shape — translations flattened to suffixed fields (headlineFR, contentDE …), secrets masked

    Received by
    Portals Platform (standing in for the Consumer Portal API)
    Processing
    • Subdomain format and uniqueness are checked
    • The return window is compared against the Headless API portal
    • Exchange and store-credit endpoints are required when those options are offered
    Response
    The stored AdminRetailer
    Front end reacts
    A second ApiEvent is appended.
    Downstream
    • The portal reads the store document on its next page load
    Failure states

    409 RETURN_WINDOW_MISMATCH

    Cause: consumer.returnPolicyDays differs from hapi.expirationPolicy.maxAge

    Surfaced as: Blocked before publish by the setup report; the adapter rejects it too, so the rule holds even if the UI is bypassed.

    409 SUBDOMAIN_TAKEN

    Cause: Another portal already uses that web address

    Surfaced as: Message next to the Publish button.

  4. 4

    —

    Handled by
    myReboundAdapter.publishCsPortalConfig()
    Configuration read
    • Everything under support.*
    • The client and flow
    Request
    POST/api/products/customer-service-portal/{clientId}

    The CsPortalConfig object

    Received by
    MyReBound (product-service)
    Processing
    • Feature combinations are validated (itemless returns require booking without an order)
    • A CustomerServicePortalConfig event is constructed
    Response
    The published event
    Front end reacts
    A third ApiEvent is appended.
    Downstream
    • product-service publishes the event to SQS
    • The Customer Support Portal consumes it and applies the settings
    • Agents see the change after their next sign-in

Ordering is not cosmetic. Writing the store before the Headless API portal would make the return-window cross-check compare against a stale value.

A partial failure is reported as such — the response carries every event, including the successful ones, so the user knows what did land.

Configuration to preview

Simulated

The preview is not a rendering of a saved document. It reads the same in-memory configuration object the form writes to, which is why it updates on the keystroke rather than on save.

In this platform:
The right-hand pane of any configuration section
  1. 1

    Changes any setting.

    Handled by
    ConsumerPreview subscribes to useWorkspace; ConsumerJourney takes config and hapi as props.
    Configuration read
    • The live workspace document, not the persisted one
    Received by
    Browser only
    Processing
    • useConsumerTheme() maps the chosen colour scheme to CSS custom properties on the preview root, exactly as production does through its ThemeProvider
    • Locale falls back to EN if the selected language is removed
    • Journey state lives in its own store, so moving between sections does not reset where the shopper had got to
    Front end reacts
    The preview repaints. A shopper standing on the item step sees the reason dropdown change underneath them.

The preview renders in a scaled fixed-width frame rather than a narrow container, so responsive breakpoints fire as they would on the real device.

Consumer Portal

The shopper's return journey, step by step, from finding an order to tracking the parcel.

Finding an order

Simulated

Order look-up requires two matching pieces of information and enforces the return window before anything else can happen.

In this platform:
Consumer Portal preview, landing screen
In production:
retailer-app: pages/search-order.tsx
  1. 1

    Enters an order number and their email or postcode, and submits.

    Handled by
    LandingStep → ConsumerJourney.lookup() → runReturnStep({ step: 'lookup' })
    Configuration read
    • consumer.userJourney — decides which landing screen is shown
    • consumer.returnPolicyDays — shown as “return within N days”
    Request
    GET/api/sales-orders/{clientOrderId}/?email=… | ?postalCode=…
    Received by
    Headless API — or Order Service v2 when ADAPTER_INTEGRATION is enabled
    Processing
    • The portal is resolved from the client application ID in the token
    • The Unicorn client is resolved from the portal
    • The order is read and the verifier is checked against it
    • Order age is compared against expirationPolicy.maxAge
    • Items already returned are marked non-returnable
    Response
    The sales order with its items, each carrying commercialValue (nullable) and a returnable flag
    Front end reacts
    The journey advances to item selection and the step rail appears.
    Failure states

    404 SALES_ORDER_NOT_FOUND

    Cause: No match, or the verifier does not match the order

    Surfaced as: An inline error at the top of the portal. Deliberately identical for both causes, so the endpoint cannot be used to confirm that an order exists.

    409 RETURN_WINDOW_EXPIRED

    Cause: The order is older than the configured window

    Surfaced as: An inline error naming both the order's age and the window, so the shopper understands why.

Choosing items and reasons

Simulated

The step that consumes the most configuration: reason structure, translations, comment rules and available outcomes all come from settings.

In this platform:
Consumer Portal preview, item selection
In production:
retailer-app: pages/return/[id]/select-items.tsx
  1. 1

    Ticks items, picks a reason, and chooses an outcome.

    Handled by
    SelectItemsStep
    Configuration read
    • consumer.nestedReturnReasonEnabled — one dropdown or two
    • consumer.returnReasons[].enabled and their order
    • subReasons[].hasComment / isCommentRequired
    • commentPlaceholder, translated per locale
    • consumer.returnOptions[].enabled — outcome buttons only appear when more than one is on
    Received by
    Browser only
    Processing
    • Continue stays disabled until every selected item has a reason, a sub-reason where nested, and any required comment
    Front end reacts
    The Continue button enables.
  2. 2

    Presses Continue.

    Handled by
    ConsumerJourney.toDraft()
    Configuration read
    • The reason and sub-reason codes behind the labels the shopper saw
    Request
    POST/api/return-orders/

    { orderCycleonId, items: [{ cycleonItemId, returnReasonCode, subReasonCode, comment, action }] }

    Received by
    Headless API
    Processing
    • A draft return order is created and the items are reserved
    • returnValue is summed from commercialValue — and is null if any selected item has no price
    Response
    { returnOrderId, status: DRAFT, returnValue, currency }
    Front end reacts
    Advances to the details step if it is enabled, otherwise straight to delivery options, loading them immediately.
    Downstream
    • Nothing is final: no label, no parcel, nothing published to your systems

The shopper sees display text; the API receives reason codes. That separation is what lets analytics compare returns across clients who word their reasons differently.

Choosing how to send it

Simulated

Delivery options are filtered to the shopper's country and priced only when a paid-returns capability is enabled.

In this platform:
Consumer Portal preview, delivery step
In production:
retailer-app: pages/return/[id]/select-carrier.tsx
  1. 1

    Reaches the delivery step.

    Handled by
    SelectCarrierStep → loadCarriers()
    Configuration read
    • hapi.parcelTypes — which postal products this portal offers
    • hapi.enabledFeatures PAYMENT / DEDUCT_FROM_REFUND — whether prices are returned
    • consumer.returnOptions[].isFree — a return where every chosen outcome is free is not charged
    • consumer.showLastCarrier — whether the previous choice is shown first
    Request
    GET/api/postal-services/?countryCode={country}
    Received by
    Headless API → Unicorn, and Payment Service when priced
    Processing
    • Postal products are read from Unicorn for this portal
    • Filtered to the shopper's country
    • Prices are read from Payment Service only when a paid capability is on and the return is not free
    • A sustainability score is derived from the CO₂e figure
    Response
    A list of postal services with price, lead time and score
    Front end reacts
    Options render. With DROP_OFF_MAP enabled, a drop-off finder link appears for anything that is not a collection.
    Failure states

    404 NO_POSTAL_SERVICES_AVAILABLE

    Cause: No enabled postal product exists for the shopper's country

    Surfaced as: An inline error naming the country. Reproducible in the playground via the “No delivery options” scenario.

Confirming the return

Simulated

The only irreversible step. Creates the parcel, generates the label, and publishes the return to your systems.

In this platform:
Consumer Portal preview, summary step
In production:
retailer-app: pages/return/[id]/summary.tsx
  1. 1

    Reviews the summary, applies a voucher if offered, ticks the agreement, confirms.

    Handled by
    SummaryStep → ConsumerJourney.confirm()
    Configuration read
    • consumer.confirmationCheckBox and confirmationTextMode
    • consumer.vouchersEnabled
    • hapi.enabledFeatures PAYMENT / DEDUCT_FROM_REFUND / BASE64_LABEL_IN_RESPONSE
    Request
    PATCH/api/return-orders/{returnOrderId}/

    { parcelType, confirm: true, voucherApplied }

    Received by
    Headless API
    Processing
    • The return order is confirmed and a parcel is created in Unicorn
    • A carrier label and tracking number are generated
    • With PAYMENT: Payment Service creates a payment link and the label is withheld until payment succeeds
    • With DEDUCT_FROM_REFUND: returnValue and refundValue are returned separately, no payment link
    • With BASE64_LABEL_IN_RESPONSE: encodedLabels carries the label inline; otherwise it is null
    Response
    { returnOrderId, status: CONFIRMED, rmaNumber, trackingNumber, labelUrl, qrCodeUrl, encodedLabels, returnValue, returnPrice, refundValue, redirectUrl, dropOffBy }
    Front end reacts
    The confirmation screen appears with the label and QR code. With PAYMENT, a payment notice is shown instead of releasing the label.
    Downstream
    • Pigeon publishes the return to your systems
    • With notifications enabled, Notification Service emails the shopper
    • The parcel enters the carrier's manifest
    Failure states

    409 PARCEL_TYPE_NOT_AVAILABLE

    Cause: The delivery option was removed from the portal between selection and confirmation

    Surfaced as: An inline error. The shopper is kept on the summary so they can choose again.

DEDUCT_FROM_REFUND changes the Headless API response only. Actually applying the deduction at refund time remains the client's responsibility.

Tracking a return

Simulated

Four carrier statuses, mapped to four shopper-facing states. Gated by the TRACKING capability.

In this platform:
Consumer Portal preview, tracking screen
In production:
retailer-app: pages/track-return.tsx
  1. 1

    Presses Track my return.

    Handled by
    TrackingStep → loadTracking()
    Configuration read
    • hapi.enabledFeatures TRACKING
    Request
    GET/api/return-orders/{returnOrderId}/tracking/
    Received by
    Tracking Service, via Headless API
    Processing
    • Carrier scans are read for this return
    • NO_TRACKING_AVAILABLE → status_10 → “Return registered online”
    • IN_POSTAL_NETWORK → status_20 → “In the carrier's network”
    • IN_LOCAL_WAREHOUSE → status_40 → “Received at our local hub”
    • ARRIVED_AT_DESTINATION → status_60 → “Delivered to the retailer”
    Response
    An ordered list of tracking events
    Front end reacts
    A four-stage timeline. Stages with no event render as not yet reached rather than being hidden, so the shopper can see what is still to come.
    Failure states

    403 FEATURE_NOT_ENABLED

    Cause: TRACKING is off for this portal

    Surfaced as: The track button is not rendered at all. Calling the endpoint directly still returns 403 — the UI is not the only guard.

Customer Support Portal

How agent-facing configuration reaches the portal your team uses.

Support portal configuration

Proposed

Today this configuration is authored in MyReBound and delivered to the portal as an SQS event. Authoring it here is the proposal; the event it produces is the real production shape.

In this platform:
/support-portal/configure/features
In production:
MyReBound product page → product-service → SQS → customer-service-portal
  1. 1

    Changes what agents can do and publishes.

    Handled by
    myReboundAdapter.publishCsPortalConfig()
    Configuration read
    • support.features — the CsPortalFeatures record
    • support.searchOptions
    • support.clientId when the returns-engine link is on
    Request
    POST/api/products/customer-service-portal/{clientId}
    Received by
    MyReBound (product-service)
    Processing
    • Feature combinations are validated
    • A CustomerServicePortalConfig event is constructed with client, implementation.flowKey, productCode and the feature flags
    Response
    The published event
    Front end reacts
    The support portal preview reflects the new capabilities.
    Downstream
    • product-service publishes to SQS
    • customer-service-portal consumes the event and applies the settings
    • Agents pick up the change on their next sign-in
    Failure states

    409 INVALID_FEATURE_COMBINATION

    Cause: Returns without item details enabled without booking without an order

    Surfaced as: Blocked before publish by the setup report.

    400 HAPI_CLIENT_ID_REQUIRED

    Cause: The returns-engine link is on with no application ID

    Surfaced as: Blocked before publish, and rejected by the adapter.

The newer OSv2 version of the Customer Support Portal drops the Headless API link entirely and calls Order Service v2 directly. The platform still models the link because both versions are in production during migration.

Agent accounts are governed by Keycloak group attributes (customer-service:portalName plus the default role mapping). Each agent belongs to exactly one support portal.

Cancelling a return

Simulated

The one action in the support portal with real, irreversible consequences — and four separate conditions that block it.

In this platform:
/support-portal/playground, results screen
  1. 1

    Presses Cancel on a return.

    Handled by
    hapiAdapter.cancelReturn()
    Configuration read
    • support.features.cancelReturn — whether the button exists
    • hapi.enabledFeatures CANCEL_RETURN — whether the call can succeed
    Request
    DELETE/api/return-orders/{id}/
    Received by
    Headless API
    Processing
    • Items are moved back to the original parcel and become returnable again
    • The Cycleon item IDs stay linked to the cancelled return
    Response
    { returnOrderId, status: CANCELLED }
    Front end reacts
    The row updates and the label can no longer be reprinted.
    Downstream
    • The shopper can create a new return for the same items
    • Tracking remains available, in case the old label is used anyway
    • The cancellation cannot be reversed
    Failure states

    403 FEATURE_NOT_ENABLED

    Cause: CANCEL_RETURN is off on the returns engine

    Surfaced as: Flagged as a blocking issue during setup, because the button would exist but always fail.

    409 RETURN_IN_POSTAL_NETWORK

    Cause: A tracking event already exists

    Surfaced as: The button is disabled with an explanation on hover.

    409 RETURN_ALREADY_PAID

    Cause: The shopper paid for the label

    Surfaced as: Rejected. The payment is not refunded automatically; a manual refund has to be requested.

    409 PICKUP_CANNOT_BE_CANCELLED

    Cause: A collection was booked

    Surfaced as: Rejected — the carrier cannot be told to stand down.

Retail Portal

B2B configuration for stores, franchisees and partners.

Retail portal configuration

Proposed

The configuration shape is the production PortalDTO. Authoring it here rather than in the Retail Portal's own admin is the proposal.

In this platform:
/retail-portal/configure/shipments
In production:
retail-portal admin → Portals → Add / edit portal
  1. 1

    Changes shipment types, templates or the reference format and publishes.

    Handled by
    myReboundAdapter.saveRetailPortal()
    Configuration read
    • The whole RetailPortalConfig / PortalDTO
    Request
    PUT/api/retail-portals/{id}
    Received by
    MyReBound
    Processing
    • Self sign-up requires a subdomain and an access group
    • Order visibility is capped at 180 days, because data is deleted after that
    • The client reference pattern is compiled to check it is a valid expression
    • Empty-box requests require a postal product
    Response
    The stored configuration
    Front end reacts
    The retail preview repaints, including the theme.
    Downstream
    • The Retail Portal reads its configuration on the next page load
    • Shipment templates are resolved from Shipment Service by country and service level
    • Locations are matched to the portal through their Keycloak attributes
    Failure states

    400 INVALID_REFERENCE_PATTERN

    Cause: The reference format is not a valid expression

    Surfaced as: Caught while typing — the field shows whether a sample reference would be accepted.

    400 VISIBILITY_PERIOD_TOO_LONG

    Cause: More than 180 days requested

    Surfaced as: Blocking issue during setup.

Per-location permissions are Keycloak attributes (retail-portal:createNewReturn, :seeStatusOfReturns, :processAReturnToStore, :countryPattern), not portal settings. The platform documents them but does not manage accounts.

A shipment template and the portal must share the same client and sorting type, and a location only sees templates matching its own service level. Both mismatches fail at label printing, not at configuration time — which is why they are surfaced as warnings here.

Notifications

Emails to shoppers, and the test-send path.

A notification reaching a shopper

Real

The production path. A return event is published, a Pigeon flow matches it to a template, and Notification Service delivers it.

In this platform:
Triggered by a return event, not by the platform
  1. 1

    A shopper confirms a return.

    Handled by
    Headless API, after confirmation succeeds
    Configuration read
    • Whether the Consumer Notifications product is configured for this client
    Received by
    Pigeon
    Processing
    • The return event is matched to a flow
    • The flow resolves the template for the trigger and locale
    • Merge fields are populated from the return and the order
    Front end reacts
    None in the portal — this happens after the shopper leaves.
    Downstream
    • Notification Service renders and delivers the message
    • Delivery, open and bounce events flow back for reporting

Templates live in Pigeon today. Authoring them in this platform is a proposal.

Sending a test notification

Simulated

Validated end to end, delivered nowhere. No message is ever handed to a mail provider from this platform.

In this platform:
/consumer-portal/notifications
  1. 1

    Types a recipient and presses Send test.

    Handled by
    notificationAdapter.sendTest()
    Configuration read
    • notifications.enabled
    • notifications.fromName and replyTo
    • The template being edited, including unsaved changes
    • The locale selected in the preview
    Request
    POST/api/notifications/send

    { to, from: { name, replyTo }, templateKey, locale, channel, variables, testMode: true }

    Received by
    Notification Service (simulated)
    Processing
    • Notifications must be enabled and the template must be on — a disabled template would never fire in production, so testing it would mislead
    • The recipient address is validated
    • A reply-to address is required
    • Two addresses fail deliberately so the error states are reachable in a demo
    Response
    { ok, recipient, messageId, simulated: true, durationMs }
    Front end reacts
    A success or error panel appears, labelled “simulated” in the success case, with a link into the activity log.
    Downstream
    • None — nothing leaves the platform
    Failure states

    409 NOTIFICATIONS_DISABLED

    Cause: Notifications are switched off

    Surfaced as: Error panel.

    409 TEMPLATE_DISABLED

    Cause: That notification is switched off

    Surfaced as: Error panel — a test that passed here would be misleading, since the message would never send.

    422 RECIPIENT_REJECTED

    Cause: bounce@example.com

    Surfaced as: Error panel. Reproducible on demand.

    422 SUPPRESSED_RECIPIENT

    Cause: blocked@example.com

    Surfaced as: Error panel. Reproducible on demand.

Conversational and messaging returns

Two proposed interfaces onto the return orchestration that already exists.

Conversational returns

Proposed

A dialogue manager over the existing return steps. It contains no returns logic: it decides what to ask, parses the reply, and calls the same endpoint the portal calls.

In this platform:
/consumer-portal/conversational
  1. 1

    Types a reply or taps a suggested answer.

    Handled by
    advance() in lib/conversation/runner.ts — a state machine over stages: await-order → await-verifier → await-items → await-reason → await-sub-reason → await-comment → await-outcome → await-address-check → await-carrier → await-confirm
    Configuration read
    • consumer.returnReasons — become the reply chips
    • consumer.nestedReturnReasonEnabled — whether a follow-up question is asked
    • consumer.returnOptions — become the outcome question, and are skipped entirely when only one is enabled
    • consumer.personalInfoStepEnabled — whether the address is confirmed
    Request
    POST/api/returns/simulate

    The same step payloads the portal sends

    Received by
    The shared return orchestration → Headless API adapter → services
    Processing
    • matchChoice() maps free text onto a configured option by exact match, substring, ordinal, or word overlap
    • The same adapter calls run, in the same order
    Response
    Identical to the portal's
    Front end reacts
    The reply is rendered as a message, with a card for orders, carriers, summaries and labels.
    Downstream
    • Identical to the portal — the same return is created

The claim that this is one engine with several front ends is testable: edit a return reason in configuration and the conversation asks a different question, with no code change.

Free-text understanding is deliberately transparent pattern matching, not a language model. What is proposed is the shape of the interaction and its mapping onto the existing steps; the language layer would be a separate decision.

WhatsApp-initiated returns

Proposed

A scripted demonstration rather than a live interface. The script is generated from live configuration, so a stakeholder sees their own reasons and delivery options, not invented content.

In this platform:
/consumer-portal/whatsapp
  1. 1

    Watches the thread play, or scrubs to a message.

    Handled by
    buildScript() reads the workspace and produces the turns
    Configuration read
    • consumer.name
    • consumer.returnReasons — become the interactive list
    • hapi.parcelTypes — become the delivery options
    • hapi.enabledFeatures PAYMENT / DEDUCT_FROM_REFUND — whether prices appear
    Received by
    Browser only — this flow makes no requests
    Processing
    • Each brand message is annotated with the call it would correspond to, shown in the panel beside the phone
    Front end reacts
    Messages animate in with a typing indicator between them.

Not built. There is no WhatsApp Business connection at ReBound.

What would be needed: a verified business number and approved message templates per client; a gateway holding conversation state per phone number; shopper identification, since a phone number is not an order; a language layer for free text; a payment link that still withholds the label until payment completes; and a data-protection decision about order contents in a third-party messaging service.

Platform

How this application itself is put together.

Support chat widget

Proposed

The reply is fixed by product decision. The endpoint exists so the widget behaves like an integration — a request goes out, a ticket reference comes back — rather than printing a string in the browser.

In this platform:
The widget in the bottom-right corner, on every page
  1. 1

    Sends a message.

    Handled by
    ChatWidget
    Request
    POST/api/chat

    { message, conversationId? }

    Received by
    Portals Platform
    Processing
    • Empty messages are rejected
    • A conversation id and ticket reference are issued
    • A deliberate delay so it reads as an agent picking the message up
    Response
    { conversationId, ticketReference, reply, respondBy, simulated: true }
    Front end reacts
    A typing indicator, then the reply with its ticket reference and a response-by date.
    Downstream
    • None — no support system exists to route this to

The activity log

Simulated

Every adapter call produces exactly one ApiEvent, returned alongside its result. The panel is fed by the same code path that produces the answer, so the record cannot drift from what happened.

In this platform:
“Under the hood” on a builder page
  1. 1

    Any interaction that reaches the service layer.

    Handled by
    call() in lib/server/adapters/types.ts
    Configuration read
    • Each adapter declares which settings shaped its request
    Received by
    Whichever service the call addresses
    Processing
    • The call is timed
    • A success produces status ok with the response attached
    • A failure produces status error with the code, HTTP status and message, and the error is rethrown for the caller to handle
    Response
    { data, event }
    Front end reacts
    The event is pushed onto the client store and appears at the top of the panel.

There is no separate logging layer that could report something the code did not do. If an event exists, a call was made.

Architecture notes

Decisions that shape everything above, including the ones that constrain what this build can honestly claim.

The adapter seam

Every outbound interaction is expressed through an adapter in lib/server/adapters. Pointing the platform at a real environment means implementing the same interfaces over HTTP against the TST base URLs and mounting them; nothing above that layer changes. This is why the simulated calls carry real paths, real payload shapes and real error codes — the seam only works if both sides of it agree.

One orchestration, several front ends

The Consumer Portal preview, the conversational interface and the WhatsApp script all reach the return steps through POST /api/returns/simulate. That is a property of the code rather than a diagram: there is no second implementation of the return journey anywhere in this application.

Validation runs twice, deliberately

buildSetupReport() runs in the browser so feedback is instant, and adapters re-run the same rules server-side so the UI is never the only guard. Headless API's feature mutual-exclusion, the return-window cross-check and the Customer Support Portal's feature combinations are all enforced in both places.

Configuration is one document

A Workspace holds every portal's settings plus the Headless API portal and notifications. Writes are whole-document because the setup report spans all of it. The spine is Client → Portal instances; each instance still carries an optional flowKey so multi-flow clients can be re-introduced without restructuring the model.

Known limitation: persistence

The default ConfigRepository is in-memory. On a serverless runtime each cold start re-seeds from the baseline, so configuration is not durable across deployments or long idle periods. The client keeps its own copy and re-persists on change, so a working session never loses anything. Durability is a matter of mounting a database-backed implementation of the same interface.