Skip to content

Amazon BFF Integration Guide (SPA)

This guide is for SPA developers integrating with the Amazon BFF routes as part of PDEV-477 (“Add from Amazon” UI) and future Amazon-adjacent features. It covers the practical choices that sit above the wire contract: when to call /import vs /search, how to handle the error envelope, recommended UX patterns per error code, and how to avoid common pitfalls (affiliate-tag stripping, over-eager caching).

This guide is not the wire contract. For field definitions, HTTP status codes, and the complete error matrix, see the Amazon BFF Routes reference.


SituationRouteReason
User has a single known ASIN (B08N5WRWNW) or pasted a full Amazon product URL/importSingle-item lookup via GetItems; returns one DTO or an error
User is typing in a free-text search box/searchKeyword search via SearchItems; returns up to 10 results
User pasted two or more ASINs (or URLs)/searchThe route dispatches to a GetItems batch internally; /import is single-item by contract
User pasted a list of UPC/EAN/ISBN identifiers/searchIdentifier mode triggers automatically when all tokens match identifier patterns

The BFF routes are authoritative for input classification. Client-side classification (see Input-Field Paste-and-Classify Pattern) is a UX optimization to avoid the round-trip — the server still validates and will return an error if the client’s guess was wrong.


The following typed helpers demonstrate how to call both routes from inside an SPA module. Adapt the base URL and token source to your auth layer.

import type { AmazonImportDto } from '@/lib/shared/amazon/types';
// ── Shared types ────────────────────────────────────────────────────────────
export type AmazonApiOk<T> = { ok: true; data: T };
export type AmazonApiError = { ok: false; code: string; message: string };
export type AmazonApiResult<T> = AmazonApiOk<T> | AmazonApiError;
export type ImportData = AmazonImportDto;
export type SearchData = {
items: AmazonImportDto[];
totalResultsHint?: number;
};
export type SearchRequest = {
query?: string;
keywords?: string[];
categories?: string[];
primeOnly?: boolean;
sortBy?: 'relevance' | 'price-low-to-high';
};
// ── importFromAmazon ─────────────────────────────────────────────────────────
export async function importFromAmazon(
input: string,
idToken: string,
signal?: AbortSignal,
): Promise<AmazonApiResult<ImportData>> {
const response = await fetch('/api/amazon/import', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${idToken}`,
},
body: JSON.stringify({ input }),
signal,
});
return (await response.json()) as AmazonApiResult<ImportData>;
}
// ── searchAmazon ─────────────────────────────────────────────────────────────
export async function searchAmazon(
request: SearchRequest,
idToken: string,
signal?: AbortSignal,
): Promise<AmazonApiResult<SearchData>> {
const response = await fetch('/api/amazon/search', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${idToken}`,
},
body: JSON.stringify(request),
signal,
});
return (await response.json()) as AmazonApiResult<SearchData>;
}

idToken is the Cognito ID token from your auth store. Pass an AbortSignal when you need to cancel in-flight requests (see Loading and Debouncing for /search).


Both routes return a discriminated union on the wire:

// Success
{ "ok": true, "data": { /* ... */ } }
// Error
{ "ok": false, "code": "SOME_ERROR_CODE", "message": "Human-readable default." }

The code field is the stable machine-readable contract; message is a BFF default that you may override in the UI. Recommended handling pattern:

const result = await searchAmazon(request, idToken, signal);
if (!result.ok) {
switch (result.code) {
case 'AUTHENTICATION_REQUIRED':
triggerAuthFlow();
break;
case 'INVALID_SEARCH_INPUT':
case 'INVALID_REQUEST':
showInlineFormError(result.message);
break;
case 'UNRECOGNIZED_AMAZON_URL':
showInlineFormError('We could not identify an Amazon Reference in your input.');
break;
default:
showToast('Amazon import temporarily unavailable. Please try again.');
Sentry.captureMessage(`amazon_bff_error:${result.code}`, 'warning');
}
return;
}
// result.ok === true
handleSuccess(result.data);

For the full error code list see Amazon BFF Routes — Error Code Matrix.


CodeHTTPRecommended UX
AUTHENTICATION_REQUIRED401Trigger the auth flow — redirect to sign-in or refresh the token and retry once. Do not show the raw error to the user.
INVALID_REQUEST400Inline form error near the input field; indicates a structural problem (malformed body). In practice this should not reach a user if the client builds the request correctly.
INVALID_SEARCH_INPUT400Inline form error near the search/query field — e.g. “Search query is too long” or “Too many ASINs (max 10)”. Use the message from the response when it is user-readable.
UNRECOGNIZED_AMAZON_URL422Inline below the input: “We could not identify an Amazon Reference in your input.”
UNSUPPORTED_SHORT_LINK422Inline below the input: “Expand the link first — short Amazon links (a.co, amzn.to) are not supported.”
UNSUPPORTED_AMAZON_LOCALE422Inline below the input: “Only US Amazon (amazon.com) products are supported.”
AMAZON_ITEM_NOT_ACCESSIBLE404Inline: “This product is not available via the Amazon API right now.”
AMAZON_API_THROTTLED429Toast: “Amazon is busy — try again in a moment.” Consider a short automatic retry after 2–3 seconds for user-triggered actions.
AMAZON_API_UNAVAILABLE502Toast: “Amazon import temporarily unavailable.” Add a Sentry breadcrumb on the client side so the error is correlatable with backend traces.
AMAZON_API_ERROR502Same as AMAZON_API_UNAVAILABLE/search collapses all SDK failures into this code.

When the “Add from Amazon” UI exposes a single shared input field, classify the pasted value client-side before deciding which route to call. This avoids a round-trip and lets you show the right loading state:

import { extractAsin } from '@/lib/shared/amazon/asin';
type Classification =
| { route: 'import'; input: string }
| { route: 'search'; query: string }
| { route: 'search'; query: string; multiAsin: true };
export function classifyInput(raw: string): Classification {
const trimmed = raw.trim();
// Single ASIN or Amazon URL → /import
const strictResult = extractAsin(trimmed);
if (strictResult.ok) {
return { route: 'import', input: trimmed };
}
// Multiple whitespace/comma-separated tokens that all look like ASINs → /search (batch)
const tokens = trimmed.split(/[\s,;]+/).filter(Boolean);
if (tokens.length > 1 && tokens.every((t) => extractAsin(t).ok)) {
return { route: 'search', query: trimmed, multiAsin: true };
}
// Everything else → /search (keyword)
return { route: 'search', query: trimmed };
}

The /search route caps batch GetItems requests at BATCH_ASIN_MAX = 10 ASINs. When the query contains more than 10 ASIN-shaped tokens the route returns:

{
"ok": false,
"code": "INVALID_SEARCH_INPUT",
"message": "Too many ASINs in query. Maximum is 10; got 12."
}

Recommended UX: detect this case client-side before submitting and surface a friendly message (“You can paste up to 10 ASINs at a time”) to avoid the round-trip. Use the classifyInput helper above — count the resolved ASIN tokens and warn before calling the route.


The /search route can make up to 1 + RELAXATION_MAX_RETRIES = 3 Amazon API calls when silent relaxation fires. With a RELAXATION_TIME_BUDGET_MS = 1500 ms wall-clock cap, the end-to-end response time may reach 1.5–2 seconds. Budget your loading spinner accordingly.

Recommended patterns:

  • Debounce user-typed queries by 300–500 ms before firing the request. This prevents a request per keystroke while still feeling responsive.
  • Cancel in-flight requests when the input changes using AbortController — pass the signal to searchAmazon. The previous result should never overwrite a newer one.
  • Show a spinner from the moment the debounce fires until the response arrives (including the possible 1.5 s relaxation window).

Example with AbortController:

let currentController: AbortController | null = null;
function onInputChange(value: string) {
// Cancel the previous in-flight request.
currentController?.abort();
currentController = new AbortController();
// Debounce: wait 400 ms before sending.
clearTimeout(debounceTimer);
debounceTimer = setTimeout(async () => {
setLoading(true);
try {
const result = await searchAmazon(
{ query: value },
idToken,
currentController!.signal,
);
handleResult(result);
} catch (err) {
// `fetch()` rejects with `AbortError` when the AbortController fires;
// the call never reaches the BFF, so there is no envelope to inspect.
// Discard aborts silently — a fresh request is already in flight.
if (err instanceof DOMException && err.name === 'AbortError') return;
throw err;
} finally {
setLoading(false);
}
}, 400);
}

When an AmazonImportDto carries a non-null productUrl, the SPA must pass it through to any “View on Amazon” button or external-link handler without modification. Amazon embeds Arda’s affiliate tag and tracking parameters (linkCode, tag, language, th, psc) into this URL at the API level.

Do not:

  • Strip query parameters with new URL(url).origin + pathname.
  • Rebuild the URL from the ASIN using buildAffiliateUrl() when a productUrl is already present.
  • Run the URL through a URL-shortening or redirect service.

Do:

// Correct — use verbatim
<a href={item.productUrl} target="_blank" rel="noopener noreferrer">
View on Amazon
</a>
// Wrong — strips affiliate tag
const cleanUrl = new URL(item.productUrl).origin + new URL(item.productUrl).pathname;

When productUrl is null, the item has no Amazon-provided detail-page link. In that case you may use buildAffiliateUrl(item.asin) from src/lib/shared/amazon/affiliate-url.ts as a fallback, but note this URL will not carry Amazon’s full tracking parameters.

See the Affiliate-Tag Rule section of the wire contract for Amazon’s verbatim guidance.


image.url in each AmazonImportDto is Amazon’s CDN-hosted URL passed through unchanged. The Amazon IP License prohibits permanent storage of image content; client-side caches must respect a 24-hour ceiling.

Recommended settings for common data-fetching libraries:

LibrarySettingValue
React Query / TanStack QuerystaleTime + gcTime (cacheTime in v4)staleTime: 20 * 60 * 1000 (20 min); gcTime: 24 * 60 * 60 * 1000 (24 h)
SWRdedupingInterval + revalidateOnFocusdedupingInterval: 20 * 60 * 1000; revalidateOnFocus: true
Browser cache (Cache-Control)Enforce via no-store or max-age on the BFF responseBFF does not set image-specific cache headers; respect Amazon’s CDN headers on the image URL itself

These settings prevent the image URL (not the image bytes) from being treated as infinitely fresh. Do not persist image.url values to local storage beyond 24 hours.


The arda-frontend-app repository ships MSW handlers for both BFF routes:

  • Layer-1 (network-boundary) mockssrc/mocks/handlers/amazon.ts. These intercept actual fetch calls at the network boundary and are the recommended default for component integration tests and Playwright E2E tests.
  • Layer-2 (route-module) mocks — for pure unit tests of components that invoke the route module directly, use jest.mock('@/server/routes/amazon/search') and jest.mock('@/server/routes/amazon/import'). This bypasses the network entirely and lets you inject specific AmazonSearchResult / AmazonImportResult values.

Always use MSW Layer-1 mocks in E2E tests. Layer-2 mocks are appropriate for small, focused component unit tests where you want to test UX branch coverage (error states, loading states) without wiring up the full mock server.