{"openapi":"3.1.0","info":{"title":"FrontDesk Storefront API","version":"1.0.0","description":"Build your own website or app on a FrontDesk merchant's data. Publishable keys read the catalogue and register guests for free events, and are origin-locked; secret keys are server-only. Payment happens on a FrontDesk-hosted page, so you never handle card details. Confirm purchases with GET /v1/store/checkouts/{ref} or the checkout.completed webhook, never with the return redirect."},"servers":[{"url":"https://api.frontdesk.africa/v1"}],"components":{"securitySchemes":{"bearerAuth":{"type":"http","scheme":"bearer","description":"fd_pk_… (read + free event registration, browser) or fd_sk_… (server only)."}}},"paths":{"/v1/store/storefront":{"get":{"summary":"The whole storefront: brand, theme, sections, pages, and the embedded event, collection and product lists.","description":"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.","security":[{"bearerAuth":["storefront:read"]}],"responses":{"200":{"description":"Storefront"},"401":{"description":"UNAUTHORIZED. Missing, unknown, revoked or wrong-environment key."},"403":{"description":"ORIGIN_NOT_ALLOWED, INSUFFICIENT_SCOPE, STORE_API_SECRET_DISABLED or STORE_API_DISABLED."},"404":{"description":"NOT_FOUND"},"429":{"description":"RATE_LIMITED. Honour Retry-After."}}}},"/v1/store/products":{"get":{"summary":"Every public product, as summaries.","description":"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.","security":[{"bearerAuth":["products:read"]}],"responses":{"200":{"description":"PublicProductSummary[]"},"401":{"description":"UNAUTHORIZED. Missing, unknown, revoked or wrong-environment key."},"403":{"description":"ORIGIN_NOT_ALLOWED, INSUFFICIENT_SCOPE, STORE_API_SECRET_DISABLED or STORE_API_DISABLED."},"404":{"description":"NOT_FOUND"},"429":{"description":"RATE_LIMITED. Honour Retry-After."}},"parameters":[{"name":"updatedSince","in":"query","required":false,"description":"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.","schema":{"type":"string"}},{"name":"limit","in":"query","required":false,"description":"Page size for delta reads, max 200. Without updatedSince the list is the storefront display order, unpaged.","schema":{"type":"string"}}]}},"/v1/store/products/{slug}":{"get":{"summary":"One product with its variants, media, specs and description blocks.","description":"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.","security":[{"bearerAuth":["products:read"]}],"responses":{"200":{"description":"PublicProduct"},"401":{"description":"UNAUTHORIZED. Missing, unknown, revoked or wrong-environment key."},"403":{"description":"ORIGIN_NOT_ALLOWED, INSUFFICIENT_SCOPE, STORE_API_SECRET_DISABLED or STORE_API_DISABLED."},"404":{"description":"NOT_FOUND"},"429":{"description":"RATE_LIMITED. Honour Retry-After."}},"parameters":[{"name":"slug","in":"path","required":true,"description":"The product's slug.","schema":{"type":"string"}}]}},"/v1/store/products/{slug}/slots":{"get":{"summary":"Bookable slots for a product that is scheduled rather than shipped.","security":[{"bearerAuth":["availability:read"]}],"responses":{"200":{"description":"PublicProductSlots"},"401":{"description":"UNAUTHORIZED. Missing, unknown, revoked or wrong-environment key."},"403":{"description":"ORIGIN_NOT_ALLOWED, INSUFFICIENT_SCOPE, STORE_API_SECRET_DISABLED or STORE_API_DISABLED."},"404":{"description":"NOT_FOUND"},"429":{"description":"RATE_LIMITED. Honour Retry-After."}},"parameters":[{"name":"slug","in":"path","required":true,"description":"The product's slug.","schema":{"type":"string"}}]}},"/v1/store/collections":{"get":{"summary":"Every public collection, as summaries.","security":[{"bearerAuth":["products:read"]}],"responses":{"200":{"description":"PublicCollectionSummary[]"},"401":{"description":"UNAUTHORIZED. Missing, unknown, revoked or wrong-environment key."},"403":{"description":"ORIGIN_NOT_ALLOWED, INSUFFICIENT_SCOPE, STORE_API_SECRET_DISABLED or STORE_API_DISABLED."},"404":{"description":"NOT_FOUND"},"429":{"description":"RATE_LIMITED. Honour Retry-After."}}}},"/v1/store/collections/{slug}":{"get":{"summary":"One collection with its products.","security":[{"bearerAuth":["products:read"]}],"responses":{"200":{"description":"PublicCollectionDetail"},"401":{"description":"UNAUTHORIZED. Missing, unknown, revoked or wrong-environment key."},"403":{"description":"ORIGIN_NOT_ALLOWED, INSUFFICIENT_SCOPE, STORE_API_SECRET_DISABLED or STORE_API_DISABLED."},"404":{"description":"NOT_FOUND"},"429":{"description":"RATE_LIMITED. Honour Retry-After."}},"parameters":[{"name":"slug","in":"path","required":true,"description":"The collection's slug.","schema":{"type":"string"}}]}},"/v1/store/events":{"get":{"summary":"Every public event, as summaries.","description":"Same list that rides inside /v1/store/storefront. Use this when you only need the events.","security":[{"bearerAuth":["products:read"]}],"responses":{"200":{"description":"PublicEventSummary[]"},"401":{"description":"UNAUTHORIZED. Missing, unknown, revoked or wrong-environment key."},"403":{"description":"ORIGIN_NOT_ALLOWED, INSUFFICIENT_SCOPE, STORE_API_SECRET_DISABLED or STORE_API_DISABLED."},"404":{"description":"NOT_FOUND"},"429":{"description":"RATE_LIMITED. Honour Retry-After."}}}},"/v1/store/events/{slug}":{"get":{"summary":"One event with its ticket types, days and location.","security":[{"bearerAuth":["products:read"]}],"responses":{"200":{"description":"PublicEvent"},"401":{"description":"UNAUTHORIZED. Missing, unknown, revoked or wrong-environment key."},"403":{"description":"ORIGIN_NOT_ALLOWED, INSUFFICIENT_SCOPE, STORE_API_SECRET_DISABLED or STORE_API_DISABLED."},"404":{"description":"NOT_FOUND"},"429":{"description":"RATE_LIMITED. Honour Retry-After."}},"parameters":[{"name":"slug","in":"path","required":true,"description":"The event's slug.","schema":{"type":"string"}}]}},"/v1/store/forms/{slug}":{"get":{"summary":"A published form and its field schema, so you can render it yourself.","security":[{"bearerAuth":["storefront:read"]}],"responses":{"200":{"description":"PublicForm"},"401":{"description":"UNAUTHORIZED. Missing, unknown, revoked or wrong-environment key."},"403":{"description":"ORIGIN_NOT_ALLOWED, INSUFFICIENT_SCOPE, STORE_API_SECRET_DISABLED or STORE_API_DISABLED."},"404":{"description":"NOT_FOUND"},"429":{"description":"RATE_LIMITED. Honour Retry-After."}},"parameters":[{"name":"slug","in":"path","required":true,"description":"The form's slug.","schema":{"type":"string"}}]}},"/v1/store/tables/{slug}":{"get":{"summary":"A published data table: its columns and rows.","security":[{"bearerAuth":["storefront:read"]}],"responses":{"200":{"description":"PublicTable"},"401":{"description":"UNAUTHORIZED. Missing, unknown, revoked or wrong-environment key."},"403":{"description":"ORIGIN_NOT_ALLOWED, INSUFFICIENT_SCOPE, STORE_API_SECRET_DISABLED or STORE_API_DISABLED."},"404":{"description":"NOT_FOUND"},"429":{"description":"RATE_LIMITED. Honour Retry-After."}},"parameters":[{"name":"slug","in":"path","required":true,"description":"The table's slug.","schema":{"type":"string"}}]}},"/v1/store/catalogs/{slug}":{"get":{"summary":"A public media catalog (a gallery).","security":[{"bearerAuth":["storefront:read"]}],"responses":{"200":{"description":"PublicCatalog"},"401":{"description":"UNAUTHORIZED. Missing, unknown, revoked or wrong-environment key."},"403":{"description":"ORIGIN_NOT_ALLOWED, INSUFFICIENT_SCOPE, STORE_API_SECRET_DISABLED or STORE_API_DISABLED."},"404":{"description":"NOT_FOUND"},"429":{"description":"RATE_LIMITED. Honour Retry-After."}},"parameters":[{"name":"slug","in":"path","required":true,"description":"The catalog's slug.","schema":{"type":"string"}}]}},"/v1/store/files/{slug}":{"get":{"summary":"A public file folder and its files.","security":[{"bearerAuth":["storefront:read"]}],"responses":{"200":{"description":"PublicFolder"},"401":{"description":"UNAUTHORIZED. Missing, unknown, revoked or wrong-environment key."},"403":{"description":"ORIGIN_NOT_ALLOWED, INSUFFICIENT_SCOPE, STORE_API_SECRET_DISABLED or STORE_API_DISABLED."},"404":{"description":"NOT_FOUND"},"429":{"description":"RATE_LIMITED. Honour Retry-After."}},"parameters":[{"name":"slug","in":"path","required":true,"description":"The folder's slug.","schema":{"type":"string"}}]}},"/v1/store/notes/{slug}":{"get":{"summary":"A published note or document.","security":[{"bearerAuth":["storefront:read"]}],"responses":{"200":{"description":"PublicNote"},"401":{"description":"UNAUTHORIZED. Missing, unknown, revoked or wrong-environment key."},"403":{"description":"ORIGIN_NOT_ALLOWED, INSUFFICIENT_SCOPE, STORE_API_SECRET_DISABLED or STORE_API_DISABLED."},"404":{"description":"NOT_FOUND"},"429":{"description":"RATE_LIMITED. Honour Retry-After."}},"parameters":[{"name":"slug","in":"path","required":true,"description":"The note's slug.","schema":{"type":"string"}}]}},"/v1/store/delivery-zones":{"get":{"summary":"Delivery zones and their fees, so a cart can quote shipping before checkout.","security":[{"bearerAuth":["availability:read"]}],"responses":{"200":{"description":"DeliveryZone[]"},"401":{"description":"UNAUTHORIZED. Missing, unknown, revoked or wrong-environment key."},"403":{"description":"ORIGIN_NOT_ALLOWED, INSUFFICIENT_SCOPE, STORE_API_SECRET_DISABLED or STORE_API_DISABLED."},"404":{"description":"NOT_FOUND"},"429":{"description":"RATE_LIMITED. Honour Retry-After."}}}},"/v1/store/availability":{"get":{"summary":"Live stock and slot availability.","description":"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.","security":[{"bearerAuth":["availability:read"]}],"responses":{"200":{"description":"PublicStoreAvailability"},"401":{"description":"UNAUTHORIZED. Missing, unknown, revoked or wrong-environment key."},"403":{"description":"ORIGIN_NOT_ALLOWED, INSUFFICIENT_SCOPE, STORE_API_SECRET_DISABLED or STORE_API_DISABLED."},"404":{"description":"NOT_FOUND"},"429":{"description":"RATE_LIMITED. Honour Retry-After."}},"parameters":[{"name":"variantRefs","in":"query","required":true,"description":"Comma-separated variant refs your cart holds (max 100). A ref missing from the response map is no longer purchasable, so drop that line.","schema":{"type":"string"}}]}},"/v1/store/checkouts":{"post":{"summary":"Open a hosted checkout. Returns a URL to send the buyer to; you never handle payment.","description":"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.","security":[{"bearerAuth":["checkout:create"]}],"responses":{"200":{"description":"StoreCheckoutSessionView"},"401":{"description":"UNAUTHORIZED. Missing, unknown, revoked or wrong-environment key."},"403":{"description":"ORIGIN_NOT_ALLOWED, INSUFFICIENT_SCOPE, STORE_API_SECRET_DISABLED or STORE_API_DISABLED."},"404":{"description":"NOT_FOUND"},"429":{"description":"RATE_LIMITED. Honour Retry-After."}},"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["items","returnUrl"],"properties":{"items":{"description":"Array of { variantRef, quantity }. Every variantRef must resolve to a live product in this store; refs come from GET /v1/store/products."},"returnUrl":{"description":"Where the buyer lands after paying. https, or localhost while testing."},"contact":{"description":"Prefills the hosted page. The buyer can still correct it."},"currency":{"description":"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":{"description":"Required when any line needs delivery. The zone fee is charged in the checkout currency."}}}}}},"parameters":[{"name":"Idempotency-Key","in":"header","required":true,"description":"A retry with the same value returns the SAME checkout.","schema":{"type":"string"}}]}},"/v1/store/events/{slug}/checkout":{"post":{"summary":"Open a hosted checkout for event tickets.","description":"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: <label>\"; a tier with requiresAttendeeDetails returns 400 \"Enter a name for each \\\"<tier>\\\" ticket\" unless every seat carries a name; and an age-gated tier fails the age check without a date of birth, on the buyer when dobMode is order and on each attendee when it is attendee. Our own hosted checkout collects all three for you, and lets the buyer name each guest even when the tier does not demand it, so this only applies when you build your own form.","security":[{"bearerAuth":["checkout:create"]}],"responses":{"200":{"description":"StoreCheckoutSessionView"},"401":{"description":"UNAUTHORIZED. Missing, unknown, revoked or wrong-environment key."},"403":{"description":"ORIGIN_NOT_ALLOWED, INSUFFICIENT_SCOPE, STORE_API_SECRET_DISABLED or STORE_API_DISABLED."},"404":{"description":"NOT_FOUND"},"429":{"description":"RATE_LIMITED. Honour Retry-After."}},"parameters":[{"name":"slug","in":"path","required":true,"description":"The event's slug.","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["tickets","contact","returnUrl"],"properties":{"tickets":{"description":"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":{"description":"NOT SUPPORTED YET: sending a non-empty array returns 400. Sell merchandise with a separate POST /v1/store/checkouts."},"contact":{"description":"Buyer name, email and optional phone. The email receives the tickets."},"dateOfBirth":{"description":"Buyer DOB as YYYY-MM-DD. Required when a chosen tier sets minAge and the event dobMode is order."},"responses":{"description":"Answers to the questions on the chosen tiers, keyed by the field key. Required whenever one of those fields is required."},"discountCode":{"description":"Promo code. Re-validated server-side."},"unlockCodes":{"description":"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":{"description":"Affiliate code from a share link. Ignored if unknown."},"currency":{"description":"Must be a currency the event sells in. Omit for its primary."},"returnUrl":{"description":"Where the buyer lands after paying."}}}}}}}},"/v1/store/events/{slug}/register":{"post":{"summary":"Register a guest for a FREE event tier from your own page. FrontDesk sends the confirmation, the calendar invite and the ticket.","description":"The one write a publishable key may make, so call it straight from the browser, from an origin registered on the key. FREE tiers only: a paid, donation or deposit tier answers 402 TIER_REQUIRES_PAYMENT, and that is your cue to open POST /v1/store/events/:slug/checkout from your server instead. Every rule the hosted storefront applies runs here too: an unknown, hidden-without-its-code, or not-yet-on-sale tier is refused, capacity answers 409 TIER_SOLD_OUT, minPerOrder/maxPerOrder are enforced, a required question left unanswered returns 400 \"Please answer: <label>\", a tier with requiresAttendeeDetails needs a name on every seat, and an age gate needs the date of birth. Duplicates are quiet: when the email already holds a seat on this event you get the SAME 201 as a first registration (their existing reference and status), the confirmation is re-sent at most once every 10 minutes, and no new seat is issued, so a retry is always safe when an email is given and no Idempotency-Key is needed. A test key runs every one of those checks and answers test:true with a test_ reference, writing nothing and sending nothing. There is no read-back endpoint: subscribe to the attendee.registered webhook, and the organiser sees every guest in their portal.","security":[{"bearerAuth":["events:register"]}],"responses":{"200":{"description":"StoreEventRegistrationView"},"201":{"description":"StoreEventRegistrationView"},"401":{"description":"UNAUTHORIZED. Missing, unknown, revoked or wrong-environment key."},"402":{"description":"TIER_REQUIRES_PAYMENT. A chosen tier costs money: open a checkout instead."},"403":{"description":"ORIGIN_NOT_ALLOWED, INSUFFICIENT_SCOPE, STORE_API_SECRET_DISABLED or STORE_API_DISABLED."},"404":{"description":"NOT_FOUND"},"409":{"description":"TIER_SOLD_OUT"},"429":{"description":"RATE_LIMITED. Honour Retry-After."}},"parameters":[{"name":"slug","in":"path","required":true,"description":"The event's slug.","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["tickets","contact"],"properties":{"tickets":{"description":"Same shape as checkout: ticketTypeRef, quantity, and optional attendees to name each seat. attendees is POSITIONAL and its length must be quantity x groupSize: leave an entry blank ({}) to keep that seat under the registrant, never drop it. Every tier must be free."},"contact":{"description":"The registrant: name, and an email and/or phone. The email receives the confirmation, the .ics invite and the ticket PDF; a phone-only guest gets an SMS."},"dateOfBirth":{"description":"YYYY-MM-DD. Required when a chosen tier sets minAge and the event dobMode is order; per attendee when it is attendee."},"responses":{"description":"Answers to the chosen tiers' questions, keyed by each formFields[].key from GET /v1/store/events/:slug. Required whenever one of those fields is required."},"unlockCodes":{"description":"Codes that reveal hidden or invite-only tiers. Required for such a tier."},"promoterCode":{"description":"Affiliate code from a share link. Ignored if unknown."}}}}}}}},"/v1/store/payment-methods":{"get":{"summary":"The payment rails this workspace can charge, so you can render your own picker.","security":[{"bearerAuth":["availability:read"]}],"responses":{"200":{"description":"string[]"},"401":{"description":"UNAUTHORIZED. Missing, unknown, revoked or wrong-environment key."},"403":{"description":"ORIGIN_NOT_ALLOWED, INSUFFICIENT_SCOPE, STORE_API_SECRET_DISABLED or STORE_API_DISABLED."},"404":{"description":"NOT_FOUND"},"429":{"description":"RATE_LIMITED. Honour Retry-After."}},"parameters":[{"name":"currency","in":"query","required":false,"description":"Defaults to the store primary.","schema":{"type":"string"}}]}},"/v1/store/events/{slug}/checkout/headless":{"post":{"summary":"Open a ticket checkout you render yourself, including the payment step.","description":"Seats are held until expiresAt. Name ONE rail: minting every rail at once would leave several live payment paths on one order, and a buyer could pay twice. Branch on the provider you asked for, never on which payment field is filled in: Paystack returns an accessCode AND a checkoutUrl, so code that redirects whenever checkoutUrl is present sends every card buyer to a Paystack page and never opens the popup. Paystack returns an accessCode for its inline popup: load js.paystack.co/v2/inline.js and call new PaystackPop().resumeTransaction({ accessCode, onSuccess, onCancel }). Pass ONE options object, because resumeTransaction takes a single argument and the older-looking resumeTransaction(accessCode, callbacks) silently falls back to a full-page redirect to Paystack. Stripe returns a checkoutUrl to send the buyer to. Bank transfer returns an account once the provider confirms it, so poll GET /v1/store/checkouts/:ref if it is null. Requires headless to be switched on for the workspace. Release the seats early with POST /v1/store/checkouts/:ref/cancel when the buyer walks away. Every rail returns the buyer to your returnUrl with ?checkout=<ref> appended, so they never see a FrontDesk page. Treat that redirect as a hint and confirm with GET /v1/store/checkouts/:ref. With a TEST key this creates a REAL order marked test (no money, no stock, no tickets) and charges against the provider sandbox — pay it with a provider test card. Where sandbox credentials are not wired, it validates and prices only: payment fields are null and orderRef is a synthetic test_ord_ value that resolves to nothing.","security":[{"bearerAuth":["checkout:create"]}],"responses":{"200":{"description":"StoreHeadlessCheckoutView"},"401":{"description":"UNAUTHORIZED. Missing, unknown, revoked or wrong-environment key."},"403":{"description":"ORIGIN_NOT_ALLOWED, INSUFFICIENT_SCOPE, STORE_API_SECRET_DISABLED or STORE_API_DISABLED."},"404":{"description":"NOT_FOUND"},"429":{"description":"RATE_LIMITED. Honour Retry-After."}},"parameters":[{"name":"slug","in":"path","required":true,"description":"The event's slug.","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["provider","tickets","contact","returnUrl"],"properties":{"provider":{"description":"The rail to use, from GET /v1/store/payment-methods."},"tickets":{"description":"Ticket lines: ticketTypeRef, quantity, optional amountMinor for donation tiers."},"contact":{"description":"Buyer name, email and optional phone."},"pricing":{"description":"Your computed totalMinor. We re-price and return PRICE_MISMATCH if it disagrees."},"returnUrl":{"description":"Your own page, where the buyer lands after paying. Must be https, or localhost while you build."}}}}}}}},"/v1/store/checkouts/{ref}":{"get":{"summary":"The state of a checkout, including the order ref once it is paid.","description":"THIS is how you confirm a purchase. The return redirect is a browser navigation and can be lost, replayed or forged. Never fulfil on it alone. Safe to poll while a buyer is paying: when the checkout is still open we ask the payment provider directly before answering, so you get a real answer even if a webhook is late or never arrives. Polling every few seconds is fine and costs you nothing extra.","security":[{"bearerAuth":["checkout:read"]}],"responses":{"200":{"description":"StoreCheckoutSessionView"},"401":{"description":"UNAUTHORIZED. Missing, unknown, revoked or wrong-environment key."},"403":{"description":"ORIGIN_NOT_ALLOWED, INSUFFICIENT_SCOPE, STORE_API_SECRET_DISABLED or STORE_API_DISABLED."},"404":{"description":"NOT_FOUND"},"429":{"description":"RATE_LIMITED. Honour Retry-After."}},"parameters":[{"name":"ref","in":"path","required":true,"description":"The checkout ref.","schema":{"type":"string"}}]}},"/v1/store/customers/link":{"post":{"summary":"Attach your own signed-in user to this workspace's contacts, so their purchases and plans line up under one person.","description":"Only link identities your own auth verified (a confirmed email or OTP-verified phone) — this endpoint trusts your assertion because the key already proves you are the merchant's backend. Idempotent per externalRef: repeat calls return the SAME link and alreadyLinked=true, and the response shape is identical whether the contact existed before or not, so nothing here can be used to probe who is already a customer. Fires the customer.linked webhook exactly once, on the link. With a TEST key this validates fully and returns synthetic test_cus_/test_con_ refs that resolve to nothing, creating no contact.","security":[{"bearerAuth":["customers:link"]}],"responses":{"200":{"description":"StoreCustomerLinkView"},"401":{"description":"UNAUTHORIZED. Missing, unknown, revoked or wrong-environment key."},"403":{"description":"ORIGIN_NOT_ALLOWED, INSUFFICIENT_SCOPE, STORE_API_SECRET_DISABLED or STORE_API_DISABLED."},"404":{"description":"NOT_FOUND"},"429":{"description":"RATE_LIMITED. Honour Retry-After."}},"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["email","externalRef"],"properties":{"email":{"description":"The email YOUR auth system verified. Never a guessed or unverified address."},"externalRef":{"description":"Your own user id (e.g. a Supabase Auth user id). The idempotency key: the same value always returns the same link."},"name":{"description":"Display name. Fills an empty contact name, never overwrites a set one."},"phone":{"description":"Optional phone for the contact."}}}}}}}},"/v1/store/orders":{"get":{"summary":"Your own store orders, newest change last — built for keeping a local copy (a Supabase mirror, a dashboard) in sync.","description":"The key's env picks the set: a live key never sees test orders and a test key never sees live ones, and `test` is also on every row. In-progress savings plans (`saving`) never appear — they are not orders yet. A page shorter than `limit` means you are caught up. Deletions arrive as the order.deleted webhook (today that is sandbox retention removing 90-day-old test orders). This is a sync and back-office read: to confirm a single purchase, keep using GET /v1/store/checkouts/:ref.","security":[{"bearerAuth":["orders:read"]}],"responses":{"200":{"description":"StoreOrderRecord[]"},"401":{"description":"UNAUTHORIZED. Missing, unknown, revoked or wrong-environment key."},"403":{"description":"ORIGIN_NOT_ALLOWED, INSUFFICIENT_SCOPE, STORE_API_SECRET_DISABLED or STORE_API_DISABLED."},"404":{"description":"NOT_FOUND"},"429":{"description":"RATE_LIMITED. Honour Retry-After."}},"parameters":[{"name":"updatedSince","in":"query","required":false,"description":"ISO 8601. Only orders changed at or after this moment, ordered by updatedAt asc. Feed the largest updatedAt you received back as the next cursor; the >= comparison re-sends the boundary row, so upsert by ref.","schema":{"type":"string"}},{"name":"limit","in":"query","required":false,"description":"Page size, 1 to 200. Defaults to 100.","schema":{"type":"string"}}]}},"/v1/store/otp/send":{"post":{"summary":"Send a one-time password to your own customer over WhatsApp, from your own connected WhatsApp number.","description":"This costs money on every send, whether or not your customer ever reads it: an authentication template is business-initiated, so the free 24-hour reply window does not apply. The response quotes what was charged. WhatsApp OTP in Nigeria is several times the price of SMS — the reasons to choose it are deliverability and one-tap copy, not cost, and an international destination costs roughly 11x the Nigerian rate. THE CODE IS NEVER RETURNED, even when we minted it: hold the challengeRef and call /v1/store/otp/verify. `to` comes back masked. Before this works the workspace needs a connected WhatsApp number and an approved authentication template (Set up OTP in the portal), and the destination's country on its allow-list, which starts as the workspace's own country only. Throttled per destination independently of this key's rate limit: 60 seconds between codes to one number, 5 an hour, 10 a day. Each KEY also has its own ceiling on total sends per rolling 24 hours (100 by default, settable per key in the portal), so one leaked credential cannot spend the workspace's whole daily WhatsApp allowance and take your other keys down with it. Nothing is charged when a guard refuses. WITH `sender: 'frontdesk'` none of the setup applies — no number to connect, no business verification, no template — but the code identifies FrontDesk rather than you, and the price is exactly the same. If we cannot send from our number it returns 503 OTP_SENDER_UNAVAILABLE and charges nothing; it never quietly falls back to yours, because a code from an unexpected number that you paid for is worse than a refusal you can read.","security":[{"bearerAuth":["otp:send"]}],"responses":{"200":{"description":"StoreOtpSendView"},"401":{"description":"UNAUTHORIZED. Missing, unknown, revoked or wrong-environment key."},"403":{"description":"ORIGIN_NOT_ALLOWED, INSUFFICIENT_SCOPE, STORE_API_SECRET_DISABLED or STORE_API_DISABLED."},"404":{"description":"NOT_FOUND"},"429":{"description":"RATE_LIMITED. Honour Retry-After."}},"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["to"],"properties":{"to":{"description":"Your customer's number in full international format, e.g. +2348012345678."},"code":{"description":"Your own code, digits only, up to 15 characters. Omit it and we mint a 6-digit one — which is also the ONLY way /v1/store/otp/verify can work, because a code you chose is one we never see in the clear."},"purpose":{"description":"Your own label for what this code was for (\"login\", \"payout\"). Stored for your send log, never shown to the customer."},"expiresInMinutes":{"description":"How long the code stays valid. Default 10, maximum 60."},"templateRef":{"description":"Which authentication template to send. Omit it when the channel has one, which is what 'Set up OTP' creates."},"sender":{"description":"Which number carries the code: 'own' (your connected WhatsApp number) or 'frontdesk' (ours). Omit it and your workspace's own setting decides, which is 'own' unless you changed it — so an integration written before this existed is unaffected. On 'frontdesk' the code arrives from FrontDesk rather than your business, because an authentication template's wording is fixed by WhatsApp and has no place for your name. Cannot be combined with templateRef: the template on our number is ours."}}}}}}}},"/v1/store/otp/verify":{"post":{"summary":"Check a code your customer typed against a challenge we minted.","description":"200 means verified. A failure is a RESULT rather than an API error, so it answers 422 with {verified:false, reason} instead of the usual error envelope — branch on `verified` either way. `reason` is invalid, expired or too_many_attempts. An unknown challengeRef, an expired challenge and a wrong code all answer identically on purpose, so this cannot be used to discover which refs exist. Five wrong guesses burns the challenge; so does one right one, because a code is good exactly once — send a new one rather than retrying. If you supplied your own `code` on send, this answers 409: we never saw it in the clear, and telling you `verified: true` for a check we cannot actually make would be worse than saying so.","security":[{"bearerAuth":["otp:verify"]}],"responses":{"200":{"description":"StoreOtpVerifyView"},"401":{"description":"UNAUTHORIZED. Missing, unknown, revoked or wrong-environment key."},"403":{"description":"ORIGIN_NOT_ALLOWED, INSUFFICIENT_SCOPE, STORE_API_SECRET_DISABLED or STORE_API_DISABLED."},"404":{"description":"NOT_FOUND"},"429":{"description":"RATE_LIMITED. Honour Retry-After."}},"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["challengeRef","code"],"properties":{"challengeRef":{"description":"The challengeRef from /v1/store/otp/send."},"code":{"description":"Exactly what your customer typed."}}}}}}}},"/v1/store/checkouts/{ref}/cancel":{"post":{"summary":"Cancel an open checkout and release the seats it was holding.","description":"Call this when your buyer abandons the payment step. A headless ticket checkout holds its tiers against capacity until expiresAt, so cancelling puts them back on sale straight away instead of leaving them out of stock for the rest of the window. Cancelling a checkout that is already paid returns 409, and a checkout that lapses on its own is released by us.","security":[{"bearerAuth":["checkout:cancel"]}],"responses":{"200":{"description":"StoreCheckoutSessionView"},"401":{"description":"UNAUTHORIZED. Missing, unknown, revoked or wrong-environment key."},"403":{"description":"ORIGIN_NOT_ALLOWED, INSUFFICIENT_SCOPE, STORE_API_SECRET_DISABLED or STORE_API_DISABLED."},"404":{"description":"NOT_FOUND"},"429":{"description":"RATE_LIMITED. Honour Retry-After."}},"parameters":[{"name":"ref","in":"path","required":true,"description":"The checkout ref.","schema":{"type":"string"}}]}}}}