# FrontDesk Storefront API Run your own website or app on a FrontDesk merchant's data: their catalogue, events, forms and checkout. You build the frontend; FrontDesk stays the source of truth and handles the money. Base URL: https://api.frontdesk.africa/v1 Building with an AI app builder (Lovable, v0, Bolt)? Paste https://api.frontdesk.africa/v1/store/lovable.txt into it first: it describes the architecture to generate, not just the endpoints. ## Credentials Two kinds, and the difference matters: - `fd_pk_…` PUBLISHABLE. Reads, plus one write: registering a guest for a FREE event. SAFE to ship in browser code. It is locked to the exact origins the merchant registered, so it only works from their own site. An empty origin list REFUSES every call. That is deliberate, not a bug. - `fd_sk_…` SECRET. Can open checkouts. SERVER ONLY. Never put it in a page: anyone viewing source can then take payments as that merchant. Keys are per workspace and carry the environment: `fd_pk_test_…` works against staging, `fd_pk_live_…` against production. A key from the wrong environment is refused. You never choose which workspace you are reading: the key decides. Any workspace ref you send is ignored. ## Responses Success returns the resource itself. Errors are: { "error": { "code": "...", "message": "...", "retryable": bool, "requestId": "..." } } `code` is stable and safe to branch on. Quote `requestId` when asking for help. Common codes: UNAUTHORIZED, ORIGIN_NOT_ALLOWED, INSUFFICIENT_SCOPE, STORE_API_DISABLED, STORE_API_SECRET_DISABLED, KEY_PAUSED (the merchant paused this key), NOT_FOUND, VALIDATION_ERROR, RATE_LIMITED (429, honour Retry-After), INTERNAL. Publishable keys work as soon as you create one and list an origin. STORE_API_SECRET_DISABLED means the merchant has not switched secret keys on. They can do that themselves in their FrontDesk portal, under Developers. STORE_API_DISABLED means the whole workspace is off. ## Endpoints ### GET /v1/store/storefront The whole storefront: brand, theme, sections, pages, and the embedded event, collection and product lists. Auth: Authorization: Bearer fd_pk_… (publishable, safe in browser code) Scope: storefront:read (the umbrella scopes read/transact also satisfy it) Returns: Storefront NOTE: One call is enough to render a home page. Events, collections and products also ride inside this, so you can render everything from one response. ### GET /v1/store/products Every public product, as summaries. Auth: Authorization: Bearer fd_pk_… (publishable, safe in browser code) Scope: products:read (the umbrella scopes read/transact also satisfy it) - updatedSince (query): ISO 8601. Only products changed at or after this moment, ordered oldest change first — for keeping a local copy fresh. Feed the largest updatedAt you received back as the next cursor; the >= comparison re-sends the boundary row, so upsert by ref. - limit (query): Page size for delta reads, max 200. Without updatedSince the list is the storefront display order, unpaged. Returns: PublicProductSummary[] NOTE: Delta reads only see PUBLIC products: a product that was deleted arrives as the product.deleted webhook instead, and one that was merely unpublished simply stops appearing — reconcile against the full list occasionally if you keep a mirror. ### GET /v1/store/products/:slug One product with its variants, media, specs and description blocks. Auth: Authorization: Bearer fd_pk_… (publishable, safe in browser code) Scope: products:read (the umbrella scopes read/transact also satisfy it) - slug (path, required): The product's slug. Returns: PublicProduct NOTE: When `options` is present the product sells along several axes (Size × Colour): render one picker per group and resolve the buyer's picks to the variant whose `optionValueRefs` holds exactly those value refs. Checkout still takes that variant's ref. ### GET /v1/store/products/:slug/slots Bookable slots for a product that is scheduled rather than shipped. Auth: Authorization: Bearer fd_pk_… (publishable, safe in browser code) Scope: availability:read (the umbrella scopes read/transact also satisfy it) - slug (path, required): The product's slug. Returns: PublicProductSlots ### GET /v1/store/collections Every public collection, as summaries. Auth: Authorization: Bearer fd_pk_… (publishable, safe in browser code) Scope: products:read (the umbrella scopes read/transact also satisfy it) Returns: PublicCollectionSummary[] ### GET /v1/store/collections/:slug One collection with its products. Auth: Authorization: Bearer fd_pk_… (publishable, safe in browser code) Scope: products:read (the umbrella scopes read/transact also satisfy it) - slug (path, required): The collection's slug. Returns: PublicCollectionDetail ### GET /v1/store/events Every public event, as summaries. Auth: Authorization: Bearer fd_pk_… (publishable, safe in browser code) Scope: products:read (the umbrella scopes read/transact also satisfy it) Returns: PublicEventSummary[] NOTE: Same list that rides inside /v1/store/storefront. Use this when you only need the events. ### GET /v1/store/events/:slug One event with its ticket types, days and location. Auth: Authorization: Bearer fd_pk_… (publishable, safe in browser code) Scope: products:read (the umbrella scopes read/transact also satisfy it) - slug (path, required): The event's slug. Returns: PublicEvent ### GET /v1/store/forms/:slug A published form and its field schema, so you can render it yourself. Auth: Authorization: Bearer fd_pk_… (publishable, safe in browser code) Scope: storefront:read (the umbrella scopes read/transact also satisfy it) - slug (path, required): The form's slug. Returns: PublicForm ### GET /v1/store/tables/:slug A published data table: its columns and rows. Auth: Authorization: Bearer fd_pk_… (publishable, safe in browser code) Scope: storefront:read (the umbrella scopes read/transact also satisfy it) - slug (path, required): The table's slug. Returns: PublicTable ### GET /v1/store/catalogs/:slug A public media catalog (a gallery). Auth: Authorization: Bearer fd_pk_… (publishable, safe in browser code) Scope: storefront:read (the umbrella scopes read/transact also satisfy it) - slug (path, required): The catalog's slug. Returns: PublicCatalog ### GET /v1/store/files/:slug A public file folder and its files. Auth: Authorization: Bearer fd_pk_… (publishable, safe in browser code) Scope: storefront:read (the umbrella scopes read/transact also satisfy it) - slug (path, required): The folder's slug. Returns: PublicFolder ### GET /v1/store/notes/:slug A published note or document. Auth: Authorization: Bearer fd_pk_… (publishable, safe in browser code) Scope: storefront:read (the umbrella scopes read/transact also satisfy it) - slug (path, required): The note's slug. Returns: PublicNote ### GET /v1/store/delivery-zones Delivery zones and their fees, so a cart can quote shipping before checkout. Auth: Authorization: Bearer fd_pk_… (publishable, safe in browser code) Scope: availability:read (the umbrella scopes read/transact also satisfy it) Returns: DeliveryZone[] ### GET /v1/store/availability Live stock and slot availability. Auth: Authorization: Bearer fd_pk_… (publishable, safe in browser code) Scope: availability:read (the umbrella scopes read/transact also satisfy it) - variantRefs (query, required): Comma-separated variant refs your cart holds (max 100). A ref missing from the response map is no longer purchasable, so drop that line. Returns: PublicStoreAvailability NOTE: A cart lives in the buyer's storage, so its lines can be days old. Call this when the buyer returns: `variants` maps each ref to units left (null = untracked, no cap), and refs listed in `preorderable` are still buyable at 0 stock — cap those on `preorderRemaining` instead of dropping them. ### POST /v1/store/checkouts Open a hosted checkout. Returns a URL to send the buyer to; you never handle payment. Auth: Authorization: Bearer fd_sk_… (secret, server only) Scope: checkout:create (the umbrella scopes read/transact also satisfy it) Body: - items (required): Array of { variantRef, quantity }. Every variantRef must resolve to a live product in this store; refs come from GET /v1/store/products. - returnUrl (required): Where the buyer lands after paying. https, or localhost while testing. - contact: Prefills the hosted page. The buyer can still correct it. - currency: The currency the buyer pays in. Must be one the store accepts, and every item must carry a price in it (see prices[] on GET /v1/store/products); otherwise 400 VALIDATION_ERROR with `details.accepted` or `details.unpriced`. Omit for the store primary. Fixed for the life of the session: the hosted page, the order and the payment are all in it, and nothing falls back to the store primary. - deliveryZoneRef: Required when any line needs delivery. The zone fee is charged in the checkout currency. Returns: StoreCheckoutSessionView NOTE: Requires an Idempotency-Key header. A retry then returns the SAME session instead of giving the buyer two carts. Items are validated here: an unknown or unpublished variantRef returns 400 VALIDATION_ERROR with the bad refs in `details.invalid`, and a variant with no price in the requested currency returns 400 VALIDATION_ERROR with the refs in `details.unpriced`, so both fail on your server rather than in front of your buyer. Pricing still happens when the buyer pays, in the session currency. Only rails that can charge that currency are offered on the hosted page (NGN: card, bank transfer; anything else: international card). A buyer who backs out of a payment returns to the hosted page, never to your returnUrl: only a paid checkout reaches returnUrl. A retry there pays the same order rather than opening another, and reopening hostedUrl resumes it, including a bank transfer already started. There is no `provider` field: the buyer picks how to pay on our page, which is why you never touch card data. ### POST /v1/store/events/:slug/checkout Open a hosted checkout for event tickets. Auth: Authorization: Bearer fd_sk_… (secret, server only) Scope: checkout:create (the umbrella scopes read/transact also satisfy it) - slug (path, required): The event's slug. Body: - tickets (required): Ticket lines: ticketTypeRef, quantity, optional amountMinor for donation tiers, and attendees to issue seats to named guests. attendees is POSITIONAL and its length must be quantity x groupSize: leave an entry blank to keep that seat under the buyer, never drop it, or the following names shift onto the wrong seats. - products: NOT SUPPORTED YET: sending a non-empty array returns 400. Sell merchandise with a separate POST /v1/store/checkouts. - contact (required): Buyer name, email and optional phone. The email receives the tickets. - dateOfBirth: Buyer DOB as YYYY-MM-DD. Required when a chosen tier sets minAge and the event dobMode is order. - responses: Answers to the questions on the chosen tiers, keyed by the field key. Required whenever one of those fields is required. - discountCode: Promo code. Re-validated server-side. - unlockCodes: Codes that reveal hidden or invite-only tiers. Re-verified server-side, and REQUIRED here for such a tier: without the code the tier is not on sale and the request is refused. - promoterCode: Affiliate code from a share link. Ignored if unknown. - currency: Must be a currency the event sells in. Omit for its primary. - returnUrl (required): Where the buyer lands after paying. Returns: StoreCheckoutSessionView NOTE: Send Idempotency-Key: a retry returns the SAME checkout instead of two. Tiers are validated here, like product variants: a ref that is unknown, hidden without its unlockCode, sold out, or asked for outside its minPerOrder/maxPerOrder returns 400 VALIDATION_ERROR with the refs in details.invalid, details.soldOut or details.quantity, so the failure lands on your server and never on your buyer. You collect what the organiser asks for, and GET /v1/store/events/:slug tells you what that is: each tier carries formFields, requiresAttendeeDetails, groupSize and minPerOrder/maxPerOrder, and the event carries minAge and dobMode. Everything you send is re-validated here, so prices, availability and age limits are decided by the server, not by your form. Three of those rules REFUSE the order rather than warn, so collect them before you send: a required field left unanswered returns 400 "Please answer: