Search for a command to run...
To integrate the ApiShip plugin into the Medusa DTC Starter by Gorgo storefront, register ApiShip with the starter's shipping extension points and add the components that collect the customer's delivery choice at checkout.
An ApiShip shipping option has no fixed price. The customer picks a carrier tariff first, and for delivery to a pickup point also the point itself. The storefront loads both from the plugin's store routes, shows them in a two-step delivery sheet, and saves the choice in the of the cart's shipping method. The plugin then checks the choice, prices the shipping method by the picked tariff, and creates the ApiShip order from it when you fulfill the order.
The starter ships no calculated shipping provider of its own, only the extension points one plugs into:
For the storefront on Next.js, you need to make the following changes:
The pickup point map is built on Yandex Maps JavaScript API v3. The API itself loads from Yandex in the browser. Two npm packages add the clusterer that groups nearby pickup points into one marker, and the API's type definitions.
Install them in the storefront app, :
1pnpm add @yandex/ymaps3-clusterer@0.0.122pnpm add -D @yandex/ymaps3-types@1.0.21414904These are the versions the example storefront uses.
The map needs a key for the JavaScript API. Create one in the Yandex developer dashboard under "JavaScript API and Geocoder", then add it to the storefront's :
.env.localNEXT_PUBLIC_YANDEX_MAPS_API_KEY=supersecret
The key is public, since the map runs in the customer's browser. Next.js embeds variables into the build, so rebuild the storefront after you change it. Without the key, the pickup point sheet shows a message that the key is missing, and courier delivery keeps working.
ApiShip delivers within Russia, so the example also sets to open the storefront in the Russian region.
ApiShip needs the recipient's phone number to calculate delivery and to create the order. The checkout collects contacts before it gets to delivery, so make the phone field required there.
Open and mark both phone inputs as , the customer's own and the one for a different recipient:

src/modules/checkout/components/checkout-contacts-sheet/index.tsx1<Input2 label={t("fieldPhone")}3 name="phone"4 autoComplete="tel"5 value={formData.phone}6 onChange={handleChange}7 required8 data-testid="shipping-phone-input"9/>1011// ...1213{isDifferentRecipient && (14 // ...15 <Input16 label={t("fieldPhone")}17 name="recipient_phone"18 autoComplete="tel"19 value={formData.recipient_phone}20 onChange={handleChange}21 required22 />23)}
The browser no longer submits the contacts sheet without a phone number, so every calculation and every ApiShip order has one.
The plugin's store routes return carrier tariffs, pickup points and carrier names. The storefront describes the fields it reads from them in its own types, together with the delivery choice it saves on the shipping method.
Create with the following content:

src/types/apiship.ts83}8485export type ApishipStoredTariff = Omit<ApishipTariff, "pointIds">8687export type ApishipStoredPoint = Pick<88 ApishipPoint,89 "id" | "providerKey" | "name" | "address"90>9192/**93 * What the customer picked, stored on the shipping method so a reload restores it and the94 * plugin can create the ApiShip order from it. `deliveryType` is 1 for an address and 295 * for a pickup point, matching the shipping option it was chosen under.96 */97export type ApishipSelection = {98 deliveryType: number99 tariff: ApishipStoredTariff100 point?: ApishipStoredPoint101}102103export type ApishipDeliveryChoice = {104 calculation: ApishipCalculation105 points: ApishipPoint[]106}
The storefront declares these types itself rather than importing them from , because that entry points at TypeScript sources that don't resolve outside the plugin's own build. Every field is optional, since carriers fill them in unevenly.
is what the checkout saves under in the shipping method's . Its is for courier delivery and for a pickup point, matching the option the customer picked it under.
The storefront calls three plugin routes. One calculates the tariffs and pickup points for the cart, one returns the carriers, and one takes a shipping method off the cart.
Create with the following content:

src/lib/data/apiship.ts1"use server"23import { sdk } from "@lib/config"4import medusaError from "@lib/util/medusa-error"5import type {6 ApishipCalculation,7 ApishipDeliveryChoice,8 ApishipPoint,9 ApishipProvider,10 ApishipRawPoint,11} from "types/apiship"12import { revalidateTag } from "next/cache"13import {14 getAuthHeaders,15 getCacheOptions,16 getCacheTag,17 getCartId,18} from "./cookies"1920const toCarrierGroups = <T extends { providerKey?: string; tariffs?: unknown }>(21 groups: T[] | undefined22) =>23 (groups ?? []).flatMap((group) =>24 group.providerKey ? [{ ...group, providerKey: group.providerKey }] : []25 )2627const toDrawablePoints = (points: ApishipRawPoint[] | undefined) =>28 (points ?? []).flatMap((point): ApishipPoint[] => {29 if (30 point.id === undefined ||31 point.id === null ||32 typeof point.lat !== "number" ||33 typeof point.lng !== "number"34 ) {35 return []36 }3738 return [{ ...point, id: String(point.id), lat: point.lat, lng: point.lng }]39 })4041export const retrieveApishipDeliveryChoice = async (42 cartId: string,43 shippingOptionId: string,44 includePoints: boolean45): Promise<ApishipDeliveryChoice | null> => {46 const headers = {47 ...(await getAuthHeaders()),48 }4950 return sdk.client51 .fetch<{ calculation: ApishipCalculation; points?: ApishipRawPoint[] }>(52 `/store/apiship/${shippingOptionId}/calculate`,53 {54 method: "POST",55 headers,56 body: { cart_id: cartId, include_points: includePoints },57 }58 )59 .then(({ calculation, points }) => ({60 calculation: {61 deliveryToDoor: toCarrierGroups(calculation.deliveryToDoor),62 deliveryToPoint: toCarrierGroups(calculation.deliveryToPoint),63 },64 points: toDrawablePoints(points),65 }))66 .catch((e) => {67 console.error("ApiShip delivery choice failed to load", e)68 return null69 })70}7172/**
calls . The plugin calculates the option for the cart's address and items, and returns only the tariffs that the delivery service settings in Medusa Admin allow. With it also returns the pickup points those tariffs serve, so the map needs no second request. The helper drops carrier groups without a key and points without coordinates, since the map can't place them.
calls , which returns the carriers with their names and logos. It passes , which tells the plugin which ApiShip instance the option belongs to. With a named instance such as , the route answers with a 404 if you omit .
calls . The plugin adds this route because Medusa's store API has no way to take a shipping method off a cart. Setting a method again keeps the stored on it, so without the removal a tariff and a pickup point picked for an old address would survive.
The ApiShip components share a set of helpers that recognize an ApiShip option, read tariffs and turn the calculation into what the lists and the map render.
Create with the following content:

src/modules/checkout/components/apiship/utils.ts1import type { HttpTypes } from "@medusajs/types"2import type {3 ApishipCalculation,4 ApishipPoint,5 ApishipSelection,6 ApishipTariff,7} from "types/apiship"89export const APISHIP_PROVIDER_PREFIX = "apiship_"1011export const isApishipOption = (12 option?: HttpTypes.StoreCartShippingOption | null13) => Boolean(option?.provider_id?.startsWith(APISHIP_PROVIDER_PREFIX))1415/**16 * Which half of the calculation a shipping option draws on. The seed writes17 * `deliveryType` into the option's own data, 1 for a courier and 2 for a pickup point.18 */19export const getApishipDeliveryType = (20 option?: HttpTypes.StoreCartShippingOption | null21): 1 | 2 | null => {22 if (!isApishipOption(option)) return null2324 const deliveryType = (option?.data as { deliveryType?: unknown } | null)25 ?.deliveryType2627 return deliveryType === 1 || deliveryType === 2 ? deliveryType : null28}2930/**31 * ApiShip quotes every tariff twice, `deliveryCost` and `deliveryCostOriginal`. The32 * fulfillment provider bills the cart with `deliveryCost`, so that is the one the checkout33 * shows, and the customer pays what they picked.34 */35export const getTariffCost = (tariff: ApishipTariff) =>36 typeof tariff.deliveryCost === "number" ? tariff.deliveryCost : null3738export const getTariffDays = (tariff: ApishipTariff) => {39 const min = tariff.daysMin ?? 040 const max = tariff.daysMax ?? 04142 if (!min && !max) return null4344 return { min: min || max, max: max || min }45}4647/**48 * Identifies a tariff across a render. ApiShip has no single id that is always filled in,49 * so the carrier key is combined with whichever of its ids came back, and the index closes50 * the gap for a carrier that sent neither.51 */52export const buildTariffKey = (53 providerKey: string,54 tariff: Omit<ApishipTariff, "key" | "providerKey">,55 index: number56) =>57 `${providerKey}:${tariff.tariffId ?? tariff.tariffProviderId ?? tariff.tariffName ?? index}`5859/**60 * Inverts the calculation into "which tariffs serve this pickup point", which is the
The starter formats delivery dates inside the shipping section. The ApiShip tariff list and shipping option card need the same dates, so move the formatter into a hook.
Create with the following content:

src/lib/hooks/use-format-delivery-days.tsx1"use client"23import { useCallback, useEffect, useMemo, useState } from "react"4import { useLocale, useTranslations } from "next-intl"5import type { DeliveryDays } from "@lib/util/fulfillment"67const NARROW_NO_BREAK_SPACE = "\u202F"8const NO_BREAK_SPACE = "\u00A0"910const keepTogether = (text: string) =>11 text.replace(/\s/g, (space) =>12 space === "\u2009" || space === NARROW_NO_BREAK_SPACE13 ? NARROW_NO_BREAK_SPACE14 : NO_BREAK_SPACE15 )1617const breakAfterDash = (range: string) => {18 const separator = /(\s+)([–—-])(\s+)/.exec(range)19 if (!separator) return keepTogether(range)2021 const [match, before, dash, after] = separator22 const start = range.slice(0, separator.index)23 const end = range.slice(separator.index + match.length)2425 return `${keepTogether(start)}${keepTogether(before)}${dash}${after}${keepTogether(end)}`26}2728export function useFormatDeliveryDays() {29 const t = useTranslations("CheckoutPage")30 const locale = useLocale()3132 const [now, setNow] = useState<Date | null>(null)33 useEffect(() => {34 setNow(new Date())35 }, [])3637 const formatter = useMemo(38 () =>39 new Intl.DateTimeFormat(locale, {40 day: "numeric",41 month: "long",42 }),43 [locale]44 )4546 return useCallback(47 (days: DeliveryDays | null) => {48 if (!days) return null49 if (days.max === 0) return t("deliveryToday")50 if (!now) return null5152 const dateIn = (daysFromNow: number) => {53 const date = new Date(now)54 date.setHours(0, 0, 0, 0)55 date.setDate(date.getDate() + daysFromNow)56 return date57 }5859 const formatDate = (daysFromNow: number) =>60 keepTogether(formatter.format(dateIn(daysFromNow)))6162 if (days.min !== undefined && days.max !== undefined) {63 if (days.min === days.max) return formatDate(days.min)6465 if (typeof formatter.formatRange === "function") {66 return breakAfterDash(67 formatter.formatRange(dateIn(days.min), dateIn(days.max))68 )69 }7071 return `${formatDate(days.min)}${NO_BREAK_SPACE}– ${formatDate(days.max)}`72 }7374 if (days.max !== undefined) {75 return t("deliveryDateUntil", { date: formatDate(days.max) })76 }7778 if (days.min !== undefined) {79 return t("deliveryDateFrom", { date: formatDate(days.min) })80 }8182 return null83 },84 [formatter, now, t]85 )86}
Then open and replace the inline formatter with the hook:

src/modules/checkout/components/checkout-shipping-section/index.tsx1import {2 getDeliveryDays,3 isPickupShippingOption,4} from "@lib/util/fulfillment"5// ...6import { useFormatDeliveryDays } from "@lib/hooks/use-format-delivery-days"78// ...910 const formatDeliveryDays = useFormatDeliveryDays()
Remove , , , , and the import, which the hook now holds. The hook formats a date or a date range in the storefront's locale and keeps a date from breaking across two lines.
Two hooks share the customer's choice and the carrier list between the shipping option card, the delivery row and the delivery sheet.
Create with the following content:

src/modules/checkout/components/apiship/use-apiship-selection.ts1"use client"23import type { HttpTypes } from "@medusajs/types"4import { useShippingSelection } from "@modules/checkout/context/shipping-selection-context"5import type { ApishipSelection } from "types/apiship"6import { getApishipSelection } from "./utils"78export function useApishipSelection(9 cart: HttpTypes.StoreCart,10 optionId: string | null | undefined11): ApishipSelection | null {12 const { getOptionData } = useShippingSelection()1314 if (!optionId) return null1516 return (17 (getOptionData(optionId) as { apishipData?: ApishipSelection } | undefined)18 ?.apishipData ?? getApishipSelection(cart, optionId)19 )20}
returns the choice for one option. It reads the choice the customer has confirmed from the checkout's shipping selection context first, and falls back to the one saved on the cart's shipping method. The cart holds one shipping method at a time, so the context is what keeps the courier card priced while the customer looks at pickup points.
Create with the following content:

src/modules/checkout/components/apiship/use-apiship-providers.ts1"use client"23import { useEffect, useState } from "react"4import { retrieveApishipProviders } from "@lib/data/apiship"5import type { ApishipProvider } from "types/apiship"67type ProvidersByKey = Record<string, ApishipProvider>89const providersByOption = new Map<string, Promise<ProvidersByKey | null>>()1011const loadProviders = (shippingOptionId: string) => {12 const cached = providersByOption.get(shippingOptionId)13 if (cached) return cached1415 const loading = retrieveApishipProviders(shippingOptionId).then((list) => {16 if (!list) {17 providersByOption.delete(shippingOptionId)18 return null19 }2021 return Object.fromEntries(22 list.flatMap((provider) =>23 provider.key && provider.name ? [[provider.key, provider]] : []24 )25 ) as ProvidersByKey26 })2728 providersByOption.set(shippingOptionId, loading)29 return loading30}3132export function useApishipProviders(33 shippingOptionId: string | undefined,34 enabled: boolean35) {36 const [providers, setProviders] = useState<ProvidersByKey>({})3738 useEffect(() => {39 if (!enabled || !shippingOptionId) return4041 let cancelled = false4243 loadProviders(shippingOptionId).then((loaded) => {44 if (!cancelled && loaded) setProviders(loaded)45 })4647 return () => {48 cancelled = true49 }50 }, [enabled, shippingOptionId])5152 return providers53}
loads the carriers of an option once and keeps the request in a module-level map, so every component on the page shares one request per option. It starts loading only when is true.
Here is how the tariff list you build in this step looks in the courier view of the delivery sheet:

The tariff list shows the tariffs of one shipping option grouped by carrier, each with its delivery date and price. The courier step of the delivery sheet shows it for every carrier, and the pickup point panel shows it for the tariffs that serve the selected point.
Create with the following content:

src/modules/checkout/components/apiship/provider-logo.tsx1import { clx } from "@medusajs/ui"2import Image from "next/image"34type ProviderLogoProps = {5 src?: string6 className?: string7}89export default function ProviderLogo({ src, className }: ProviderLogoProps) {10 if (!src) return null1112 return (13 <Image14 src={src}15 alt=""16 width={24}17 height={24}18 className={clx(19 "h-6 w-6 shrink-0 rounded-full shadow-borders-base",20 className21 )}22 />23 )24}
draws a carrier's logo, which the plugin returns as an SVG data URI in the carrier's field. The tariff list, the pickup point panel and the delivery row all use it.
Create with the following content:

src/modules/checkout/components/apiship/tariff-list.tsx1"use client"23import { convertToLocale } from "@lib/util/money"4import { useFormatDeliveryDays } from "@lib/hooks/use-format-delivery-days"5import { useLocaleDirection } from "@lib/hooks/use-locale-direction"6import { RadioGroup, Text } from "@medusajs/ui"7import { useLocale } from "next-intl"8import ProviderLogo from "./provider-logo"9import type { ApishipProvider, ApishipTariff } from "types/apiship"10import { getTariffCost, getTariffDays } from "./utils"1112type TariffGroup = {13 providerKey: string14 tariffs: ApishipTariff[]15}1617type TariffListProps = {18 groups: TariffGroup[]19 currencyCode: string20 providers: Record<string, ApishipProvider>21 selectedKey: string | null22 onSelect: (key: string) => void23 /** Carrier headings only help when more than one carrier is on offer. */24 showGroupNames?: boolean25}2627/**28 * The tariffs of one shipping option, grouped by carrier. Both the courier modal and the29 * map's point panel show the same list, so the two stay in step.30 */31export default function TariffList({32 groups,33 currencyCode,34 providers,35 selectedKey,36 onSelect,37 showGroupNames = true,38}: TariffListProps) {39 const dir = useLocaleDirection()40 const locale = useLocale()41 const formatDeliveryDays = useFormatDeliveryDays()4243 return (44 <RadioGroup45 dir={dir}46 value={selectedKey ?? undefined}47 onValueChange={onSelect}48 className="flex flex-col gap-[10px]"49 >50 {groups.map((group) => (51 <div key={group.providerKey} className="flex flex-col gap-[10px]">52 {showGroupNames && (53 <div className="flex items-center gap-x-2">54 <ProviderLogo src={providers[group.providerKey]?.icon} />55 <Text className="txt-compact-small-plus text-ui-fg-subtle">56 {providers[group.providerKey]?.name ?? group.providerKey}57 </Text>58 </div>59 )}6061 {group.tariffs.map((tariff) => {62 const cost = getTariffCost(tariff)63 const days = formatDeliveryDays(getTariffDays(tariff))6465 return (66 <RadioGroup.ChoiceBox67 key={tariff.key}68 value={tariff.key}69 label={tariff.tariffName ?? group.providerKey}70 description={[71 days,72 cost === null73 ? null74 : convertToLocale({75 amount: cost,76 currency_code: currencyCode,77 locale,78 }),79 ]80 .filter(Boolean)81 .join(" · ")}82 data-testid="apiship-tariff-radio"83 />84 )85 })}86 </div>87 ))}88 </RadioGroup>89 )90}
Each tariff is a from , with the tariff name as the label and the delivery date and price as the description. The pickup point panel passes , since its tariffs all belong to the point's own carrier.
Here is how the pickup point map you build in this step looks in the delivery sheet:

The map shows the pickup points that serve the customer's address, each marked with its carrier's logo, and groups points that sit close together into a numbered cluster. Clicking a cluster zooms into it, and clicking a point selects it.
Create with the following content:

src/modules/checkout/components/apiship/pickup-point-map.tsx27const Z_INDEX_SELECTED = 12829/**30 * Yandex Maps takes a full locale and rejects a bare language tag with a 400, so the31 * storefront's own locale is mapped onto the four the map supports.32 */33const YANDEX_LANGS: Record<string, string> = {34 en: "en_US",35 ru: "ru_RU",36 tr: "tr_TR",37 uk: "uk_UA",38}3940const toYandexLang = (locale: string) =>41 YANDEX_LANGS[locale.split(/[-_]/)[0].toLowerCase()] ?? "en_US"4243function loadYmaps3Script(apiKey: string, lang: string) {44 if (typeof ymaps3 !== "undefined") return Promise.resolve()4546 return new Promise<void>((resolve, reject) => {47 const script = document.createElement("script")48 script.src = `https://api-maps.yandex.ru/v3/?apikey=${encodeURIComponent(49 apiKey50 )}&lang=${encodeURIComponent(lang)}`51 script.async = true52 script.onload = () => resolve()53 script.onerror = () => {54 script.remove()55 reject(new Error("Failed to load the Yandex Maps script"))56 }57 document.head.appendChild(script)58 })59}6061async function loadMapComponents(apiKey: string, lang: string) {62 await loadYmaps3Script(apiKey, lang)63 await ymaps3.ready6465 const [{ reactify }, clusterer] = await Promise.all([66 ymaps3.import("@yandex/ymaps3-reactify"),67 import("@yandex/ymaps3-clusterer"),68 ])6970 const bound = reactify.bindTo(React, ReactDOM)71 const { YMap, YMapDefaultSchemeLayer, YMapDefaultFeaturesLayer, YMapMarker } =72 bound.module(ymaps3)73 const { YMapClusterer } = bound.module(clusterer)7475 return {76 YMap,77 YMapDefaultSchemeLayer,78 YMapDefaultFeaturesLayer,79 YMapMarker,80 YMapClusterer,81 }82}8384type MapComponents = Awaited<ReturnType<typeof loadMapComponents>>8586let mapComponentsLoading: Promise<MapComponents> | null = null8788const getMapComponents = (apiKey: string, lang: string) => {89 mapComponentsLoading ??= loadMapComponents(apiKey, lang).catch((e) => {90 mapComponentsLoading = null91 throw e92 })93 return mapComponentsLoading94}9596const locationForPoints = (97 points: ApishipPoint[],
The component loads the Yandex Maps script once, with the key from Step 2 and the storefront's locale. Yandex Maps rejects a bare language tag, so maps the locale onto the four languages the map supports and falls back to English for the rest. The map's React components come from the API's own module, and the clusterer from the package installed in Step 1.
merges markers that sit closer than 60 pixels on screen, recalculates once per zoom level and returns only the clusters in view. The map fits its view to all points, or opens on the pickup point the customer picked before. When the key is missing, the map fails to load or no pickup point serves the address, the component shows a message instead.
Here is how the delivery sheet you build in this step looks with a pickup point selected on the map:

ApiShip can't calculate anything before it knows the address, so the delivery sheet has two steps. The first is the checkout's own address form. The second is the tariff list for courier delivery, or the map with a panel for the selected pickup point.
Create with the following content:
![]()
src/modules/common/icons/clock.tsx1import type React from "react"23import type { IconProps } from "types/icon"45const Clock: React.FC<IconProps> = ({6 size = "20",7 color = "currentColor",8 ...attributes9}) => {10 return (11 <svg12 width={size}13 height={size}14 viewBox="0 0 20 20"15 fill="none"16 xmlns="http://www.w3.org/2000/svg"17 {...attributes}18 >19 <path20 d="M10.0003 17.5C14.1425 17.5 17.5003 14.1421 17.5003 10C17.5003 5.85786 14.1425 2.5 10.0003 2.5C5.85819 2.5 2.50033 5.85786 2.50033 10C2.50033 14.1421 5.85819 17.5 10.0003 17.5Z"21 stroke={color}22 strokeWidth="1.5"23 strokeLinecap="round"24 strokeLinejoin="round"25 />26 <path27 d="M10 5.83337V10L12.5 11.6667"28 stroke={color}29 strokeWidth="1.5"30 strokeLinecap="round"31 strokeLinejoin="round"32 />33 </svg>34 )35}3637export default Clock
The pickup point panel shows the opening hours next to this icon.
Create with the following content:

src/modules/checkout/components/apiship/delivery-modal.tsx44}4546/**47 * ApiShip needs an address before it can quote anything, so the flow is two steps in one48 * sheet. The first is the checkout's own address step, and only then does the second show49 * what the carriers offer for that address, a tariff list for courier delivery and a map50 * of pickup points for the rest.51 */52export default function ApishipDeliveryModal({53 open,54 onClose,55 cart,56 addresses,57 option,58 providers,59}: ApishipDeliveryModalProps) {60 const t = useTranslations("Apiship")61 const tCheckout = useTranslations("CheckoutPage")62 const locale = useLocale()63 const { setOptionData } = useShippingSelection()6465 const toPoint = getApishipDeliveryType(option) === 26667 const [step, setStep] = useState<"address" | "choice">("address")68 const [calculation, setCalculation] = useState<ApishipCalculation | null>(null)69 const [points, setPoints] = useState<ApishipPoint[]>([])70 const [tariffsByPointId, setTariffsByPointId] = useState<71 Record<string, ApishipTariff[]>72 >({})73 const [isLoading, setIsLoading] = useState(false)74 const [isSaving, setIsSaving] = useState(false)75 const [loadFailed, setLoadFailed] = useState(false)76 const [error, setError] = useState<string | null>(null)77 const [selectedPointId, setSelectedPointId] = useState<string | null>(null)78 const [selectedTariffKey, setSelectedTariffKey] = useState<string | null>(null)79 const [loadedKey, setLoadedKey] = useState<string | null>(null)8081 const savedSelection = useApishipSelection(cart, option.id)82 const savedPointId = savedSelection?.point?.id ?? null83 const savedTariffKey = savedSelection?.tariff.key ?? null8485 const hasAddress = Boolean(cart.shipping_address?.address_1)86 const addressKey = [87 cart.shipping_address?.country_code,88 cart.shipping_address?.province,89 cart.shipping_address?.city,90 cart.shipping_address?.postal_code,91 cart.shipping_address?.address_1,92 cart.shipping_address?.address_2,93 ].join("|")94 const itemsKey = (cart.items ?? [])95 .map((item) => `${item.variant_id}:${item.quantity}`)96 .join(",")97 const requestKey = [option.id, addressKey, itemsKey].join("#")9899 useEffect(() => {100 if (!open) return101 setError(null)102 setStep(hasAddress ? "choice" : "address")103 }, [open, hasAddress])104105 useEffect(() => {106 if (!open) return107 setSelectedPointId(savedPointId)108 setSelectedTariffKey(savedTariffKey)109 }, [open, savedPointId, savedTariffKey])110111 useEffect(() => {112 if (!open || step !== "choice" || loadedKey === requestKey) return113114 let cancelled = false115 setIsLoading(true)116 setLoadFailed(false)117118 void (async () => {119 const result = await retrieveApishipDeliveryChoice(120 cart.id,121 option.id,122 toPoint123 )124 if (cancelled) return125126 setCalculation(result?.calculation ?? null)127 setTariffsByPointId(128 result && toPoint ? buildTariffsByPointId(result.calculation) : {}129 )130 setPoints(result?.points ?? [])131 setLoadFailed(!result)132 setLoadedKey(result ? requestKey : null)133 setIsLoading(false)134 })()135136 return () => {137 cancelled = true138 }139 }, [open, step, cart.id, option.id, toPoint, requestKey, loadedKey])140141 const weekdayFormatter = useMemo(
The sheet opens on the address step when the cart has no address yet, and on the choice step otherwise. It loads the calculation when the choice step opens and again whenever the option, the address or the cart items change, which tracks. For a pickup point option it builds the tariffs of each point with , and the panel shows the clicked point's tariffs together with its address, opening hours, description and photos.
saves the choice with under , and in the shipping selection context. The plugin calculates the cart again on its side and rejects a tariff that isn't available for the cart, or that doesn't serve the chosen pickup point. The sheet then shows the error and stays open.
Create with the following content:

src/modules/checkout/components/apiship/index.ts1export { default as ApishipDeliveryModal } from "./delivery-modal"2export { default as ApishipProviderLogo } from "./provider-logo"3export { useApishipProviders } from "./use-apiship-providers"4export { useApishipSelection } from "./use-apiship-selection"5export type {6 ApishipPoint,7 ApishipSelection,8 ApishipTariff,9} from "types/apiship"10export {11 APISHIP_PROVIDER_PREFIX,12 getApishipDeliveryType,13 getApishipSelection,14 getTariffCost,15 getTariffDays,16 isApishipOption,17} from "./utils"
The card and the row in the next steps import everything they need from ApiShip through this file.
The descriptor tells the starter's checkout how to treat an ApiShip shipping option.
Create with the following content:

src/modules/checkout/components/apiship/descriptor.ts1import type { ShippingOptionDescriptor } from "@lib/constants"2import { removeApishipShippingMethod } from "@lib/data/apiship"3import {4 getApishipSelection,5 isApishipOption,6 isApishipSelectionComplete,7} from "./utils"89export const apishipShippingOptionDescriptor: ShippingOptionDescriptor = {10 test: isApishipOption,11 // ApiShip attaches with the cheapest tariff it found, which is enough to price the12 // cart but not to ship it: the order needs the tariff the customer actually picked,13 // and a pickup point where the option delivers to one.14 isReady: (cart, option) =>15 isApishipSelectionComplete(getApishipSelection(cart, option.id), option),16 pricedByChoice: true,17 removeShippingMethod: removeApishipShippingMethod,18}
Then open and add the descriptor to :

src/lib/constants.tsx1import { apishipShippingOptionDescriptor } from "@modules/checkout/components/apiship/descriptor"2// ...34const shippingOptionDescriptors: ShippingOptionDescriptor[] = [5 apishipShippingOptionDescriptor,6]
Here is how the shipping option cards you build in this step look at the checkout:

The card shows an ApiShip option in the list of delivery methods. It has no price until the customer picks a tariff, and then shows the tariff's delivery date and the exact price the cart is charged.
Create with the following content:

src/modules/checkout/components/shipping-option/providers/apiship.tsx1"use client"23import { convertToLocale } from "@lib/util/money"4import {5 getTariffCost,6 getTariffDays,7 useApishipSelection,8} from "@modules/checkout/components/apiship"9import { useLocale, useTranslations } from "next-intl"10import type React from "react"11import { ShippingOptionCardShell, type ShippingOptionCardProps } from "../shared"1213/**14 * ApiShip has no price until the customer picks a tariff, so the card shows none. Once a15 * tariff is picked the card carries its delivery dates and exactly the price the cart is16 * charged, and keeps both while the customer looks at another method, since the choice is17 * kept too.18 */19const ApishipShippingOptionCard: React.FC<ShippingOptionCardProps> = ({20 cart,21 option,22 isSelected,23 isUnavailable,24 formatDeliveryDays,25}) => {26 const tCheckout = useTranslations("CheckoutPage")27 const locale = useLocale()28 const selection = useApishipSelection(cart, option.id)2930 const cost = selection ? getTariffCost(selection.tariff) : null3132 const price =33 cost === null34 ? null35 : cost === 036 ? tCheckout("freeShipping")37 : convertToLocale({38 amount: cost,39 currency_code: cart.currency_code,40 locale,41 })4243 const caption = selection44 ? formatDeliveryDays(getTariffDays(selection.tariff))45 : null4647 return (48 <ShippingOptionCardShell49 option={option}50 isSelected={isSelected}51 isUnavailable={isUnavailable}52 price={price}53 isLoadingPrice={false}54 isFreeShipping={cost === 0}55 caption={caption}56 />57 )58}5960export default ApishipShippingOptionCard
The card renders through the starter's , so it keeps the same layout as the starter's own options. It keeps its price while the customer looks at another option, because keeps the choice too.
Then open and add a case for ApiShip to the card dispatcher:

src/modules/checkout/components/shipping-option/index.tsx1import { isApishipOption } from "@modules/checkout/components/apiship"2import ApishipShippingOptionCard from "./providers/apiship"3// ...45 switch (true) {6 case isApishipOption(props.option):7 return <ApishipShippingOptionCard {...props} />8 default:9 return <ManualShippingOptionCard {...props} />10 }
Every option that isn't ApiShip still falls through to the starter's manual card.
Here is how the delivery row you build in this step looks at the checkout:

The delivery row sits under the shipping options and opens the delivery sheet. For courier delivery it shows the address, and for a pickup point option the point's address under the "Pickup point" heading. Once the customer has made a choice, the carrier's logo appears at the end of the row.
The starter's row has no place for the logo yet. Open and add a prop:

src/modules/checkout/components/checkout-info-rows/row.tsx1type CheckoutInfoRowProps = {2 // ...3 trailing?: ReactNode4 // ...5}67export default function CheckoutInfoRow({8 // ...9 trailing,10 // ...11}: CheckoutInfoRowProps) {12 return (13 <button14 // ...15 >16 {/* ... */}17 {(trailing || !disabled) && (18 <div className="flex flex-shrink-0 items-center gap-x-3">19 {trailing}20 {!disabled && (21 <ChevronRight className="text-ui-fg-base flex-shrink-0 rtl:rotate-180" />22 )}23 </div>24 )}25 </button>26 )27}

src/modules/checkout/components/delivery-row/shared.tsx1type DeliveryRowShellProps = {2 // ...3 trailing?: ReactNode4 // ...5}67export const DeliveryRowShell = ({8 // ...9 trailing,10 // ...11}: DeliveryRowShellProps) => (12 <CheckoutInfoRow13 // ...14 trailing={trailing}15 // ...16 />17)
Create with the following content:

src/modules/checkout/components/delivery-row/providers/apiship.tsx1"use client"23import { useState } from "react"4import {5 ApishipDeliveryModal,6 ApishipProviderLogo,7 getApishipDeliveryType,8 useApishipProviders,9 useApishipSelection,10} from "@modules/checkout/components/apiship"11import { useTranslations } from "next-intl"12import type React from "react"13import { DeliveryRowShell, type DeliveryRowProps } from "../shared"1415/**16 * The row an ApiShip method collects its choice through. Delivery to a pickup point asks17 * for the point rather than an address, so the row says so and carries the point under18 * the heading; courier delivery keeps the address. Either way the row opens the same19 * two-step sheet, address first and the carrier's offer second.20 */21const ApishipDeliveryRow: React.FC<DeliveryRowProps> = ({22 cart,23 addresses,24 option,25}) => {26 const t = useTranslations("Apiship")27 const tCheckout = useTranslations("CheckoutPage")28 const [open, setOpen] = useState(false)2930 const selection = useApishipSelection(cart, option?.id)31 const providers = useApishipProviders(option?.id, open || !!selection)3233 if (!option) return null3435 const toPoint = getApishipDeliveryType(option) === 236 const providerKey = selection?.point?.providerKey ?? selection?.tariff.providerKey37 const addr = cart.shipping_address3839 const addressText = addr?.address_140 ? [addr.address_1, addr.city, addr.postal_code].filter(Boolean).join(", ")41 : null4243 return (44 <>45 <DeliveryRowShell46 heading={toPoint ? t("pickupPointHeading") : tCheckout("addressHeading")}47 onClick={() => setOpen(true)}48 value={!selection ? null : toPoint ? selection.point?.address : addressText}49 trailing={50 selection && providerKey ? (51 <ApishipProviderLogo src={providers[providerKey]?.icon} />52 ) : null53 }54 data-testid="checkout-apiship-row"55 />5657 <ApishipDeliveryModal58 open={open}59 onClose={() => setOpen(false)}60 cart={cart}61 addresses={addresses}62 option={option}63 providers={providers}64 />65 </>66 )67}6869export default ApishipDeliveryRow
The row opens . It starts loading the carriers only when the sheet opens or a choice already exists, so a customer who never opens the sheet causes no request for them.
Then open and add a case for ApiShip to the row dispatcher:

src/modules/checkout/components/delivery-row/index.tsx1import { isApishipOption } from "@modules/checkout/components/apiship"2// ...3import ApishipDeliveryRow from "./providers/apiship"45// ...67 switch (true) {8 case isApishipOption(props.option):9 return <ApishipDeliveryRow {...props} />10 default:11 return <ManualDeliveryRow {...props} />12 }
Every option that isn't ApiShip keeps the starter's address row.
Add the namespace to and to every other locale under :
messages/en.json1{2 // ...3 "Apiship": {4 "choose": "Choose",5 "courierModalTitle": "Courier delivery",6 "loadFailed": "We couldn't load the delivery options. Please try again.",7 "mapKeyMissing": "The map needs a Yandex Maps API key.",8 "mapLoadFailed": "We couldn't load the map. Please try again.",9 "next": "Next",10 "noPoints": "No pickup points serve this address.",11 "noTariffs": "No carrier delivers to this address.",12 "photoAlt": "Pickup point photo {index}",13 "pickupPointHeading": "Pickup point",14 "pickupPointModalTitle": "Pickup point",15 "pointDetails": "Details",16 "schedule": "Opening hours",17 "selectPointPrompt": "Pick a point on the map"18 },19 // ...20}
The example translates the namespace into all 36 storefront locales. A locale without these keys fails at runtime, not at build time.
The complete storefront is located at , built on the Medusa DTC Starter by Gorgo. The example's own README lists every file the integration consists of, backend included.