@frontdesk-africa/store-js
v0.4.0
Published
Typed client for the FrontDesk Storefront API: catalogue, events, forms, hosted checkout and signed webhooks.
Maintainers
Readme
@frontdesk-africa/store-js
Typed client for the FrontDesk Storefront API. Build your own website or app on a merchant's catalogue, events, forms and checkout.
It is deliberately thin: a fetch wrapper with the response types attached and the error envelope
turned into a real Error. It does not cache, retry or reshape anything, because a client that does
becomes a second implementation of the API's semantics.
Full reference for agents: GET /v1/store/llms.txt. OpenAPI: GET /v1/store/openapi.json.
MCP: POST /v1/store/mcp.
The two keys
| Key | Where it goes | What it does |
|---|---|---|
| fd_pk_… | your page, safe to ship | reads, plus free event registration, and only from origins the merchant registered |
| fd_sk_… | your server, never a page | opens checkouts |
A publishable key is designed to be public. It is locked to exact origins, and an empty origin list refuses everything — that is deliberate, not a misconfiguration. A secret key in browser code lets anyone viewing source take payments as that merchant.
fd_pk_test_… / fd_sk_test_… work against staging; live against production. A key from the
wrong environment is refused.
Install
pnpm add @frontdesk-africa/store-jsRead (browser)
import { createStoreClient } from '@frontdesk-africa/store-js'
const store = createStoreClient({
baseUrl: 'https://api.frontdesk.africa/v1',
key: process.env.NEXT_PUBLIC_FRONTDESK_PK!, // fd_pk_…
})
const site = await store.storefront() // brand, theme, sections, pages, embedded lists
const products = await store.products()
const item = await store.product('blue-mug')You never choose the workspace: the key does. Any workspace ref you send is ignored.
Products sold in several dimensions
A product whose merchant sells it by Size and Colour carries an options array: one entry per
group, each with its own display (dropdown, text, color, image) and its list of values.
The buyer picks one value per group, and that set resolves to a single variant — the one whose
optionValueRefs holds exactly those value refs. Checkout is unchanged: you still send that
variant's ref.
const item = await store.product('shirt-dress')
// options is absent on an ordinary product — render item.variants as a flat list then.
const picked = { [item.options![0].ref]: 'value-ref-small', [item.options![1].ref]: 'value-ref-teal' }
const chosen = item.variants.find(
(v) => v.optionValueRefs?.length === 2 && v.optionValueRefs.every((r) => Object.values(picked).includes(r)),
)Dim a value when every variant still holding it is soldOut (or has no price in the buyer's
currency), and swap your gallery to a value's media when it is selected.
Free event registration (browser)
A free RSVP page needs no server. Read the event, offer the tiers whose kind is free, ask the
questions in each tier's formFields, and post the registration with the publishable key.
const event = await store.event('open-house')
const tier = event.ticketTypes.find((t) => t.kind === 'free')!
const done = await store.registerForEvent(event.slug, {
tickets: [{ ticketTypeRef: tier.ref, quantity: 2, attendees: [{}, { name: 'Tunde Obi', email: '[email protected]' }] }],
contact: { name: 'Ada Obi', email: '[email protected]', phone: '+2348120700080' },
responses: { piggyvest_user: 'Yes', first_time: 'No' }, // keyed by formFields[].key
})
// done.status → 'confirmed' | 'pending_approval' | 'waitlisted'; done.reference → 'EV-7F3A2C'FrontDesk emails the guest their confirmation, calendar invite and ticket. A paid tier is refused
with TIER_REQUIRES_PAYMENT: open a checkout for it from your server. Registering the same email
twice returns the same answer and re-sends the confirmation instead of issuing a second seat, so a
retry is safe. To read registrations back, subscribe to the attendee.registered webhook.
Checkout (server)
You never handle card details and you never price a cart. The buyer pays on a FrontDesk-hosted page on the merchant's own domain, so their branding does not change mid-payment.
// server only — fd_sk_…
const checkout = await store.createCheckout(
{
items: [{ variantRef: variant.ref, quantity: 2 }],
returnUrl: 'https://yourbrand.com/thanks',
},
`cart_${cart.id}`, // idempotency key: stable per cart, NOT random per attempt
)
redirect(checkout.hostedUrl)Every variantRef must be a live one from the catalogue. An unknown or unpublished ref is refused
with 400 VALIDATION_ERROR listing the bad refs in error.details.invalid, so a typo fails on your
server instead of in front of your buyer.
Then, when the buyer comes back:
const done = await store.getCheckout(ref)
if (done.status === 'completed') fulfil(done.orderRef)Do not fulfil on the redirect itself. It is a browser navigation: it can be lost, replayed or
forged. getCheckout or the checkout.completed webhook are the only proof.
Only a paid checkout reaches returnUrl. A buyer who backs out of a payment comes back to the hosted
page with the same cart and a link to your site, a retry there pays the same order rather than opening
another, and reopening hostedUrl resumes it, including a bank transfer already started.
Event tickets work the same way, with the event telling you what to collect first:
const event = await store.event('gala-night') // tiers carry formFields, requiresAttendeeDetails, min/max
const checkout = await store.createEventCheckout(
'gala-night',
{
tickets: [{ ticketTypeRef: tier.ref, quantity: 2 }],
contact: { name, email },
returnUrl: 'https://yourbrand.com/thanks',
},
`evtcart_${cart.id}`,
)
redirect(checkout.hostedUrl)If the buyer walks away, cancelCheckout(ref) releases the seats straight away instead of holding
them until the session lapses. For carts that live in the buyer's storage, availability(variantRefs)
tells you which lines are still purchasable before you reopen a days-old cart.
createHeadlessCheckout (you render the payment step yourself) needs headless switched on for the
workspace, and one provider from paymentMethods() — see the API docs for the per-rail flow.
Prices from the read endpoints are for display. The order is priced when the buyer pays, so a price that moved in between is resolved there, not by you.
Webhooks
import { verifyWebhook } from '@frontdesk-africa/store-js'
export async function POST(req: Request) {
const raw = await req.text() // RAW body — verify before parsing
const ok = await verifyWebhook({
rawBody: raw,
signatureHeader: req.headers.get('x-fd-signature'),
timestampHeader: req.headers.get('x-fd-timestamp'),
secret: process.env.FRONTDESK_WEBHOOK_SECRET!,
})
if (!ok) return new Response('bad signature', { status: 400 })
const event = JSON.parse(raw)
// Return 2xx fast and do the work asynchronously; we retry with backoff for 24h.
queue(event)
return new Response('ok')
}Pass the raw body. JSON.parse then JSON.stringify will not reproduce the bytes we signed, and
the check will fail in a way that looks like a key mismatch.
Payloads are thin — refs only. Re-fetch through the API for detail, so a replayed delivery can never present stale figures as current.
Orders + keeping a local copy in sync
orders() (server only, secret key) lists your own orders as a delta read: pass the largest
updatedAt you have seen back as updatedSince and upsert by ref — the >= comparison repeats
the boundary row on purpose so a same-second tie can never drop one. products({ updatedSince })
works the same way for the catalogue.
let cursor = loadCursor() // null on first run = full backfill
for (;;) {
const page = await store.orders(cursor ? { updatedSince: cursor, limit: 200 } : { limit: 200 })
for (const order of page) upsertByRef(order)
if (page.length < 200) break
cursor = page[page.length - 1].updatedAt
}Deletions never show up as absence in a delta page: they arrive as the product.deleted and
order.deleted webhooks — remove those refs from your copy when they land. FrontDesk stays the
source of truth; on any conflict, its row wins.
Confirming a single purchase is still getCheckout(ref), not a scan of orders().
Test mode
A fd_sk_test_… key opens a test checkout that completes with a real charge on the provider's
sandbox — pay it with a Paystack test card (the popup offers a Success option). There is exactly
one way to finish a checkout in either mode.
The order it creates is REAL and marked test everywhere: no money moves, nothing is held from
stock, and checkout.completed fires with the real orderRef. Test orders show under a Test badge
in the portal, only a test key can read them (orders() never mixes envs), and they are
hard-deleted after 90 days — the order.deleted webhook tells your mirror when that happens.
Starters
Lovable / Replit prompt
Paste this, with your key:
Build a storefront on the FrontDesk Storefront API.
Read the full API reference first: https://api.frontdesk.africa/v1/store/llms.txt
Use publishable key
fd_pk_live_…for all reads, in the browser. Base URLhttps://api.frontdesk.africa/v1,Authorization: Bearer <key>.Pages: home rendering
GET /v1/store/storefront, a product list fromGET /v1/store/products, and a product page fromGET /v1/store/products/{slug}.For a free event RSVP page, call
POST /v1/store/events/{slug}/registerstraight from the browser with the publishable key, sendingtickets,contactandresponseskeyed by each tier'sformFields[].key. FrontDesk sends the confirmation email itself.For checkout, call
POST /v1/store/checkoutsfrom a server route, never the browser, using the secret key, with anIdempotency-Keyheader derived from the cart id. Redirect the buyer to thehostedUrlyou get back. Do not build a payment form and do not ask for card details.After the buyer returns, confirm with
GET /v1/store/checkouts/{ref}server-side before showing a success page. Never trust the redirect alone.
The origin your app is served from must be registered on the publishable key, in the merchant's FrontDesk portal under Settings → Website → Developers. Until it is, every call is refused.
Next.js (App Router)
// lib/store.ts — browser-safe reads
import { createStoreClient } from '@frontdesk-africa/store-js'
export const store = createStoreClient({
baseUrl: process.env.NEXT_PUBLIC_FRONTDESK_API!,
key: process.env.NEXT_PUBLIC_FRONTDESK_PK!,
})
// lib/store.server.ts — server only, never imported by a client component
import 'server-only'
import { createStoreClient } from '@frontdesk-africa/store-js'
export const storeServer = createStoreClient({
baseUrl: process.env.FRONTDESK_API!,
key: process.env.FRONTDESK_SK!,
})// app/api/checkout/route.ts
import { storeServer } from '@/lib/store.server'
export async function POST(req: Request) {
const { items, cartId } = await req.json()
const checkout = await storeServer.createCheckout(
{ items, returnUrl: `${process.env.SITE_URL}/thanks` },
`cart_${cartId}`,
)
return Response.json({ hostedUrl: checkout.hostedUrl })
}The server-only import is the guard that matters: it turns "I accidentally imported the secret key
into a client component" from a silent production leak into a build error.
Expo / React Native
const store = createStoreClient({
baseUrl: process.env.EXPO_PUBLIC_FRONTDESK_API!,
key: process.env.EXPO_PUBLIC_FRONTDESK_PK!,
})A native app has no browser Origin, so the origin lock does not apply the same way — treat the
publishable key as public (it is), keep the secret key on your own server, and open checkouts there.
Send the buyer to hostedUrl in a system browser or web view, then confirm server-side on return.
Errors
import { StoreApiError } from '@frontdesk-africa/store-js'
try {
await store.product('nope')
} catch (e) {
if (e instanceof StoreApiError) {
e.code // 'NOT_FOUND' | 'ORIGIN_NOT_ALLOWED' | 'INSUFFICIENT_SCOPE' | 'RATE_LIMITED' | …
e.retryable // honour Retry-After on RATE_LIMITED
e.requestId // quote this when asking us for help
}
}ORIGIN_NOT_ALLOWED almost always means the origin is not on the key, or the key has no origins at
all. INSUFFICIENT_SCOPE means a publishable key tried something only a secret key can do.
STORE_API_SECRET_DISABLED means your workspace is not cleared for server-side (fd_sk_) calls yet —
ask us to switch it on. Publishable reads are not affected and keep working. STORE_API_DISABLED is
different: it means the whole workspace is switched off, and both kinds of key stop.
