Skip to content

Scan to Item — Backend

The backend accepts a product identifier and returns a draft, offers, images, confidence, and the evidence used to choose each field. It is written in TypeScript and runs as in-process modules inside the arda-frontend-app BFF, behind POST /api/enrichment/import, mirroring /api/amazon/import.

The engine is responsible for:

  • normalizing the scanned or typed source;
  • selecting and running the providers that support that source;
  • separating exact identity lookup from later product and supplier discovery;
  • reconciling provider candidates into one reviewable result;
  • preserving competing evidence and provider traces; and
  • isolating provider timeouts and failures.

It does not create Items or ItemSupplies, own tenant-authored catalogs, or silently convert package quantities. The accepted draft continues through the existing Operations write path.

The component view below separates identity providers, discovery, packaging suggestions, and deterministic reconciliation inside the engine.

PlantUML diagram

The route classifies adapters as identity or discovery sources and starts identity lookups together. As each one resolves, its candidates and trace are added to the pending result. The first authoritative identity candidate lets discovery start immediately while slower identity providers keep running in parallel — this shortens total latency without changing the single-response contract.

The call resolves once every configured adapter has matched, returned no match, failed, or timed out, and returns one reconciled response. Provider completion order does not affect field selection.

POST /api/enrichment/import in the arda-frontend-app BFF is a thin route handler → route module → the in-process engine under src/server/lib/enrichment, mirroring /api/amazon/import. The request is { input: "<barcode | product URL | free text>" }; classification is server-side, not chosen by the caller. The call is synchronous: the route waits for identity and discovery adapters to finish, reconciles their output, and returns one response. There is no run resource, no idempotency key, and no polling.

OutcomeStatus
success200
INVALID_REQUEST, UNRECOGNIZED_INPUT400
AUTHENTICATION_REQUIRED401
NO_MATCH404
ENRICHMENT_UNAVAILABLE502
ENRICHMENT_TIMEOUT504 — reserved; no current path produces it (an all-provider timeout surfaces as ENRICHMENT_UNAVAILABLE)

The response is { ok: true, data: EnrichmentImportDto } or { ok: false, code: EnrichmentErrorCode, message }, the same discriminated envelope as /api/amazon/import. Auth is getBffAuthHeaders (a Bearer access token), identical to the Amazon route.

type EnrichmentImportDto = {
name: string | null;
brand: string | null;
description: string | null;
image: string | null; // primary; also images[0]
images: string[]; // distinct product images, best first
price: { amount: number; currency: string } | null;
unit: string | null; // free-text package label, e.g. "pack of 12"
unitCount: number | null;
packaging: { // reconciled purchasing suggestions; null when none resolved
unitOfMeasure: string | null;
unitsPerPackage: number | null;
minimumOrderQuantity: number | null;
orderIncrement: number | null;
priceBasisQuantity: number | null;
} | null;
barcode: string | null; // GTIN / UPC
mpn: string | null;
productUrl: string | null; // canonical source page
confidence: 'matched' | 'needs_review';
offers: EnrichmentOffer[]; // ranked, canonical source first
};

Nullable fields follow the Amazon convention: null means no source provided this, not an error.

Each EnrichmentOffer carries supplier, supplierSku, title, url, image, price, and verdict. Offers are ranked best verdict first; the top-level price and productUrl are recomputed from the final merged offer list, so an exact Amazon offer is not buried behind a related engine offer.

The frontend can show its own loading state while the request is in flight — a scan can take a few seconds while identity and discovery adapters run — but that is presentation only. One POST returns the full, reconciled result; the frontend never issues a second request to check on it.

The current source field accepts three forms:

InputNormalizationCurrent use
8–14 digit barcodeRemoves spaces and hyphens and stores a barcode keyExact catalog and marketplace identity lookup
HTTP or HTTPS URLCanonicalizes the URLFixture or supplier matching; available to extraction adapters
Any other non-blank stringNormalizes case and whitespace for the lookup keyFixture and supplier matching; available to extraction adapters

Plain text is a search input. The BFF fans out to Amazon’s /api/amazon/search and to Exa, and the reconciler merges the two. These results remain needs_review because keyword relevance is not exact product identity.

Amazon product URLs use the fast identity path. The Amazon import route validates the host, extracts the ASIN from supported /dp/, /gp/product/, or /gp/aw/d/ paths, calls Amazon GetItems, and rejects a response with another ASIN. That exact draft is published before optional discovery begins. Lookalike hosts and URLs without a supported ASIN path do not reach Amazon at all.

OCR does not need a separate reconciliation system. The clean extension is an image input that references an uploaded asset, followed by an OCR adapter that emits text, barcode, and product candidates with evidence. Exact identifiers can enter the existing identity stage; descriptive text can enter discovery or model extraction and remain non-authoritative until corroborated.

The engine’s internal input currently accepts only string source plus optional hints and provider selection, so image support needs an input-shape addition. Two reasonable shapes are:

  • upload the image through an existing file service, then call /api/enrichment/import with a discriminated input such as { "input": { "type": "image", "assetId": "..." } }; or
  • expose a dedicated image-enrichment endpoint that accepts multipart upload and returns the same EnrichmentImportDto shape.

An asset reference is the better default because it keeps large payloads out of the import request and makes authorization and retention explicit. The adapter, provider choice, file limits, retention rules, and consent requirements are future work; the reconciler does not need to change.

Structured barcode catalogs do not cover every product. During the prototype, 0810086790630 returned no match from Barcode Lookup, UPCitemdb, and Amazon. An ordinary exact web search found a supplier page that printed the UPC, while the normal five-result Exa search did not. The local demo now has a fixture for that evaluated item, and Exa also has a miss-only fallback for uncatalogued barcodes.

When the identity stage produces no candidate, Exa switches from the normal five-result fast query to a quoted, 25-result auto query and requests page text. The adapter accepts a result only when the title, URL, highlights, or returned page text contains the same normalized barcode. The candidate keeps the page URL as evidence and remains non-authoritative and non-cacheable, so the run is needs_review.

Exa does not expose a strict lexical-search mode. It finds a broader candidate set here; the adapter’s exact page-text check is what makes acceptance deterministic.

This does not loosen the normal path. Catalog matches still use their existing exact checks, and resolved products still use the smaller Exa discovery query. The wider request is only paid for after a complete identity miss.

SourceStageAcceptance and use
Barcode LookupIdentityUses the exact barcode response for product identity, images, and merchant offers
UPCitemdbIdentityUses the exact barcode response for identity, images, and merchant offers
Open Food FactsIdentityUses an exact barcode response for food identity and package data
Fixture catalogIdentitySupplies deterministic products in local development
Supplier sample catalogIdentitySupplies bundled Grainger, Uline, and McMaster examples in local development
ExaDiscoveryUses a five-result fast search for resolved identity and requires exact barcode or manufacturer-part evidence; after a total barcode miss, uses a quoted 25-result auto search and still requires the barcode in returned page content; plain text returns non-authoritative suggestions
OpenAI packagingDiscoveryUses the resolved product fields to suggest unit type, units per package, minimum order, order increment, price basis, and a package label; every value is non-authoritative and reviewable

The engine has no Amazon adapter of its own: Amazon URL/ASIN lookups delegate to /api/amazon/import, and freeform text fans out to /api/amazon/search alongside Exa, merged by the same reconciler. See Front door: wrapping Amazon.

Exa runs after identity resolution and remains discovery evidence. A plausible web page cannot make an otherwise unverified product authoritative.

The packaging adapter calls the OpenAI Responses API with a strict JSON schema. It defaults to gpt-5.4-nano and returns null for fields it cannot support. It is enabled automatically whenever OPENAI_API_KEY is set, so once that key exists in an environment it runs on every scan within a three-second budget. Set ENRICHMENT_PACKAGING_MODEL_ENABLED=false to force it off. The adapter cannot change the product name, brand, identifiers, supplier, or price. It only ever receives resolved product fields, never the user’s raw input. The reconciler records each proposed packaging field as openai-packaging evidence, and the frontend asks the user to confirm non-default quantities and units before saving.

The adapter interface is the main place this grows. New sources plug in behind the same identity and discovery stages and the same reconciler, so later product features are additive rather than special cases — a tenant catalog adapter that matches a scan against a tenant’s own approved items and SKUs before any external call, or a customer-supplied match set such as an uploaded price list or parts catalog. Each new adapter keeps the same evidence, authority, and cache rules as the built-in ones.

UPCitemdb is useful as a second general barcode catalog, although product and merchant quality vary by category. Open Food Facts has detailed open package data for food but does not cover Arda’s broader inventory. Amazon adds exact marketplace identity and current commerce data, with credential and freshness constraints.

Imported tenant, supplier, and manufacturer catalogs could provide higher-value aliases, package quantities, supplier SKUs, and specifications. They need ingestion, ownership, and refresh workflows that do not exist in the prototype. Generic web search and model extraction have broad reach but require more extraction and corroboration; Exa was chosen because it supports resolved product context, supplier profiles, domain constraints, and exact-identifier evidence.

/api/enrichment/import is the only route the frontend calls. Classification in normalizeSource decides how each input reaches Amazon; three of the four paths route themselves unambiguously, and one is a genuine merge.

InputRouted toAmazon involvement
BarcodeEngine catalogs (Barcode Lookup, UPCitemdb, Open Food Facts)None
Amazon URL / ASINDelegates to /api/amazon/importExclusive — Creators API GetItems, mapped 1:1 onto EnrichmentImportDto
Non-Amazon URLEngine scrape (Exa contents)None
Freeform textBoth /api/amazon/search and the engine’s Exa-search, merged by the reconcilerThe one genuine overlap

The Amazon route modules are imported lazily, on the Amazon paths only, so a barcode scan never loads the Amazon client or requires Amazon credentials to be configured.

The freeform-text merge is the crux under review: whether wrapping Amazon behind one front door is worth the coupling it creates — for freeform text, enrichment depends on the Amazon route being up — against keeping /api/amazon/import and /api/enrichment/import as composable peer routes and pushing that choice to the client. See Open decisions.

The reconciler sorts candidates by authority first, then by source type:

  1. tenant;
  2. manufacturer;
  3. fixture;
  4. catalog;
  5. marketplace;
  6. supplier; and
  7. web.

The fixture position keeps local demos deterministic and is not a production trust decision. When Barcode Lookup and Amazon both return authoritative but conflicting field values, Barcode Lookup currently wins because catalog ranks above marketplace.

The highest-ranked value becomes the draft field, while every competing value stays in evidence. Confidence becomes matched when one authoritative source has an exact identifier or when two independent sources have sufficiently similar names. Other results remain needs_review.

Packaging is reconciled field by field rather than selecting one provider’s whole package object. A catalog can supply units per package while the model suggests a minimum order or unit label; each selected and competing value keeps its own evidence record.

Offers are evaluated separately:

  • exact means identifier, title, and available package evidence agree;
  • equivalent means the product matches but package or presentation differs; and
  • related is a useful lead that should not be treated as the scanned item.

Images are deduplicated by URL. Placeholder and logo URLs are demoted, authoritative candidates come next, and known or inferred resolution breaks remaining ties.

The current values were chosen while testing known barcodes and specific failure cases. The live suite has nine cases: two barcode identity matches, a negative short-code case, and six supplier-URL cases; unit cases cover unrelated offers, package mismatches, corroboration, source precedence, and image choice.

ValueUsed forBasis
tenant 7 → web 1Source orderingRepresents the intended order. Only relative order matters; authority is checked first.
0.2 name overlapOffer title guardAllows minor title differences while rejecting titles with almost no shared words.
0.5 name overlapOffer equivalence and source agreementRequires at least half of the combined distinct words to overlap.
2 character token minimumName comparisonRemoves one-character words that add noise.
2 agreeing sourcesConfidenceAllows independent corroboration to produce matched without an authoritative source.
exact 0, equivalent 1, related 2Offer sortingPreserves the order shown to the user.
640,000 inferred pixelsImage rankingTreats a zoom URL as roughly 800×800 when dimensions are missing.
4 images and 5 offersCandidate limitsKeeps one catalog from flooding the response.
5s / 10sTotal wall-clock budget: barcode / URL-and-textEnforced across both phases; an adapter’s own hint can shrink but never exceed the remaining allowance. Set from live measurements: barcode identity resolves in ~2–3.5s, unbounded supplier-URL crawls measured 6.5–13.7s, so the slowest pages are traded for a bounded wait.
4s / 3sExa / packaging-model budget hintsReflects the expected relative latency of web-search and small-model calls, within the total budget above. The Exa budget applies to either its normal or miss-only request, not both.

As the evaluation set grows, each failure should become a case with the expected identity, offer, package, and image choice. Change a threshold only when it fixes that case without breaking the existing set. When several values work, prefer the value that avoids false matches.

These prices were checked on 13 July 2026. Provider plans change, so the engine should record the provider-reported request cost when available and the team should recheck this table before committing to a production volume.

ProviderPublished price used hereCost in the current flow
Barcode LookupStarter: $99/month for 5,000 calls; Advanced: $249 for 25,000; Professional: $499 for 100,000; Enterprise: $949 for 500,000At full use, about $0.0198, $0.00996, $0.00499, or $0.001898 per call. The monthly fee is paid even when volume is lower.
ExaSearch starts at $7 per 1,000 requests; returned content and larger result sets can add costThe normal five-result fast search was $0.007 in the live check. The 25-result catalog-miss query with page text was $0.022. Its costDollars response should be the accounting source of truth.
OpenAI GPT-5.4 nano$0.20 per million input tokens and $1.25 per million output tokensAn estimated 800 input and 120 output tokens costs about $0.00031. Actual token use should be logged by model and adapter version.
UPCitemdbExplorer: 100 requests/day; DEV: $99/month for 20,000 lookups/day; PRO: $699/month for 150,000/day; documented overage is $0.04 per 100Marginal cost is zero inside a purchased quota, but the subscription still belongs in the monthly allocation. Overage is $0.0004 per call.
Open Food FactsNo metered fee is documented for the public API; product reads are rate-limitedTreat it as rate-limited shared infrastructure, not unlimited free capacity.
Amazon Creators APINo per-call API fee is documentedAccess, attribution, usage policy, and sales-linked rate limits are the constraints. Amazon commerce data is not a general-purpose cache.

For a default barcode scan on the Barcode Lookup Starter plan, assuming every scan also runs Exa and the packaging model, the fully utilized marginal model is approximately:

$0.0198 + $0.007 + $0.00031 = $0.02711 per scan.

That 2.71-cent figure is only reached when the 5,000-call Barcode Lookup quota is used. Lower volume has a higher effective cost because of the fixed monthly fee:

Monthly scansBarcode Lookup tierBarcode + Exa + modelEffective cost per scan
1,000$99 Starter$106.31$0.10631
5,000$99 Starter$135.55$0.02711
25,000$249 Advanced$431.75$0.01727
100,000$499 Professional$1,230.00$0.01230

The table excludes taxes, failed-call credits, UPCitemdb subscriptions, and provider plan changes. It assumes one Exa search and an 800-input/120-output model call for every scan. Skipping discovery or the model reduces cost. Catalog misses that use the wider Exa request add about $0.015 over the normal Exa assumption in this table.

An Amazon URL or ASIN request delegates to /api/amazon/import and does not call Exa or the model; it has no metered provider charge identified in the published Amazon documentation. A default Amazon URL request returns the exact Amazon draft first, then may spend about $0.00731 on Exa and packaging suggestions while the user is already reviewing the form.

Cost reporting should separate fixed subscription spend, provider-reported variable cost, and effective cost per successful draft. The useful operational metrics are cost per submitted scan, cost per completed enrichment call, and cost per accepted item; cost per API call alone hides no-match and retry waste.

Each provider runs with its own timeout and returns a trace with status, duration, and candidate count. One failed or timed-out provider does not stop the others. A run fails only when every applicable provider fails; successful lookups with no candidates finish as needs_review.

The route wraps the whole engine call: an unexpected failure — a provider throwing, or adapter setup failing — is reported to Sentry (tagged route: enrichment.import) and degrades to ENRICHMENT_UNAVAILABLE rather than an unhandled 500. Provider errors are never returned as raw bodies to the client. Built adapters are cached across requests, so a warm instance does not rebuild them or re-read the environment per scan. Fuller structured logging (request ID, per-provider metrics) is still future work.

Retries need provider-specific treatment. Safe GET-like catalog lookups can be retried within the request’s provider budgets; rate limits and non-idempotent provider behavior must be handled explicitly rather than through a blanket retry policy.

The engine can scope by tenant — the reconciler ranks tenant sources highest and the supplier catalog filters by tenant — but the BFF port does not thread a real tenant yet: every run uses an empty tenant context. Wiring it is future work and needs the BFF to authenticate the Arda session and derive tenant and author from trusted claims before enrichment reads anything tenant-owned.

Provider credentials stay in the BFF’s server-side environment. They do not appear in the browser bundle, response, source evidence, or source traces.

There is no tenant projection in the BFF port today. When one is added and wired to Operations, that read must use the authenticated tenant context and must not let aliases, catalog records, or supplier policy cross tenant boundaries.

The BFF port holds no durable state: each scan runs the adapters and returns a result, with nothing written between calls. Result memory and tenant catalogs are design targets, not current behavior.

StateCurrent implementationFuture work
Result memoryNone — every scan re-runs the providersDecide whether reusable data is provider output, user-approved output, or both, then add a store
Tenant catalogs and policyNone wiredOperations-owned projection or module integration

The intended design when this is built:

  • Result memory: a two-method store (find/put) keyed by tenant plus the normalized source key. Write a result only when matched and every contributing candidate is cacheable, so live commerce data never enters memory. In the synchronous route this means: check memory before the provider fan-out, return a hit immediately.
  • Tenant policy (preferredSuppliers, allowedSupplierDomains, categoryHints) already has a consumer: the Exa search adapter in the BFF reads it to target supplier domains. Only the projection reader that feeds it from Operations is missing — thread the authenticated tenant and implement the reader, and the engine side works as-is.

The enrichment response already records provider status, duration, candidate count, field evidence, and selected values. Unexpected failures report to Sentry tagged by route. Because Barcode Lookup requires its key as a URL query parameter, the Sentry server config redacts secret-style query parameters (key, token, and similar) from breadcrumbs, request URLs, and trace spans before an event is sent. Future telemetry should aggregate:

  • provider latency, timeouts, failures, and rate-limit responses;
  • matched, needs-review, and no-match rates;
  • first-adapter and total request latency;
  • coverage and false-match rate from reviewed results;
  • provider request cost, token use, and fixed-plan allocation; and
  • fields changed by the user before save.

The current live corpus has nine cases: two barcode identity matches, a negative short-code case, and six supplier-URL cases. The next evaluation should use 30–50 physical items from real Arda environments, with ground truth recorded before the lookup. Production promotion should require the precision, coverage, latency, resilience, and credential-safety gates in the overview.

  • Wire TenantProjectionReader to approved aliases, existing items, and supplier preferences from Operations.
  • Decide what result memory means, then define provider-specific freshness and invalidation rules.
  • Replace bundled supplier samples with an owned ingestion and refresh path.
  • Evaluate packaging suggestions against the physical-item corpus and tune the prompt, allowed unit vocabulary, and UI review language from those results.
  • Decide whether URL and free-text identity extraction needs another model adapter beyond the implemented Amazon and Exa search paths; keep any such identity non-authoritative until corroborated.
  • Define the image asset contract and add an OCR adapter that produces normal identifier and text evidence.
  • Evaluate the miss-only Exa path across more uncatalogued barcodes and monitor its exact-match rate, latency, and reported cost.
  • Add structured metrics and session-derived production auth.

Enrichment runs in the arda-frontend-app BFF, at POST /api/enrichment/import next to /api/amazon/import. That route already attaches auth and keeps provider secrets on the server, so enrichment just reuses it — no new service to deploy, no new secret shape, and no Operations change beyond the provider keys the prototype already needs.

Alternatives considered: a standalone service (duplicates the auth and secret handling the BFF already has) and an Operations module (one fewer deployment, closer to the tenant catalog, but a larger change than reusing the Amazon route).