npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@frontdesk-africa/store-js

v0.4.0

Published

Typed client for the FrontDesk Storefront API: catalogue, events, forms, hosted checkout and signed webhooks.

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-js

Read (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 URL https://api.frontdesk.africa/v1, Authorization: Bearer <key>.

Pages: home rendering GET /v1/store/storefront, a product list from GET /v1/store/products, and a product page from GET /v1/store/products/{slug}.

For a free event RSVP page, call POST /v1/store/events/{slug}/register straight from the browser with the publishable key, sending tickets, contact and responses keyed by each tier's formFields[].key. FrontDesk sends the confirmation email itself.

For checkout, call POST /v1/store/checkouts from a server route, never the browser, using the secret key, with an Idempotency-Key header derived from the cart id. Redirect the buyer to the hostedUrl you 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.