# Security Source: https://docs.shoppex.io/account/security 2FA, active sessions, and the audit trail. Shoppex authentication runs on BetterAuth, with two-factor authentication, session management, and an audit trail for every shop. Manage your 2FA and sessions from your **profile** (avatar dropdown, top right, then **Profile**, or go to `/settings/me/profile`). The shop-level audit trail is under **Security** in the sidebar. ## Two-factor authentication Open your profile page. Shoppex supports time-based one-time passwords (TOTP) through any authenticator app, for example Google Authenticator, Authy, 1Password, Bitwarden, or any app that scans a QR code. When you enable 2FA, you scan the QR code with your app and enter a code to confirm, and you are done. Future logins from new devices prompt for the code. At enrollment, Shoppex gives you a set of **recovery codes**. Save them somewhere safe. If you lose your phone, they are the only way back into your account. ## Active sessions The session list on your profile page shows every device currently logged in. Revoke an individual session or sign out everywhere at once. This is useful after a lost device, or when you have been working on someone else's machine. ## Audit trail Every significant action by every teammate is recorded under **Security → Audit Trail** (`/security/audit`). Each entry captures: * Who acted (user ID). * What they did (action name, resource type, resource ID). * When (timestamp). * From where (IP and user agent). * What changed (before/after data for updates). Filter by user, action type, or date range. The log is kept indefinitely. ## Account recovery If you lose access to both your password and your 2FA device, identity verification can recover the account. Reach out through the [Shoppex Discord](https://discord.gg/VjrptT6Nvx) or [Telegram](https://t.me/shoppexhq). The team will verify your ownership and walk you through the recovery flow. # Team and stores Source: https://docs.shoppex.io/account/team Invite teammates with scoped access, and run more than one shop from one login. Each teammate on your shop uses their own Shoppex login and can hold a different role on each shop they access. Open the **Team** page from your avatar dropdown, top right of the dashboard, or go directly to `/settings/shop/team`. ## Roles * **Owner** — full access including billing, gateway connections, and shop deletion. There is one owner per shop. Ownership can be transferred. * **Admin** — everything except billing and ownership transfer. * **Manager** — orders, customers, products, fulfillment. No settings, no team management, no finances. * **Support** — orders, the inbox, customers. Read-only on products and settings. * **Viewer** — read-only across the dashboard. Useful for accountants or read-access integrations. Beyond these presets, each membership has a `permissions` JSON field for detailed overrides. For example, a Manager can also see billing, or a Support role can be blocked from customer contact details. Configure these overrides from the teammate's row in the team list. ## Inviting Send an invite by email. The recipient sees the invitation when they log in (or sign up if they do not have a Shoppex account yet). They can accept or decline. Accepted invites become active immediately. ## Revoking access Remove a teammate from the team list. They lose access on their next dashboard request. Existing tabs are not actively signed out, but every API call re-checks membership, so the next click fails. ## Multiple shops per login You can own and operate any number of shops from a single Shoppex login, with each shop fully independent. Each shop keeps its own: * URL (`yourshop.shoppex.io`) and optional [custom domain](/storefront/custom-domain). * Connected payment gateways. * Products, orders, customers, and reports. * Team and permissions. * Theme, branding, and storefront. Shared across all shops on the same account: your login credentials, your billing relationship with Shoppex, and your identity. ### Creating another shop From the **shop switcher** in the sidebar, choose **Create Store**. Pick a slug for the URL, such as `yournewshop.shoppex.io`, and the shop is ready. The new shop starts empty: set up gateways, products, and branding from scratch. There is no "copy from existing shop" flow. If you need similar settings across shops, document them somewhere and re-apply manually. ### Switching between shops The shop switcher in the sidebar moves you between shops instantly. Each shop has its own dashboard URL, so you can bookmark them individually. When you switch shops, the URL changes and the entire dashboard re-loads with that shop's data. Open shops in different tabs if you want to compare side-by-side. ### Teammates across shops A teammate can have different roles in different shops: for example, Manager on Shop A and Admin on Shop B. The shop switcher in the sidebar shows them every shop they can access. # Get current account Source: https://docs.shoppex.io/api-reference/account-&-auth/get-current-account /openapi.json get /dev/v1/me Developer API operation for GET /dev/v1/me. # Get current account capabilities Source: https://docs.shoppex.io/api-reference/account-&-auth/get-current-account-capabilities /openapi.json get /dev/v1/me/capabilities Developer API operation for GET /dev/v1/me/capabilities. # Approve affiliate application Source: https://docs.shoppex.io/api-reference/affiliates/approve-affiliate-application /openapi.json post /dev/v1/affiliates/applications/{id}/approve Developer API operation for POST /dev/v1/affiliates/applications/{id}/approve. # Approve affiliate payout request Source: https://docs.shoppex.io/api-reference/affiliates/approve-affiliate-payout-request /openapi.json post /dev/v1/affiliates/payout-requests/{id}/approve Developer API operation for POST /dev/v1/affiliates/payout-requests/{id}/approve. # Cancel affiliate payout request Source: https://docs.shoppex.io/api-reference/affiliates/cancel-affiliate-payout-request /openapi.json post /dev/v1/affiliates/payout-requests/{id}/cancel Developer API operation for POST /dev/v1/affiliates/payout-requests/{id}/cancel. # Complete affiliate payout request Source: https://docs.shoppex.io/api-reference/affiliates/complete-affiliate-payout-request /openapi.json post /dev/v1/affiliates/payout-requests/{id}/complete Developer API operation for POST /dev/v1/affiliates/payout-requests/{id}/complete. # Create affiliate link primary Source: https://docs.shoppex.io/api-reference/affiliates/create-affiliate-link-primary /openapi.json post /dev/v1/affiliates/links/{id}/primary Developer API operation for POST /dev/v1/affiliates/links/{id}/primary. # Force approve affiliate payout request Source: https://docs.shoppex.io/api-reference/affiliates/force-approve-affiliate-payout-request /openapi.json post /dev/v1/affiliates/payout-requests/{id}/force-approve Developer API operation for POST /dev/v1/affiliates/payout-requests/{id}/force-approve. # Get affiliate analytics Source: https://docs.shoppex.io/api-reference/affiliates/get-affiliate-analytics /openapi.json get /dev/v1/affiliates/analytics Developer API operation for GET /dev/v1/affiliates/analytics. # Get affiliate customer performance Source: https://docs.shoppex.io/api-reference/affiliates/get-affiliate-customer-performance /openapi.json get /dev/v1/affiliates/customers/{customerId}/performance Developer API operation for GET /dev/v1/affiliates/customers/{customerId}/performance. # Get affiliate settings Source: https://docs.shoppex.io/api-reference/affiliates/get-affiliate-settings /openapi.json get /dev/v1/affiliates/settings Developer API operation for GET /dev/v1/affiliates/settings. # Invite affiliate customer Source: https://docs.shoppex.io/api-reference/affiliates/invite-affiliate-customer /openapi.json post /dev/v1/affiliates/customers/{customerId}/invite Developer API operation for POST /dev/v1/affiliates/customers/{customerId}/invite. # List affiliate applications Source: https://docs.shoppex.io/api-reference/affiliates/list-affiliate-applications /openapi.json get /dev/v1/affiliates/applications Developer API operation for GET /dev/v1/affiliates/applications. # List affiliate customers Source: https://docs.shoppex.io/api-reference/affiliates/list-affiliate-customers /openapi.json get /dev/v1/affiliates/customers Developer API operation for GET /dev/v1/affiliates/customers. # List affiliate payout requests Source: https://docs.shoppex.io/api-reference/affiliates/list-affiliate-payout-requests /openapi.json get /dev/v1/affiliates/payout-requests Developer API operation for GET /dev/v1/affiliates/payout-requests. # Reject affiliate application Source: https://docs.shoppex.io/api-reference/affiliates/reject-affiliate-application /openapi.json post /dev/v1/affiliates/applications/{id}/reject Developer API operation for POST /dev/v1/affiliates/applications/{id}/reject. # Reject affiliate payout request Source: https://docs.shoppex.io/api-reference/affiliates/reject-affiliate-payout-request /openapi.json post /dev/v1/affiliates/payout-requests/{id}/reject Developer API operation for POST /dev/v1/affiliates/payout-requests/{id}/reject. # Update affiliate customer status Source: https://docs.shoppex.io/api-reference/affiliates/update-affiliate-customer-status /openapi.json patch /dev/v1/affiliates/customers/{customerId}/status Developer API operation for PATCH /dev/v1/affiliates/customers/{customerId}/status. # Update affiliate link Source: https://docs.shoppex.io/api-reference/affiliates/update-affiliate-link /openapi.json patch /dev/v1/affiliates/links/{id} Developer API operation for PATCH /dev/v1/affiliates/links/{id}. # Update affiliate settings Source: https://docs.shoppex.io/api-reference/affiliates/update-affiliate-settings /openapi.json patch /dev/v1/affiliates/settings Developer API operation for PATCH /dev/v1/affiliates/settings. # Create an allowlist entry Source: https://docs.shoppex.io/api-reference/allowlist/create-an-allowlist-entry /openapi.json post /dev/v1/allowlist Developer API operation for POST /dev/v1/allowlist. # Delete an allowlist entry Source: https://docs.shoppex.io/api-reference/allowlist/delete-an-allowlist-entry /openapi.json delete /dev/v1/allowlist/{id} Developer API operation for DELETE /dev/v1/allowlist/{id}. # Get an allowlist entry Source: https://docs.shoppex.io/api-reference/allowlist/get-an-allowlist-entry /openapi.json get /dev/v1/allowlist/{id} Developer API operation for GET /dev/v1/allowlist/{id}. # List allowlist entries Source: https://docs.shoppex.io/api-reference/allowlist/list-allowlist-entries /openapi.json get /dev/v1/allowlist Developer API operation for GET /dev/v1/allowlist. # Create analytics report Source: https://docs.shoppex.io/api-reference/analytics/create-analytics-report /openapi.json post /dev/v1/analytics/reports Developer API operation for POST /dev/v1/analytics/reports. # Delete analytics report Source: https://docs.shoppex.io/api-reference/analytics/delete-analytics-report /openapi.json delete /dev/v1/analytics/reports/{id} Developer API operation for DELETE /dev/v1/analytics/reports/{id}. # Generate analytics report Source: https://docs.shoppex.io/api-reference/analytics/generate-analytics-report /openapi.json post /dev/v1/analytics/reports/{id}/generate Developer API operation for POST /dev/v1/analytics/reports/{id}/generate. # Get analytics revenue Source: https://docs.shoppex.io/api-reference/analytics/get-analytics-revenue /openapi.json get /dev/v1/analytics/revenue Developer API operation for GET /dev/v1/analytics/revenue. # List analytics reports Source: https://docs.shoppex.io/api-reference/analytics/list-analytics-reports /openapi.json get /dev/v1/analytics/reports Developer API operation for GET /dev/v1/analytics/reports. # Create a blacklist entry Source: https://docs.shoppex.io/api-reference/blacklist/create-a-blacklist-entry /openapi.json post /dev/v1/blacklist Developer API operation for POST /dev/v1/blacklist. # Delete blacklist entry Source: https://docs.shoppex.io/api-reference/blacklist/delete-blacklist-entry /openapi.json delete /dev/v1/blacklist/{id} Developer API operation for DELETE /dev/v1/blacklist/{id}. # Get blacklist entry Source: https://docs.shoppex.io/api-reference/blacklist/get-blacklist-entry /openapi.json get /dev/v1/blacklist/{id} Developer API operation for GET /dev/v1/blacklist/{id}. # List blacklist entries Source: https://docs.shoppex.io/api-reference/blacklist/list-blacklist-entries /openapi.json get /dev/v1/blacklist Developer API operation for GET /dev/v1/blacklist. # Create category Source: https://docs.shoppex.io/api-reference/categories/create-category /openapi.json post /dev/v1/categories Creates a category for the current shop. The response includes `cdn_image_url`, which is `null` unless the category already has a cover image. # Delete category Source: https://docs.shoppex.io/api-reference/categories/delete-category /openapi.json delete /dev/v1/categories/{id} Deletes a category and unbinds linked products from it. # Get category Source: https://docs.shoppex.io/api-reference/categories/get-category /openapi.json get /dev/v1/categories/{id} Returns one category by uniqid, including its public cover URL in `cdn_image_url` when a category image exists. # List categories Source: https://docs.shoppex.io/api-reference/categories/list-categories /openapi.json get /dev/v1/categories Returns categories for the current shop, including the public category cover URL in `cdn_image_url` when a category image exists. # Update category Source: https://docs.shoppex.io/api-reference/categories/update-category /openapi.json patch /dev/v1/categories/{id} Updates an existing category. The response keeps returning the current `cdn_image_url` so clients can continue rendering the category cover after non-image edits. # Enqueue code storefront build Source: https://docs.shoppex.io/api-reference/code-storefronts/enqueue-code-storefront-build /openapi.json post /dev/v1/code-storefronts/{id}/builds Developer API operation for POST /dev/v1/code-storefronts/{id}/builds. # Export code storefront source as a ZIP archive Source: https://docs.shoppex.io/api-reference/code-storefronts/export-code-storefront-source-as-a-zip-archive /openapi.json get /dev/v1/code-storefronts/{id}/source/archive Developer API operation for GET /dev/v1/code-storefronts/{id}/source/archive. # Get code storefront source file Source: https://docs.shoppex.io/api-reference/code-storefronts/get-code-storefront-source-file /openapi.json get /dev/v1/code-storefronts/{id}/source/content Developer API operation for GET /dev/v1/code-storefronts/{id}/source/content. # Get code storefront source manifest Source: https://docs.shoppex.io/api-reference/code-storefronts/get-code-storefront-source-manifest /openapi.json get /dev/v1/code-storefronts/{id}/source Developer API operation for GET /dev/v1/code-storefronts/{id}/source. # Get current code storefront build Source: https://docs.shoppex.io/api-reference/code-storefronts/get-current-code-storefront-build /openapi.json get /dev/v1/code-storefronts/{id}/builds/current Developer API operation for GET /dev/v1/code-storefronts/{id}/builds/current. # List code storefronts Source: https://docs.shoppex.io/api-reference/code-storefronts/list-code-storefronts /openapi.json get /dev/v1/code-storefronts Developer API operation for GET /dev/v1/code-storefronts. # Publish code storefront Source: https://docs.shoppex.io/api-reference/code-storefronts/publish-code-storefront /openapi.json post /dev/v1/code-storefronts/{id}/publish Developer API operation for POST /dev/v1/code-storefronts/{id}/publish. # Replace code storefront source Source: https://docs.shoppex.io/api-reference/code-storefronts/replace-code-storefront-source /openapi.json put /dev/v1/code-storefronts/{id}/source Developer API operation for PUT /dev/v1/code-storefronts/{id}/source. # Replace code storefront source from a ZIP archive Source: https://docs.shoppex.io/api-reference/code-storefronts/replace-code-storefront-source-from-a-zip-archive /openapi.json put /dev/v1/code-storefronts/{id}/source/archive Developer API operation for PUT /dev/v1/code-storefronts/{id}/source/archive. # Create coupon Source: https://docs.shoppex.io/api-reference/coupons/create-coupon /openapi.json post /dev/v1/coupons Creates a coupon for the authenticated shop. # Delete coupon Source: https://docs.shoppex.io/api-reference/coupons/delete-coupon /openapi.json delete /dev/v1/coupons/{id} Deletes a coupon, or deactivates it when invoices already reference it. # Get coupon Source: https://docs.shoppex.io/api-reference/coupons/get-coupon /openapi.json get /dev/v1/coupons/{id} Returns one coupon by uniqid for the authenticated shop. # List coupons Source: https://docs.shoppex.io/api-reference/coupons/list-coupons /openapi.json get /dev/v1/coupons Returns active coupons for the authenticated shop. # Update coupon Source: https://docs.shoppex.io/api-reference/coupons/update-coupon /openapi.json patch /dev/v1/coupons/{id} Updates a coupon for the authenticated shop. # Validate coupon by code Source: https://docs.shoppex.io/api-reference/coupons/validate-coupon-by-code /openapi.json get /dev/v1/coupons/code/{code} Validates a coupon server-side by code. Use `product_id` for single-product checks or `cart` with a JSON string payload for cart validation. # Convert affiliate balance Source: https://docs.shoppex.io/api-reference/customers/convert-affiliate-balance /openapi.json post /dev/v1/customers/{id}/affiliate/convert-to-balance Developer API operation for POST /dev/v1/customers/{id}/affiliate/convert-to-balance. # Create customer Source: https://docs.shoppex.io/api-reference/customers/create-customer /openapi.json post /dev/v1/customers Developer API operation for POST /dev/v1/customers. # Create customer affiliate payout request Source: https://docs.shoppex.io/api-reference/customers/create-customer-affiliate-payout-request /openapi.json post /dev/v1/customers/{id}/affiliate/payout-requests Developer API operation for POST /dev/v1/customers/{id}/affiliate/payout-requests. # Credit customer wallet Source: https://docs.shoppex.io/api-reference/customers/credit-customer-wallet /openapi.json post /dev/v1/customers/{id}/wallet/credit Developer API operation for POST /dev/v1/customers/{id}/wallet/credit. # Debit customer wallet Source: https://docs.shoppex.io/api-reference/customers/debit-customer-wallet /openapi.json post /dev/v1/customers/{id}/wallet/debit Developer API operation for POST /dev/v1/customers/{id}/wallet/debit. # Delete customer Source: https://docs.shoppex.io/api-reference/customers/delete-customer /openapi.json delete /dev/v1/customers/{id} Developer API operation for DELETE /dev/v1/customers/{id}. # Get customer Source: https://docs.shoppex.io/api-reference/customers/get-customer /openapi.json get /dev/v1/customers/{id} Developer API operation for GET /dev/v1/customers/{id}. # Get customer affiliate Source: https://docs.shoppex.io/api-reference/customers/get-customer-affiliate /openapi.json get /dev/v1/customers/{id}/affiliate Developer API operation for GET /dev/v1/customers/{id}/affiliate. # Get customer wallet Source: https://docs.shoppex.io/api-reference/customers/get-customer-wallet /openapi.json get /dev/v1/customers/{id}/wallet Developer API operation for GET /dev/v1/customers/{id}/wallet. # List customers Source: https://docs.shoppex.io/api-reference/customers/list-customers /openapi.json get /dev/v1/customers Developer API operation for GET /dev/v1/customers. # Update customer Source: https://docs.shoppex.io/api-reference/customers/update-customer /openapi.json patch /dev/v1/customers/{id} Developer API operation for PATCH /dev/v1/customers/{id}. # Get dispute Source: https://docs.shoppex.io/api-reference/disputes-&-escrow/get-dispute /openapi.json get /dev/v1/disputes/{id} Developer API operation for GET /dev/v1/disputes/{id}. # Get escrow record Source: https://docs.shoppex.io/api-reference/disputes-&-escrow/get-escrow-record /openapi.json get /dev/v1/escrow/{id} Developer API operation for GET /dev/v1/escrow/{id}. # List disputes Source: https://docs.shoppex.io/api-reference/disputes-&-escrow/list-disputes /openapi.json get /dev/v1/disputes Developer API operation for GET /dev/v1/disputes. # List escrow records Source: https://docs.shoppex.io/api-reference/disputes-&-escrow/list-escrow-records /openapi.json get /dev/v1/escrow Developer API operation for GET /dev/v1/escrow. # Release escrow record Source: https://docs.shoppex.io/api-reference/disputes-&-escrow/release-escrow-record /openapi.json post /dev/v1/escrow/{id}/release Developer API operation for POST /dev/v1/escrow/{id}/release. # Create email marketing automation Source: https://docs.shoppex.io/api-reference/email-marketing/create-email-marketing-automation /openapi.json post /dev/v1/email-marketing/automations Developer API operation for POST /dev/v1/email-marketing/automations. # Create email marketing automation activate Source: https://docs.shoppex.io/api-reference/email-marketing/create-email-marketing-automation-activate /openapi.json post /dev/v1/email-marketing/automations/{id}/activate Developer API operation for POST /dev/v1/email-marketing/automations/{id}/activate. # Create email marketing automation trigger Source: https://docs.shoppex.io/api-reference/email-marketing/create-email-marketing-automation-trigger /openapi.json post /dev/v1/email-marketing/automations/trigger Developer API operation for POST /dev/v1/email-marketing/automations/trigger. # Create email marketing campaign Source: https://docs.shoppex.io/api-reference/email-marketing/create-email-marketing-campaign /openapi.json post /dev/v1/email-marketing/campaigns Developer API operation for POST /dev/v1/email-marketing/campaigns. # Create email marketing contact Source: https://docs.shoppex.io/api-reference/email-marketing/create-email-marketing-contact /openapi.json post /dev/v1/email-marketing/contacts Developer API operation for POST /dev/v1/email-marketing/contacts. # Create email marketing segment Source: https://docs.shoppex.io/api-reference/email-marketing/create-email-marketing-segment /openapi.json post /dev/v1/email-marketing/segments Developer API operation for POST /dev/v1/email-marketing/segments. # Create email marketing suppression Source: https://docs.shoppex.io/api-reference/email-marketing/create-email-marketing-suppression /openapi.json post /dev/v1/email-marketing/suppressions Developer API operation for POST /dev/v1/email-marketing/suppressions. # Get email marketing billing usage Source: https://docs.shoppex.io/api-reference/email-marketing/get-email-marketing-billing-usage /openapi.json get /dev/v1/email-marketing/billing/usage Developer API operation for GET /dev/v1/email-marketing/billing/usage. # Get email marketing campaign Source: https://docs.shoppex.io/api-reference/email-marketing/get-email-marketing-campaign /openapi.json get /dev/v1/email-marketing/campaigns/{id} Developer API operation for GET /dev/v1/email-marketing/campaigns/{id}. # Get email marketing list Source: https://docs.shoppex.io/api-reference/email-marketing/get-email-marketing-list /openapi.json get /dev/v1/email-marketing/lists/{id} Developer API operation for GET /dev/v1/email-marketing/lists/{id}. # Get email marketing settings Source: https://docs.shoppex.io/api-reference/email-marketing/get-email-marketing-settings /openapi.json get /dev/v1/email-marketing/settings Developer API operation for GET /dev/v1/email-marketing/settings. # List email marketing automations Source: https://docs.shoppex.io/api-reference/email-marketing/list-email-marketing-automations /openapi.json get /dev/v1/email-marketing/automations Developer API operation for GET /dev/v1/email-marketing/automations. # List email marketing campaigns Source: https://docs.shoppex.io/api-reference/email-marketing/list-email-marketing-campaigns /openapi.json get /dev/v1/email-marketing/campaigns Developer API operation for GET /dev/v1/email-marketing/campaigns. # List email marketing contacts Source: https://docs.shoppex.io/api-reference/email-marketing/list-email-marketing-contacts /openapi.json get /dev/v1/email-marketing/contacts Developer API operation for GET /dev/v1/email-marketing/contacts. # List email marketing lists Source: https://docs.shoppex.io/api-reference/email-marketing/list-email-marketing-lists /openapi.json get /dev/v1/email-marketing/lists Developer API operation for GET /dev/v1/email-marketing/lists. # List email marketing segments Source: https://docs.shoppex.io/api-reference/email-marketing/list-email-marketing-segments /openapi.json get /dev/v1/email-marketing/segments Developer API operation for GET /dev/v1/email-marketing/segments. # List email marketing suppressions Source: https://docs.shoppex.io/api-reference/email-marketing/list-email-marketing-suppressions /openapi.json get /dev/v1/email-marketing/suppressions Developer API operation for GET /dev/v1/email-marketing/suppressions. # Create group Source: https://docs.shoppex.io/api-reference/groups/create-group /openapi.json post /dev/v1/groups Developer API operation for POST /dev/v1/groups. # Delete group Source: https://docs.shoppex.io/api-reference/groups/delete-group /openapi.json delete /dev/v1/groups/{uniqid} Developer API operation for DELETE /dev/v1/groups/{uniqid}. # Get group Source: https://docs.shoppex.io/api-reference/groups/get-group /openapi.json get /dev/v1/groups/{uniqid} Developer API operation for GET /dev/v1/groups/{uniqid}. # List groups Source: https://docs.shoppex.io/api-reference/groups/list-groups /openapi.json get /dev/v1/groups Developer API operation for GET /dev/v1/groups. # Update group Source: https://docs.shoppex.io/api-reference/groups/update-group /openapi.json patch /dev/v1/groups/{uniqid} Developer API operation for PATCH /dev/v1/groups/{uniqid}. # Get invoice Source: https://docs.shoppex.io/api-reference/invoices/get-invoice /openapi.json get /dev/v1/invoices/{uniqid} Developer API operation for GET /dev/v1/invoices/{uniqid}. # Issue invoice replacement Source: https://docs.shoppex.io/api-reference/invoices/issue-invoice-replacement /openapi.json post /dev/v1/invoices/{uniqid}/replacements Issues replacement serials for a completed serial-based invoice. By default, replacements cannot exceed the original purchased quantity. Set `override_remaining_quantity` to `true` for a manual support override when a previous replacement also failed. # List invoices Source: https://docs.shoppex.io/api-reference/invoices/list-invoices /openapi.json get /dev/v1/invoices Developer API operation for GET /dev/v1/invoices. # Trigger server-side invoice completion Source: https://docs.shoppex.io/api-reference/invoices/trigger-server-side-invoice-completion /openapi.json post /dev/v1/invoices/{uniqid}/complete Completes or processes a `PENDING` or `PENDING_PAYMENT` invoice through the existing completion pipeline. This is the public Developer API equivalent of the Dashboard’s **Process Invoice** action. The buyer email states that the merchant completed the order and that Shoppex did not verify the payment. `Idempotency-Key` is required. # Update invoice Source: https://docs.shoppex.io/api-reference/invoices/update-invoice /openapi.json patch /dev/v1/invoices/{uniqid} Developer API operation for PATCH /dev/v1/invoices/{uniqid}. # Void invoice Source: https://docs.shoppex.io/api-reference/invoices/void-invoice /openapi.json post /dev/v1/invoices/{uniqid}/void Voids a non-completed invoice through the existing invoice void pipeline. # Create license Source: https://docs.shoppex.io/api-reference/licenses/create-license /openapi.json post /dev/v1/licenses Developer API operation for POST /dev/v1/licenses. # Delete license Source: https://docs.shoppex.io/api-reference/licenses/delete-license /openapi.json delete /dev/v1/licenses/{id} Developer API operation for DELETE /dev/v1/licenses/{id}. # Get license Source: https://docs.shoppex.io/api-reference/licenses/get-license /openapi.json get /dev/v1/licenses/{id} Developer API operation for GET /dev/v1/licenses/{id}. # Get license product Source: https://docs.shoppex.io/api-reference/licenses/get-license-product /openapi.json get /dev/v1/licenses/products/{product_id} Developer API operation for GET /dev/v1/licenses/products/{product_id}. # List licenses Source: https://docs.shoppex.io/api-reference/licenses/list-licenses /openapi.json get /dev/v1/licenses Developer API operation for GET /dev/v1/licenses. # Reset license HWID Source: https://docs.shoppex.io/api-reference/licenses/reset-license-hwid /openapi.json post /dev/v1/licenses/{id}/reset-hwid Developer API operation for POST /dev/v1/licenses/{id}/reset-hwid. # Update license Source: https://docs.shoppex.io/api-reference/licenses/update-license /openapi.json patch /dev/v1/licenses/{id} Developer API operation for PATCH /dev/v1/licenses/{id}. # Update license HWID Source: https://docs.shoppex.io/api-reference/licenses/update-license-hwid /openapi.json patch /dev/v1/licenses/keys/{key}/hwid Developer API operation for PATCH /dev/v1/licenses/keys/{key}/hwid. # Validate license Source: https://docs.shoppex.io/api-reference/licenses/validate-license /openapi.json post /dev/v1/licenses/validate Developer API operation for POST /dev/v1/licenses/validate. # Authorize an OAuth app Source: https://docs.shoppex.io/api-reference/oauth2/authorize-an-oauth-app /openapi.json get /dev/v1/oauth/authorize Developer API operation for GET /dev/v1/oauth/authorize. # Confirm OAuth app authorization Source: https://docs.shoppex.io/api-reference/oauth2/confirm-oauth-app-authorization /openapi.json post /dev/v1/oauth/authorize Developer API operation for POST /dev/v1/oauth/authorize. # Exchange an OAuth code or refresh token Source: https://docs.shoppex.io/api-reference/oauth2/exchange-an-oauth-code-or-refresh-token /openapi.json post /dev/v1/oauth/token Developer API operation for POST /dev/v1/oauth/token. # Create order from product line items Source: https://docs.shoppex.io/api-reference/orders/create-order-from-product-line-items /openapi.json post /dev/v1/orders Creates a server-side Shoppex order from product or variant line items. Use `gateway` to preselect a valid active gateway for the resulting pending order. # Fulfill an order line item Source: https://docs.shoppex.io/api-reference/orders/fulfill-an-order-line-item /openapi.json post /dev/v1/orders/{id}/items/{itemId}/fulfill Delivers content for one line item on a completed order. A dynamic-delivery webhook may acknowledge the original delivery request with `{ "status": "pending" }` and later push the final content through this endpoint. # Get order details Source: https://docs.shoppex.io/api-reference/orders/get-order-details /openapi.json get /dev/v1/orders/{id} Returns a single Shoppex order by internal order ID or public uniqid. The response can include `custom_fields` set during invoice creation, for example `{ "source": "affiliate", "campaign": "spring_launch" }`. # List orders Source: https://docs.shoppex.io/api-reference/orders/list-orders /openapi.json get /dev/v1/orders Returns Shoppex orders created from invoices. Order payloads can include `custom_fields`, which are commonly used to carry internal routing markers from invoice creation. Example: `{ "custom_fields": { "source": "affiliate", "campaign": "spring_launch" } }`. # Refund an order Source: https://docs.shoppex.io/api-reference/orders/refund-an-order /openapi.json post /dev/v1/orders/{id}/refund Refunds the payment behind an order, either in full or for a partial `amount`. `Idempotency-Key` is required so retries reuse the same provider operation. Only gateways with refund support accept this call; unsupported gateways are rejected with a validation error. # Replace order affiliate attribution Source: https://docs.shoppex.io/api-reference/orders/replace-order-affiliate-attribution /openapi.json put /dev/v1/orders/{id} Alias for `PATCH /dev/v1/orders/:id`. # Retry a failed line-item delivery Source: https://docs.shoppex.io/api-reference/orders/retry-a-failed-line-item-delivery /openapi.json post /dev/v1/orders/{id}/items/{itemId}/retry-delivery Re-posts the dynamic-delivery webhook for a failed line item. Warning: the vendor may already have provisioned the item during the first ambiguous failure, so retrying can cause double delivery. # Trigger server-side completion Source: https://docs.shoppex.io/api-reference/orders/trigger-server-side-completion /openapi.json post /dev/v1/orders/{id}/complete Completes a pending order server-side through the existing invoice completion pipeline. This is an explicit alias for integrations that look for a completion endpoint. The buyer email states that the merchant completed the order and that Shoppex did not verify the payment. # Trigger server-side fulfillment Source: https://docs.shoppex.io/api-reference/orders/trigger-server-side-fulfillment /openapi.json post /dev/v1/orders/{id}/fulfill Completes and fulfills a pending order server-side through the existing invoice completion pipeline. The buyer email states that the merchant completed the order and that Shoppex did not verify the payment. # Update order affiliate attribution Source: https://docs.shoppex.io/api-reference/orders/update-order-affiliate-attribution /openapi.json patch /dev/v1/orders/{id} Assigns affiliate attribution to a pending order using `affiliate_code`, `referral_code`, or `affiliate_id`. This updates attribution and pending commission records, but does not recalculate order totals or discounts. # Create payment link Source: https://docs.shoppex.io/api-reference/payment-links/create-payment-link /openapi.json post /dev/v1/payment-links Developer API operation for POST /dev/v1/payment-links. # Delete payment link Source: https://docs.shoppex.io/api-reference/payment-links/delete-payment-link /openapi.json delete /dev/v1/payment-links/{id} Developer API operation for DELETE /dev/v1/payment-links/{id}. # Get payment link Source: https://docs.shoppex.io/api-reference/payment-links/get-payment-link /openapi.json get /dev/v1/payment-links/{id} Developer API operation for GET /dev/v1/payment-links/{id}. # List payment links Source: https://docs.shoppex.io/api-reference/payment-links/list-payment-links /openapi.json get /dev/v1/payment-links Developer API operation for GET /dev/v1/payment-links. # Toggle payment link Source: https://docs.shoppex.io/api-reference/payment-links/toggle-payment-link /openapi.json post /dev/v1/payment-links/{id}/toggle Developer API operation for POST /dev/v1/payment-links/{id}/toggle. # Update payment link Source: https://docs.shoppex.io/api-reference/payment-links/update-payment-link /openapi.json patch /dev/v1/payment-links/{id} Developer API operation for PATCH /dev/v1/payment-links/{id}. # Complete payment Source: https://docs.shoppex.io/api-reference/payments/complete-payment /openapi.json post /dev/v1/payments/{uniqid}/complete Triggers server-side completion for a pending developer payment. This confirms the payment state only and does not create product fulfillment. The buyer receives a neutral merchant-confirmed completion email that does not claim Shoppex verified the payment. Use `/dev/v1/orders/:id/fulfill` for deliverable orders. # Create checkout session Source: https://docs.shoppex.io/api-reference/payments/create-checkout-session /openapi.json post /dev/v1/checkout/sessions Stripe-style alias for `POST /dev/v1/payments`. Accepts `amount`, `success_url`, `cancel_url`, `customer_email`, and a single `product` descriptor; returns a session shape with `id`, `url`, and `status`. Internally delegates to the payments endpoint, so PSP orchestration, scopes, and webhook behavior are identical; `Idempotency-Key` replay is handled by this endpoint and returns the stored session response. For multi-item checkouts, build the cart on your side and call this endpoint with the aggregated amount. # Create payment Source: https://docs.shoppex.io/api-reference/payments/create-payment /openapi.json post /dev/v1/payments Creates a generic developer payment request. This endpoint does not create fulfillable product delivery. Use `/dev/v1/orders` when you need product line items and Shoppex fulfillment. If `webhook` is provided, the response includes `webhook_secret` once; use it to verify the `order:paid` callback for this specific payment/invoice after the payment reaches `COMPLETED`. For `gateway=PANDABASE` or `gateway=AUTHORIZENET`, Shoppex also creates the provider session and returns `checkout_url` plus `session_id` so the payment can complete automatically through the provider webhook flow. For merchant crypto providers (`OXAPAY`, `CRYPTOMUS`, `NOWPAYMENTS`), pass `crypto_gateway` to return a hosted checkout URL that opens the concrete coin or network directly. Native crypto uses the shop's non-custodial wallet when `crypto_gateway=BITCOIN|LITECOIN|USDT_TRC20|SOLANA|USDT_SOL|USDC_SOL` is requested and a native wallet is active for that chain; send `gateway=NATIVE_CRYPTO` to require that path and fail if it is not configured. Sending `crypto_gateway` without `gateway` starts a crypto session on the shop's active native wallet or connected crypto provider and fails when neither is configured. Unsupported gateway values return a validation error and never fall back to another provider. All crypto payments use the shop's own accounts; Shoppex-managed white-label crypto has been removed. # Get payment Source: https://docs.shoppex.io/api-reference/payments/get-payment /openapi.json get /dev/v1/payments/{uniqid} Returns a single developer payment. Session-backed gateways can include `checkout_url` and `session_id` when Shoppex created the provider session. # List payments Source: https://docs.shoppex.io/api-reference/payments/list-payments /openapi.json get /dev/v1/payments Returns developer payments for the current shop. This endpoint lists payment records you already created through the Developer API; it does not return the shop's currently enabled payment methods. Use `/dev/v1/me/capabilities` when you need the payment methods enabled for the current shop. Session-backed gateways can include `checkout_url` and `session_id` when Shoppex owns the provider session. # Void payment Source: https://docs.shoppex.io/api-reference/payments/void-payment /openapi.json delete /dev/v1/payments/{uniqid} Voids a pending developer payment. # Add product serials Source: https://docs.shoppex.io/api-reference/products/add-product-serials /openapi.json post /dev/v1/products/{id}/serials Adds serial inventory to a base serial product without variants. This endpoint appends inventory. It does not replace the full serial list. # Bulk update products Source: https://docs.shoppex.io/api-reference/products/bulk-update-products /openapi.json patch /dev/v1/products/bulk Developer API operation for PATCH /dev/v1/products/bulk. # Claim product serials Source: https://docs.shoppex.io/api-reference/products/claim-product-serials /openapi.json post /dev/v1/products/{id}/serials/claim Atomically claims and removes the requested number of available serials from a base serial product without variants, then returns the claimed serial values plus the updated remaining stock. Use this endpoint when you want serial values directly and do not want to manually fetch serial IDs first. # Create product Source: https://docs.shoppex.io/api-reference/products/create-product /openapi.json post /dev/v1/products Developer API operation for POST /dev/v1/products. # Create product addon Source: https://docs.shoppex.io/api-reference/products/create-product-addon /openapi.json post /dev/v1/products/addons Developer API operation for POST /dev/v1/products/addons. # Create product bundle Source: https://docs.shoppex.io/api-reference/products/create-product-bundle /openapi.json post /dev/v1/products/bundles Developer API operation for POST /dev/v1/products/bundles. # Create product field option Source: https://docs.shoppex.io/api-reference/products/create-product-field-option /openapi.json post /dev/v1/products/{id}/variants/fields/{fieldId}/options Developer API operation for POST /dev/v1/products/{id}/variants/fields/{fieldId}/options. # Create product quantity deal Source: https://docs.shoppex.io/api-reference/products/create-product-quantity-deal /openapi.json post /dev/v1/products/quantity-deals Developer API operation for POST /dev/v1/products/quantity-deals. # Create product variant Source: https://docs.shoppex.io/api-reference/products/create-product-variant /openapi.json post /dev/v1/products/{id}/variants Developer API operation for POST /dev/v1/products/{id}/variants. # Create product variant field Source: https://docs.shoppex.io/api-reference/products/create-product-variant-field /openapi.json post /dev/v1/products/{id}/variants/fields Developer API operation for POST /dev/v1/products/{id}/variants/fields. # Create product variant serial Source: https://docs.shoppex.io/api-reference/products/create-product-variant-serial /openapi.json post /dev/v1/products/{id}/variants/{variantId}/serials Developer API operation for POST /dev/v1/products/{id}/variants/{variantId}/serials. # Delete all product serials Source: https://docs.shoppex.io/api-reference/products/delete-all-product-serials /openapi.json delete /dev/v1/products/{id}/serials/all Removes all currently linked serial inventory entries from a base serial product without variants. Use the optional `search` filter to remove only matching serials. # Delete product Source: https://docs.shoppex.io/api-reference/products/delete-product /openapi.json delete /dev/v1/products/{id} Developer API operation for DELETE /dev/v1/products/{id}. # Delete product addon Source: https://docs.shoppex.io/api-reference/products/delete-product-addon /openapi.json delete /dev/v1/products/addons/{uniqid} Developer API operation for DELETE /dev/v1/products/addons/{uniqid}. # Delete product bundle Source: https://docs.shoppex.io/api-reference/products/delete-product-bundle /openapi.json delete /dev/v1/products/bundles/{uniqid} Developer API operation for DELETE /dev/v1/products/bundles/{uniqid}. # Delete product field option Source: https://docs.shoppex.io/api-reference/products/delete-product-field-option /openapi.json delete /dev/v1/products/{id}/variants/fields/{fieldId}/options/{optionId} Developer API operation for DELETE /dev/v1/products/{id}/variants/fields/{fieldId}/options/{optionId}. # Delete product quantity deal Source: https://docs.shoppex.io/api-reference/products/delete-product-quantity-deal /openapi.json delete /dev/v1/products/quantity-deals/{uniqid} Developer API operation for DELETE /dev/v1/products/quantity-deals/{uniqid}. # Delete product variant Source: https://docs.shoppex.io/api-reference/products/delete-product-variant /openapi.json delete /dev/v1/products/{id}/variants/{variantId} Developer API operation for DELETE /dev/v1/products/{id}/variants/{variantId}. # Delete product variant field Source: https://docs.shoppex.io/api-reference/products/delete-product-variant-field /openapi.json delete /dev/v1/products/{id}/variants/fields/{fieldId} Developer API operation for DELETE /dev/v1/products/{id}/variants/fields/{fieldId}. # Delete product variant serial Source: https://docs.shoppex.io/api-reference/products/delete-product-variant-serial /openapi.json delete /dev/v1/products/{id}/variants/{variantId}/serials Developer API operation for DELETE /dev/v1/products/{id}/variants/{variantId}/serials. # Duplicate product Source: https://docs.shoppex.io/api-reference/products/duplicate-product /openapi.json post /dev/v1/products/{id}/duplicate Developer API operation for POST /dev/v1/products/{id}/duplicate. # Get product Source: https://docs.shoppex.io/api-reference/products/get-product /openapi.json get /dev/v1/products/{id} Developer API operation for GET /dev/v1/products/{id}. # Get product addon Source: https://docs.shoppex.io/api-reference/products/get-product-addon /openapi.json get /dev/v1/products/addons/{uniqid} Developer API operation for GET /dev/v1/products/addons/{uniqid}. # Get product bundle Source: https://docs.shoppex.io/api-reference/products/get-product-bundle /openapi.json get /dev/v1/products/bundles/{uniqid} Developer API operation for GET /dev/v1/products/bundles/{uniqid}. # Get product quantity deal Source: https://docs.shoppex.io/api-reference/products/get-product-quantity-deal /openapi.json get /dev/v1/products/quantity-deals/{uniqid} Developer API operation for GET /dev/v1/products/quantity-deals/{uniqid}. # Get product stock Source: https://docs.shoppex.io/api-reference/products/get-product-stock /openapi.json get /dev/v1/products/{id}/stock Returns the current stock snapshot for one product. For `SERIALS` products, `available_stock` is calculated from currently available serial inventory. Read `orderable`, not `stock`: a `supplier_backed` row is fulfilled through the shop's own supplier (Dynamic Delivery) and holds no local units on purpose. # Get product variant Source: https://docs.shoppex.io/api-reference/products/get-product-variant /openapi.json get /dev/v1/products/{id}/variants/{variantId} Developer API operation for GET /dev/v1/products/{id}/variants/{variantId}. # Get product variant field Source: https://docs.shoppex.io/api-reference/products/get-product-variant-field /openapi.json get /dev/v1/products/{id}/variants/fields/{fieldId} Developer API operation for GET /dev/v1/products/{id}/variants/fields/{fieldId}. # List product addons Source: https://docs.shoppex.io/api-reference/products/list-product-addons /openapi.json get /dev/v1/products/addons Developer API operation for GET /dev/v1/products/addons. # List product bundles Source: https://docs.shoppex.io/api-reference/products/list-product-bundles /openapi.json get /dev/v1/products/bundles Developer API operation for GET /dev/v1/products/bundles. # List product field options Source: https://docs.shoppex.io/api-reference/products/list-product-field-options /openapi.json get /dev/v1/products/{id}/variants/fields/{fieldId}/options Developer API operation for GET /dev/v1/products/{id}/variants/fields/{fieldId}/options. # List product quantity deals Source: https://docs.shoppex.io/api-reference/products/list-product-quantity-deals /openapi.json get /dev/v1/products/quantity-deals Developer API operation for GET /dev/v1/products/quantity-deals. # List product serials Source: https://docs.shoppex.io/api-reference/products/list-product-serials /openapi.json get /dev/v1/products/{id}/serials Returns unlinked inventory serials for a base serial product that does not use variants. Each serial item includes its `id`, and that `id` is the value you pass inside `serial_ids` when calling `DELETE /dev/v1/products/{id}/serials`. Use the variant serial routes only when the product inventory is tracked per variant. # List product stock movements Source: https://docs.shoppex.io/api-reference/products/list-product-stock-movements /openapi.json get /dev/v1/products/{id}/stock-movements Returns a read-only stock movement timeline synthesized from serial inventory and completed invoice sales. # List product variant fields Source: https://docs.shoppex.io/api-reference/products/list-product-variant-fields /openapi.json get /dev/v1/products/{id}/variants/fields Developer API operation for GET /dev/v1/products/{id}/variants/fields. # List product variant serials Source: https://docs.shoppex.io/api-reference/products/list-product-variant-serials /openapi.json get /dev/v1/products/{id}/variants/{variantId}/serials Developer API operation for GET /dev/v1/products/{id}/variants/{variantId}/serials. # List product variants Source: https://docs.shoppex.io/api-reference/products/list-product-variants /openapi.json get /dev/v1/products/{id}/variants Developer API operation for GET /dev/v1/products/{id}/variants. # List products Source: https://docs.shoppex.io/api-reference/products/list-products /openapi.json get /dev/v1/products Developer API operation for GET /dev/v1/products. # Remove product serials Source: https://docs.shoppex.io/api-reference/products/remove-product-serials /openapi.json delete /dev/v1/products/{id}/serials Removes specific serial inventory entries from a base serial product without variants by serial ID. Use the `id` values returned by `GET /dev/v1/products/{id}/serials` as the entries inside `serial_ids`. # Reorder product variants Source: https://docs.shoppex.io/api-reference/products/reorder-product-variants /openapi.json patch /dev/v1/products/{id}/variants/order Developer API operation for PATCH /dev/v1/products/{id}/variants/order. # Reorder products Source: https://docs.shoppex.io/api-reference/products/reorder-products /openapi.json patch /dev/v1/products/order Developer API operation for PATCH /dev/v1/products/order. # Search products Source: https://docs.shoppex.io/api-reference/products/search-products /openapi.json get /dev/v1/products/search Searches products by the required `q` query parameter. Use `GET /dev/v1/products` to list products without a search term. # Update product Source: https://docs.shoppex.io/api-reference/products/update-product /openapi.json patch /dev/v1/products/{id} Updates an existing product. For base `SERIALS` products, sending the `serials` field replaces the full base-product serial inventory. Use `/dev/v1/products/{id}/serials` when you need append/remove behavior instead of full replacement. # Update product addon Source: https://docs.shoppex.io/api-reference/products/update-product-addon /openapi.json patch /dev/v1/products/addons/{uniqid} Developer API operation for PATCH /dev/v1/products/addons/{uniqid}. # Update product bundle Source: https://docs.shoppex.io/api-reference/products/update-product-bundle /openapi.json patch /dev/v1/products/bundles/{uniqid} Developer API operation for PATCH /dev/v1/products/bundles/{uniqid}. # Update product field option Source: https://docs.shoppex.io/api-reference/products/update-product-field-option /openapi.json patch /dev/v1/products/{id}/variants/fields/{fieldId}/options/{optionId} Developer API operation for PATCH /dev/v1/products/{id}/variants/fields/{fieldId}/options/{optionId}. # Update product quantity deal Source: https://docs.shoppex.io/api-reference/products/update-product-quantity-deal /openapi.json patch /dev/v1/products/quantity-deals/{uniqid} Developer API operation for PATCH /dev/v1/products/quantity-deals/{uniqid}. # Update product serial Source: https://docs.shoppex.io/api-reference/products/update-product-serial /openapi.json patch /dev/v1/products/{id}/serials/{serialId} Updates a single serial inventory entry for a base serial product without variants. # Update product variant Source: https://docs.shoppex.io/api-reference/products/update-product-variant /openapi.json patch /dev/v1/products/{id}/variants/{variantId} Developer API operation for PATCH /dev/v1/products/{id}/variants/{variantId}. # Update product variant field Source: https://docs.shoppex.io/api-reference/products/update-product-variant-field /openapi.json patch /dev/v1/products/{id}/variants/fields/{fieldId} Developer API operation for PATCH /dev/v1/products/{id}/variants/fields/{fieldId}. # Archive replacement rule Source: https://docs.shoppex.io/api-reference/replacements/archive-replacement-rule /openapi.json delete /dev/v1/replacement-rules/{id} Developer API operation for DELETE /dev/v1/replacement-rules/{id}. # Create replacement rule Source: https://docs.shoppex.io/api-reference/replacements/create-replacement-rule /openapi.json post /dev/v1/replacement-rules Developer API operation for POST /dev/v1/replacement-rules. # Dry-run a rule against a real invoice (no side effects) Source: https://docs.shoppex.io/api-reference/replacements/dry-run-a-rule-against-a-real-invoice-no-side-effects /openapi.json post /dev/v1/replacement-rules/{id}/simulate Developer API operation for POST /dev/v1/replacement-rules/{id}/simulate. # Get replacement audit event Source: https://docs.shoppex.io/api-reference/replacements/get-replacement-audit-event /openapi.json get /dev/v1/replacement-events/{id} Developer API operation for GET /dev/v1/replacement-events/{id}. # Get replacement rule Source: https://docs.shoppex.io/api-reference/replacements/get-replacement-rule /openapi.json get /dev/v1/replacement-rules/{id} Developer API operation for GET /dev/v1/replacement-rules/{id}. # List replacement audit events Source: https://docs.shoppex.io/api-reference/replacements/list-replacement-audit-events /openapi.json get /dev/v1/replacement-events Developer API operation for GET /dev/v1/replacement-events. # List replacement rules Source: https://docs.shoppex.io/api-reference/replacements/list-replacement-rules /openapi.json get /dev/v1/replacement-rules Developer API operation for GET /dev/v1/replacement-rules. # Update replacement rule Source: https://docs.shoppex.io/api-reference/replacements/update-replacement-rule /openapi.json patch /dev/v1/replacement-rules/{id} Developer API operation for PATCH /dev/v1/replacement-rules/{id}. # Invite a reseller Source: https://docs.shoppex.io/api-reference/resellers/invite-a-reseller /openapi.json post /dev/v1/resellers/invite Sends a wholesale invitation email. Requires an enabled program and either an explicit `tier_id` or a program default tier. Omitting `tier_id` uses the program default. # List reseller tiers Source: https://docs.shoppex.io/api-reference/resellers/list-reseller-tiers /openapi.json get /dev/v1/reseller-tiers Read-only list of wholesale tiers, ordered by `sort_order`. Tier authoring is dashboard-only. # List resellers Source: https://docs.shoppex.io/api-reference/resellers/list-resellers /openapi.json get /dev/v1/resellers Returns the wholesale reseller accounts of the current shop, newest first. Page-based pagination. # Reinstate a reseller Source: https://docs.shoppex.io/api-reference/resellers/reinstate-a-reseller /openapi.json post /dev/v1/resellers/{id}/reinstate Returns a SUSPENDED reseller to ACTIVE. Only a SUSPENDED reseller can be reinstated. # Retrieve a reseller Source: https://docs.shoppex.io/api-reference/resellers/retrieve-a-reseller /openapi.json get /dev/v1/resellers/{id} Returns one reseller account including its tier and lifetime wholesale totals. # Retrieve the reseller program Source: https://docs.shoppex.io/api-reference/resellers/retrieve-the-reseller-program /openapi.json get /dev/v1/reseller-program Returns the wholesale program settings of the current shop. The row is created lazily on first read. # Suspend a reseller Source: https://docs.shoppex.io/api-reference/resellers/suspend-a-reseller /openapi.json post /dev/v1/resellers/{id}/suspend Blocks wholesale pricing and reseller API access. Only an ACTIVE reseller can be suspended. # Update the reseller program Source: https://docs.shoppex.io/api-reference/resellers/update-the-reseller-program /openapi.json patch /dev/v1/reseller-program Partial update of the wholesale program settings. `enrollment_mode: "OPEN"` requires a `default_tier_id`. # Create review reply Source: https://docs.shoppex.io/api-reference/reviews/create-review-reply /openapi.json post /dev/v1/reviews/{uniqid}/reply Developer API operation for POST /dev/v1/reviews/{uniqid}/reply. # Get review Source: https://docs.shoppex.io/api-reference/reviews/get-review /openapi.json get /dev/v1/reviews/{uniqid} Developer API operation for GET /dev/v1/reviews/{uniqid}. # List reviews Source: https://docs.shoppex.io/api-reference/reviews/list-reviews /openapi.json get /dev/v1/reviews Developer API operation for GET /dev/v1/reviews. # Get advanced security settings Source: https://docs.shoppex.io/api-reference/security/get-advanced-security-settings /openapi.json get /dev/v1/security/security-settings Developer API operation for GET /dev/v1/security/security-settings. # Get audit trail Source: https://docs.shoppex.io/api-reference/security/get-audit-trail /openapi.json get /dev/v1/security/audit-trail Developer API operation for GET /dev/v1/security/audit-trail. # Get security settings Source: https://docs.shoppex.io/api-reference/security/get-security-settings /openapi.json get /dev/v1/security Developer API operation for GET /dev/v1/security. # Get session trail Source: https://docs.shoppex.io/api-reference/security/get-session-trail /openapi.json get /dev/v1/security/session-trail/{session_id} Developer API operation for GET /dev/v1/security/session-trail/{session_id}. # List security events Source: https://docs.shoppex.io/api-reference/security/list-security-events /openapi.json get /dev/v1/security/events Developer API operation for GET /dev/v1/security/events. # List security shared blacklists Source: https://docs.shoppex.io/api-reference/security/list-security-shared-blacklists /openapi.json get /dev/v1/security/shared-blacklists Developer API operation for GET /dev/v1/security/shared-blacklists. # Update advanced security settings Source: https://docs.shoppex.io/api-reference/security/update-advanced-security-settings /openapi.json patch /dev/v1/security/security-settings Developer API operation for PATCH /dev/v1/security/security-settings. # Update security settings Source: https://docs.shoppex.io/api-reference/security/update-security-settings /openapi.json patch /dev/v1/security Developer API operation for PATCH /dev/v1/security. # Add additional domain Source: https://docs.shoppex.io/api-reference/store/add-additional-domain /openapi.json post /dev/v1/store/domains/additional Developer API operation for POST /dev/v1/store/domains/additional. # Delete additional domain Source: https://docs.shoppex.io/api-reference/store/delete-additional-domain /openapi.json delete /dev/v1/store/domains/additional/{id} Developer API operation for DELETE /dev/v1/store/domains/additional/{id}. # Disable additional domain Source: https://docs.shoppex.io/api-reference/store/disable-additional-domain /openapi.json post /dev/v1/store/domains/additional/{id}/disable Developer API operation for POST /dev/v1/store/domains/additional/{id}/disable. # Disable custom domain Source: https://docs.shoppex.io/api-reference/store/disable-custom-domain /openapi.json post /dev/v1/store/domains/custom/disable Developer API operation for POST /dev/v1/store/domains/custom/disable. # Enable additional domain Source: https://docs.shoppex.io/api-reference/store/enable-additional-domain /openapi.json post /dev/v1/store/domains/additional/{id}/enable Developer API operation for POST /dev/v1/store/domains/additional/{id}/enable. # Enable custom domain Source: https://docs.shoppex.io/api-reference/store/enable-custom-domain /openapi.json post /dev/v1/store/domains/custom/enable Developer API operation for POST /dev/v1/store/domains/custom/enable. # Get additional domain verification Source: https://docs.shoppex.io/api-reference/store/get-additional-domain-verification /openapi.json get /dev/v1/store/domains/additional/{id}/verification Developer API operation for GET /dev/v1/store/domains/additional/{id}/verification. # Get custom domain status Source: https://docs.shoppex.io/api-reference/store/get-custom-domain-status /openapi.json get /dev/v1/store/domains/custom/status Developer API operation for GET /dev/v1/store/domains/custom/status. # Get store branding Source: https://docs.shoppex.io/api-reference/store/get-store-branding /openapi.json get /dev/v1/store/branding Developer API operation for GET /dev/v1/store/branding. # Get store features Source: https://docs.shoppex.io/api-reference/store/get-store-features /openapi.json get /dev/v1/store/features Developer API operation for GET /dev/v1/store/features. # Get store settings Source: https://docs.shoppex.io/api-reference/store/get-store-settings /openapi.json get /dev/v1/store/settings Developer API operation for GET /dev/v1/store/settings. # Get storefront layout Source: https://docs.shoppex.io/api-reference/store/get-storefront-layout /openapi.json get /dev/v1/store/layout Developer API operation for GET /dev/v1/store/layout. # List store domains Source: https://docs.shoppex.io/api-reference/store/list-store-domains /openapi.json get /dev/v1/store/domains Developer API operation for GET /dev/v1/store/domains. # Request additional domain validation Source: https://docs.shoppex.io/api-reference/store/request-additional-domain-validation /openapi.json post /dev/v1/store/domains/additional/{id}/request-validation Developer API operation for POST /dev/v1/store/domains/additional/{id}/request-validation. # Update custom domain Source: https://docs.shoppex.io/api-reference/store/update-custom-domain /openapi.json patch /dev/v1/store/domains/custom Developer API operation for PATCH /dev/v1/store/domains/custom. # Update storefront layout Source: https://docs.shoppex.io/api-reference/store/update-storefront-layout /openapi.json put /dev/v1/store/layout Developer API operation for PUT /dev/v1/store/layout. # Cancel subscription Source: https://docs.shoppex.io/api-reference/subscriptions/cancel-subscription /openapi.json post /dev/v1/subscriptions/{id}/cancel Developer API operation for POST /dev/v1/subscriptions/{id}/cancel. # Change subscription plan Source: https://docs.shoppex.io/api-reference/subscriptions/change-subscription-plan /openapi.json post /dev/v1/subscriptions/{id}/change-plan Developer API operation for POST /dev/v1/subscriptions/{id}/change-plan. # Get subscription Source: https://docs.shoppex.io/api-reference/subscriptions/get-subscription /openapi.json get /dev/v1/subscriptions/{id} Developer API operation for GET /dev/v1/subscriptions/{id}. # List subscription billing history Source: https://docs.shoppex.io/api-reference/subscriptions/list-subscription-billing-history /openapi.json get /dev/v1/subscriptions/{id}/billing-history Developer API operation for GET /dev/v1/subscriptions/{id}/billing-history. # List subscription plan options Source: https://docs.shoppex.io/api-reference/subscriptions/list-subscription-plan-options /openapi.json get /dev/v1/subscriptions/{id}/plan-options Developer API operation for GET /dev/v1/subscriptions/{id}/plan-options. # List subscriptions Source: https://docs.shoppex.io/api-reference/subscriptions/list-subscriptions /openapi.json get /dev/v1/subscriptions Developer API operation for GET /dev/v1/subscriptions. # Pause subscription Source: https://docs.shoppex.io/api-reference/subscriptions/pause-subscription /openapi.json post /dev/v1/subscriptions/{id}/pause Developer API operation for POST /dev/v1/subscriptions/{id}/pause. # Refund subscription Source: https://docs.shoppex.io/api-reference/subscriptions/refund-subscription /openapi.json post /dev/v1/subscriptions/{id}/refund Developer API operation for POST /dev/v1/subscriptions/{id}/refund. # Resume subscription Source: https://docs.shoppex.io/api-reference/subscriptions/resume-subscription /openapi.json post /dev/v1/subscriptions/{id}/resume Developer API operation for POST /dev/v1/subscriptions/{id}/resume. # Update subscription custom fields Source: https://docs.shoppex.io/api-reference/subscriptions/update-subscription-custom-fields /openapi.json patch /dev/v1/subscriptions/{id}/custom-fields Developer API operation for PATCH /dev/v1/subscriptions/{id}/custom-fields. # Cancel telegram bot broadcast Source: https://docs.shoppex.io/api-reference/telegram/cancel-telegram-bot-broadcast /openapi.json post /dev/v1/telegram/bots/{botId}/broadcasts/{id}/cancel Developer API operation for POST /dev/v1/telegram/bots/{botId}/broadcasts/{id}/cancel. # Create telegram bot Source: https://docs.shoppex.io/api-reference/telegram/create-telegram-bot /openapi.json post /dev/v1/telegram/bots Developer API operation for POST /dev/v1/telegram/bots. # Create telegram bot broadcast Source: https://docs.shoppex.io/api-reference/telegram/create-telegram-bot-broadcast /openapi.json post /dev/v1/telegram/bots/{botId}/broadcasts Developer API operation for POST /dev/v1/telegram/bots/{botId}/broadcasts. # Create telegram bot journey Source: https://docs.shoppex.io/api-reference/telegram/create-telegram-bot-journey /openapi.json post /dev/v1/telegram/bots/{botId}/journeys Developer API operation for POST /dev/v1/telegram/bots/{botId}/journeys. # Create telegram bot segment Source: https://docs.shoppex.io/api-reference/telegram/create-telegram-bot-segment /openapi.json post /dev/v1/telegram/bots/{botId}/segments Developer API operation for POST /dev/v1/telegram/bots/{botId}/segments. # Delete telegram bot Source: https://docs.shoppex.io/api-reference/telegram/delete-telegram-bot /openapi.json delete /dev/v1/telegram/bots/{botId} Developer API operation for DELETE /dev/v1/telegram/bots/{botId}. # Delete telegram bot broadcast Source: https://docs.shoppex.io/api-reference/telegram/delete-telegram-bot-broadcast /openapi.json delete /dev/v1/telegram/bots/{botId}/broadcasts/{id} Developer API operation for DELETE /dev/v1/telegram/bots/{botId}/broadcasts/{id}. # Delete telegram bot journey Source: https://docs.shoppex.io/api-reference/telegram/delete-telegram-bot-journey /openapi.json delete /dev/v1/telegram/bots/{botId}/journeys/{journeyId} Developer API operation for DELETE /dev/v1/telegram/bots/{botId}/journeys/{journeyId}. # Delete telegram bot segment Source: https://docs.shoppex.io/api-reference/telegram/delete-telegram-bot-segment /openapi.json delete /dev/v1/telegram/bots/{botId}/segments/{segmentId} Developer API operation for DELETE /dev/v1/telegram/bots/{botId}/segments/{segmentId}. # Get inbox conversation Source: https://docs.shoppex.io/api-reference/telegram/get-inbox-conversation /openapi.json get /dev/v1/telegram/bots/{botId}/inbox/{conversationId} Developer API operation for GET /dev/v1/telegram/bots/{botId}/inbox/{conversationId}. # Get telegram agent stats Source: https://docs.shoppex.io/api-reference/telegram/get-telegram-agent-stats /openapi.json get /dev/v1/telegram/bots/{botId}/agent/stats Developer API operation for GET /dev/v1/telegram/bots/{botId}/agent/stats. # Get telegram bot analytics Source: https://docs.shoppex.io/api-reference/telegram/get-telegram-bot-analytics /openapi.json get /dev/v1/telegram/bots/{botId}/analytics Developer API operation for GET /dev/v1/telegram/bots/{botId}/analytics. # Get telegram bot broadcast Source: https://docs.shoppex.io/api-reference/telegram/get-telegram-bot-broadcast /openapi.json get /dev/v1/telegram/bots/{botId}/broadcasts/{id} Developer API operation for GET /dev/v1/telegram/bots/{botId}/broadcasts/{id}. # Get telegram bot journey Source: https://docs.shoppex.io/api-reference/telegram/get-telegram-bot-journey /openapi.json get /dev/v1/telegram/bots/{botId}/journeys/{journeyId} Developer API operation for GET /dev/v1/telegram/bots/{botId}/journeys/{journeyId}. # Get telegram bot segment Source: https://docs.shoppex.io/api-reference/telegram/get-telegram-bot-segment /openapi.json get /dev/v1/telegram/bots/{botId}/segments/{segmentId} Developer API operation for GET /dev/v1/telegram/bots/{botId}/segments/{segmentId}. # Get telegram bot user Source: https://docs.shoppex.io/api-reference/telegram/get-telegram-bot-user /openapi.json get /dev/v1/telegram/bots/{botId}/users/{userId} Developer API operation for GET /dev/v1/telegram/bots/{botId}/users/{userId}. # Get telegram broadcast analytics Source: https://docs.shoppex.io/api-reference/telegram/get-telegram-broadcast-analytics /openapi.json get /dev/v1/telegram/bots/{botId}/broadcasts/{id}/analytics Developer API operation for GET /dev/v1/telegram/bots/{botId}/broadcasts/{id}/analytics. # Get telegram broadcast stats Source: https://docs.shoppex.io/api-reference/telegram/get-telegram-broadcast-stats /openapi.json get /dev/v1/telegram/bots/{botId}/broadcasts/{id}/stats Developer API operation for GET /dev/v1/telegram/bots/{botId}/broadcasts/{id}/stats. # Get telegram user stats Source: https://docs.shoppex.io/api-reference/telegram/get-telegram-user-stats /openapi.json get /dev/v1/telegram/bots/{botId}/users/stats Developer API operation for GET /dev/v1/telegram/bots/{botId}/users/stats. # List inbox conversations Source: https://docs.shoppex.io/api-reference/telegram/list-inbox-conversations /openapi.json get /dev/v1/telegram/bots/{botId}/inbox Developer API operation for GET /dev/v1/telegram/bots/{botId}/inbox. # List telegram bot broadcasts Source: https://docs.shoppex.io/api-reference/telegram/list-telegram-bot-broadcasts /openapi.json get /dev/v1/telegram/bots/{botId}/broadcasts Developer API operation for GET /dev/v1/telegram/bots/{botId}/broadcasts. # List telegram bot journeys Source: https://docs.shoppex.io/api-reference/telegram/list-telegram-bot-journeys /openapi.json get /dev/v1/telegram/bots/{botId}/journeys Developer API operation for GET /dev/v1/telegram/bots/{botId}/journeys. # List telegram bot segments Source: https://docs.shoppex.io/api-reference/telegram/list-telegram-bot-segments /openapi.json get /dev/v1/telegram/bots/{botId}/segments Developer API operation for GET /dev/v1/telegram/bots/{botId}/segments. # List telegram bot users Source: https://docs.shoppex.io/api-reference/telegram/list-telegram-bot-users /openapi.json get /dev/v1/telegram/bots/{botId}/users Developer API operation for GET /dev/v1/telegram/bots/{botId}/users. # List telegram bots Source: https://docs.shoppex.io/api-reference/telegram/list-telegram-bots /openapi.json get /dev/v1/telegram/bots Developer API operation for GET /dev/v1/telegram/bots. # Reply to inbox conversation Source: https://docs.shoppex.io/api-reference/telegram/reply-to-inbox-conversation /openapi.json post /dev/v1/telegram/bots/{botId}/inbox/{conversationId}/reply Developer API operation for POST /dev/v1/telegram/bots/{botId}/inbox/{conversationId}/reply. # Send product restock broadcast Source: https://docs.shoppex.io/api-reference/telegram/send-product-restock-broadcast /openapi.json post /dev/v1/telegram/bots/{botId}/broadcasts/restock Queues a Telegram restock notification for a product or variant. This is a notification endpoint, not a warehouse inventory endpoint. # Send test telegram bot journey Source: https://docs.shoppex.io/api-reference/telegram/send-test-telegram-bot-journey /openapi.json post /dev/v1/telegram/bots/{botId}/journeys/{journeyId}/test Developer API operation for POST /dev/v1/telegram/bots/{botId}/journeys/{journeyId}/test. # Send warehouse restock broadcast Source: https://docs.shoppex.io/api-reference/telegram/send-warehouse-restock-broadcast /openapi.json post /dev/v1/telegram/bots/{botId}/broadcasts/warehouse-restock Legacy name for the Telegram product restock broadcast endpoint. Prefer `/dev/v1/telegram/bots/{botId}/broadcasts/restock` for new integrations. # Start telegram bot broadcast Source: https://docs.shoppex.io/api-reference/telegram/start-telegram-bot-broadcast /openapi.json post /dev/v1/telegram/bots/{botId}/broadcasts/{id}/start Developer API operation for POST /dev/v1/telegram/bots/{botId}/broadcasts/{id}/start. # Update inbox conversation status Source: https://docs.shoppex.io/api-reference/telegram/update-inbox-conversation-status /openapi.json put /dev/v1/telegram/bots/{botId}/inbox/{conversationId}/status Developer API operation for PUT /dev/v1/telegram/bots/{botId}/inbox/{conversationId}/status. # Update telegram bot agent Source: https://docs.shoppex.io/api-reference/telegram/update-telegram-bot-agent /openapi.json put /dev/v1/telegram/bots/{botId}/agent Developer API operation for PUT /dev/v1/telegram/bots/{botId}/agent. # Update telegram bot broadcast Source: https://docs.shoppex.io/api-reference/telegram/update-telegram-bot-broadcast /openapi.json put /dev/v1/telegram/bots/{botId}/broadcasts/{id} Developer API operation for PUT /dev/v1/telegram/bots/{botId}/broadcasts/{id}. # Update telegram bot journey Source: https://docs.shoppex.io/api-reference/telegram/update-telegram-bot-journey /openapi.json put /dev/v1/telegram/bots/{botId}/journeys/{journeyId} Developer API operation for PUT /dev/v1/telegram/bots/{botId}/journeys/{journeyId}. # Update telegram bot segment Source: https://docs.shoppex.io/api-reference/telegram/update-telegram-bot-segment /openapi.json put /dev/v1/telegram/bots/{botId}/segments/{segmentId} Developer API operation for PUT /dev/v1/telegram/bots/{botId}/segments/{segmentId}. # Update telegram bulk notification Source: https://docs.shoppex.io/api-reference/telegram/update-telegram-bulk-notification /openapi.json put /dev/v1/telegram/bots/{botId}/users/bulk/notifications Developer API operation for PUT /dev/v1/telegram/bots/{botId}/users/bulk/notifications. # Update telegram user notification Source: https://docs.shoppex.io/api-reference/telegram/update-telegram-user-notification /openapi.json put /dev/v1/telegram/bots/{botId}/users/{userId}/notifications Developer API operation for PUT /dev/v1/telegram/bots/{botId}/users/{userId}/notifications. # Create theme Source: https://docs.shoppex.io/api-reference/themes/create-theme /openapi.json post /dev/v1/themes/control/create Developer API operation for POST /dev/v1/themes/control/create. # Get editable theme document draft Source: https://docs.shoppex.io/api-reference/themes/get-editable-theme-document-draft /openapi.json get /dev/v1/themes/{id}/control/document/draft Developer API operation for GET /dev/v1/themes/{id}/control/document/draft. # Get theme builder settings Source: https://docs.shoppex.io/api-reference/themes/get-theme-builder-settings /openapi.json get /dev/v1/themes/{id}/control/settings Developer API operation for GET /dev/v1/themes/{id}/control/settings. # Get theme document Source: https://docs.shoppex.io/api-reference/themes/get-theme-document /openapi.json get /dev/v1/themes/{id}/control/document Developer API operation for GET /dev/v1/themes/{id}/control/document. # Get theme document schema Source: https://docs.shoppex.io/api-reference/themes/get-theme-document-schema /openapi.json get /dev/v1/themes/{id}/control/document/schema Developer API operation for GET /dev/v1/themes/{id}/control/document/schema. # Get ThemeDocument JSON Schema for editors Source: https://docs.shoppex.io/api-reference/themes/get-themedocument-json-schema-for-editors /openapi.json get /dev/v1/themes/{id}/control/document/json-schema Developer API operation for GET /dev/v1/themes/{id}/control/document/json-schema. # List themes Source: https://docs.shoppex.io/api-reference/themes/list-themes /openapi.json get /dev/v1/themes Developer API operation for GET /dev/v1/themes. # Publish theme Source: https://docs.shoppex.io/api-reference/themes/publish-theme /openapi.json post /dev/v1/themes/{id}/control/publish Developer API operation for POST /dev/v1/themes/{id}/control/publish. # Save theme document draft Source: https://docs.shoppex.io/api-reference/themes/save-theme-document-draft /openapi.json put /dev/v1/themes/{id}/control/document Developer API operation for PUT /dev/v1/themes/{id}/control/document. # Start theme preview Source: https://docs.shoppex.io/api-reference/themes/start-theme-preview /openapi.json post /dev/v1/themes/{id}/control/preview Developer API operation for POST /dev/v1/themes/{id}/control/preview. # Stop theme preview Source: https://docs.shoppex.io/api-reference/themes/stop-theme-preview /openapi.json delete /dev/v1/themes/{id}/control/preview/{sessionId} Developer API operation for DELETE /dev/v1/themes/{id}/control/preview/{sessionId}. # Update theme builder settings Source: https://docs.shoppex.io/api-reference/themes/update-theme-builder-settings /openapi.json post /dev/v1/themes/{id}/control/settings Developer API operation for POST /dev/v1/themes/{id}/control/settings. # Close ticket Source: https://docs.shoppex.io/api-reference/tickets/close-ticket /openapi.json post /dev/v1/tickets/{id}/close Developer API operation for POST /dev/v1/tickets/{id}/close. # Create ticket Source: https://docs.shoppex.io/api-reference/tickets/create-ticket /openapi.json post /dev/v1/tickets Developer API operation for POST /dev/v1/tickets. # Create ticket message Source: https://docs.shoppex.io/api-reference/tickets/create-ticket-message /openapi.json post /dev/v1/tickets/{id}/messages Developer API operation for POST /dev/v1/tickets/{id}/messages. # Get ticket Source: https://docs.shoppex.io/api-reference/tickets/get-ticket /openapi.json get /dev/v1/tickets/{id} Developer API operation for GET /dev/v1/tickets/{id}. # List tickets Source: https://docs.shoppex.io/api-reference/tickets/list-tickets /openapi.json get /dev/v1/tickets Developer API operation for GET /dev/v1/tickets. # Reopen ticket Source: https://docs.shoppex.io/api-reference/tickets/reopen-ticket /openapi.json post /dev/v1/tickets/{id}/reopen Developer API operation for POST /dev/v1/tickets/{id}/reopen. # Delete all variant serials Source: https://docs.shoppex.io/api-reference/variants/delete-all-variant-serials /openapi.json delete /dev/v1/products/{id}/variants/{variantId}/serials/all Removes all currently linked serial inventory entries for one variant. Use the optional `search` filter to remove only matching serials. # Create webhook Source: https://docs.shoppex.io/api-reference/webhook-endpoints/create-webhook /openapi.json post /dev/v1/webhooks Developer API operation for POST /dev/v1/webhooks. # Delete webhook Source: https://docs.shoppex.io/api-reference/webhook-endpoints/delete-webhook /openapi.json delete /dev/v1/webhooks/{id} Developer API operation for DELETE /dev/v1/webhooks/{id}. # Get webhook Source: https://docs.shoppex.io/api-reference/webhook-endpoints/get-webhook /openapi.json get /dev/v1/webhooks/{id} Developer API operation for GET /dev/v1/webhooks/{id}. # Get webhook log Source: https://docs.shoppex.io/api-reference/webhook-endpoints/get-webhook-log /openapi.json get /dev/v1/webhooks/logs/{id} Developer API operation for GET /dev/v1/webhooks/logs/{id}. # List webhook events Source: https://docs.shoppex.io/api-reference/webhook-endpoints/list-webhook-events /openapi.json get /dev/v1/webhooks/events Developer API operation for GET /dev/v1/webhooks/events. # List webhook logs Source: https://docs.shoppex.io/api-reference/webhook-endpoints/list-webhook-logs /openapi.json get /dev/v1/webhooks/logs Developer API operation for GET /dev/v1/webhooks/logs. # List webhooks Source: https://docs.shoppex.io/api-reference/webhook-endpoints/list-webhooks /openapi.json get /dev/v1/webhooks Developer API operation for GET /dev/v1/webhooks. # Retry webhook log Source: https://docs.shoppex.io/api-reference/webhook-endpoints/retry-webhook-log /openapi.json post /dev/v1/webhooks/logs/{id}/retry Developer API operation for POST /dev/v1/webhooks/logs/{id}/retry. # Rotate webhook secret Source: https://docs.shoppex.io/api-reference/webhook-endpoints/rotate-webhook-secret /openapi.json post /dev/v1/webhooks/{id}/rotate-secret Developer API operation for POST /dev/v1/webhooks/{id}/rotate-secret. # Send test webhook Source: https://docs.shoppex.io/api-reference/webhook-endpoints/send-test-webhook /openapi.json post /dev/v1/webhooks/{id}/test Developer API operation for POST /dev/v1/webhooks/{id}/test. # Update webhook Source: https://docs.shoppex.io/api-reference/webhook-endpoints/update-webhook /openapi.json patch /dev/v1/webhooks/{id} Developer API operation for PATCH /dev/v1/webhooks/{id}. # Support inbox Source: https://docs.shoppex.io/customers/inbox Reply to buyer questions from one place. When a buyer opens a ticket from any of their orders, it lands in your **Inbox**, and you get an email plus a dashboard notification. ## Ticket states A ticket moves through these states: * **Pending** — new ticket, you have not opened it yet. * **Open** — you have opened it, you owe the buyer a reply. * **Awaiting reply** — you have replied. Reopens automatically if the buyer responds. * **Resolved** — you marked the issue done. Reopens automatically if the buyer comes back. * **Closed** — archived. The buyer **cannot** reply to a closed ticket. They must open a new one instead. If you want the buyer to return to the same thread, use **Resolved** instead. ## Priorities Tickets carry a priority: `LOW`, `NORMAL`, `HIGH`, `URGENT`. New tickets default to `NORMAL`. Raise the priority on a busy day to find the most urgent tickets first. ## Replying Open the ticket, write your reply, and send it. The buyer is emailed and can respond from their customer dashboard. Every message stays attached to the thread. You can also: * Attach files to a reply. * Add **internal notes** that only your team sees, not the buyer. * Apply a saved macro for common questions. ## Categorization Tickets are tagged with a category (for example, "delivery issue" or "refund request") and a country based on the buyer's profile. Use these tags to filter the inbox during triage. ## Tickets vs. disputes A ticket is the buyer talking to you. A dispute is the buyer talking to their bank. Resolve issues through tickets first. This is faster and cheaper, and it skips the chargeback fee that the gateway charges regardless of the dispute outcome. If a buyer threatens a chargeback in a ticket, take it seriously. A refund as credit, or a straight refund, almost always costs less than the fee plus the lost goods. # Customers Source: https://docs.shoppex.io/customers/overview Customer profiles, wallet credits, and affiliates. Every buyer who completes an order on your shop gets a customer profile that shows their order history, lifetime value, and wallet balance. From the **Customers** tab, you can view this history and contact a buyer through the inbox. You can also credit their wallet, promote them to an affiliate, or block them. ## What is on a customer profile * Order history with status and amount per order. * Lifetime value across all gateways combined. * Contact details: email, name, phone, country, city, postal code, and linked Discord ID. * Current wallet balance and transaction history. * Affiliate status (if enrolled) with referral count and commission earnings. ## Store credit Store credit is a balance you add to a customer's wallet, and the customer spends it on a future order in your shop. ### Crediting a customer's wallet Credit a customer's wallet from the customer detail page. Common reasons to add credit: * A promotional gift, a win-back campaign, or an apology credit after a bad experience. * An affiliate commission paid out as store credit instead of crypto. ### Refunds as store credit Instead of refunding an order to the original payment method, you can credit the buyer's wallet for the same amount. The buyer then spends that balance on a future order. Refunding an order as credit happens from the order itself. See [Refunds](/payments/refunds) for that flow. ### The wallet ledger Every change to a wallet balance creates a ledger entry. You cannot change the ledger: past entries never change, and a correction adds a new entry instead of editing an old one. The ledger records one of seven transaction types: | Type | Meaning | | ------------ | -------------------------------------------------- | | `CREDIT` | Balance added manually or through a promotion | | `DEBIT` | Balance removed manually | | `REFUND` | An order refunded as credit instead of cash | | `PROMO` | A promotional or win-back credit | | `ADJUSTMENT` | A manual correction to the balance | | `PAYMENT` | Balance spent on an order | | `EXPIRED` | Balance removed because it reached its expiry date | ### Store credit settings Configure shop-wide behavior at **Settings → Payments → Store Credit**: * **Allow refunds as credit** — turns on the option to refund an order as wallet credit instead of cash. * **Default expiry** — the number of days new credit stays valid, or never. * **Balance cap** — the maximum wallet balance a single customer can hold. Expiry is off by default. When you turn it on, new credit expires after the number of days you set. ## Affiliates Any customer can become an affiliate of your shop, earning a share of the sales they refer through a personal link. ### Turning on the affiliate program Configure affiliates at **Marketing → Rewards**, or directly from a customer's profile under **Customers → Affiliate**. Three settings control the program: | Setting | Effect | | -------------------- | ----------------------------------------------------------------------------------- | | Affiliate revenue | The percent affiliates earn on sales they refer, once turned on. | | Customer discount | The percent off a referred buyer gets through the affiliate's link, once turned on. | | Custom link approval | Whether you manually approve a custom affiliate link before it goes live. | ### How referrals work A buyer who arrives through an affiliate's link gets attributed to that affiliate for their purchase. When Customer discount is on, the buyer sees the discount applied automatically at checkout. ### Payouts An affiliate requests a payout from their own customer account. You approve or reject the request from the **Affiliates** section of your dashboard. An approved payout settles in one of two ways: * **Crypto** — sent to the wallet address the affiliate provides. * **Store credit** — converted into a wallet balance on your store. See [Store credit](#store-credit) above for how that balance works. If you want a fully branded affiliate dashboard instead of the built-in customer account, build it against the Dev API. See [Headless affiliate and customer portals](/customers/overview). ## Blocking customers If a customer is fraudulent or abusive, block them from **Security → Blocked** (`/security/blocked`), or directly from the customer's row action menu. Block by: * Email * IP / IP range * Country * Domain (in their email) * ASN * ISP * City * Crypto wallet address Use the narrowest match that catches the abuse. Whole-country bans are blunt. ASN or IP-range bans usually work better for a repeat offender. See [Support inbox](/customers/inbox) for how the support inbox works. # Resellers Source: https://docs.shoppex.io/customers/resellers Let trusted partners buy your products at wholesale prices and pay from a prepaid balance. A reseller is a customer who buys your products at a discount and sells them on. You set the discount tiers and choose which products a reseller can buy. Pricing stays global: you never negotiate per partner. Find it in the dashboard under **Customers → Resellers**. ## How the money works Resellers do not check out with a card. They keep a **prepaid balance** in your shop, top it up themselves, and every wholesale order is charged against that balance instantly. Nothing is invoiced, nothing is on credit, and an order that goes over the balance is rejected. Two things to know before you set this up: * A reseller order settles immediately, so their codes or files are delivered right away. * Reseller top-ups earn **no** wallet bonuses or reward credits, even if you run a top-up bonus campaign. A bonus on money already spent at a discount amounts to a second discount. ## Setting the program up The Overview tab shows a three-step checklist until you finish it. Go to the **Settings** tab and switch **Reseller Program** on. While it is off, existing resellers keep their status but cannot place wholesale orders or use the reseller API. Then pick an **Enrollment Mode**: * **Manual** — only you invite resellers. Customers cannot request access. * **Application** — customers apply from their account and you approve or reject each request. You can add an **Application Message** that explains what you expect, and you can require applicants to write a note. * **Open** — any customer becomes a reseller instantly and lands in your default tier. Open enrollment requires a **Default Tier**. Nobody reviews the signup, so you must decide the new reseller's price in advance. The dashboard blocks the switch until you set one. A tier holds the wholesale discount. On the **Tiers** tab, choose **Create Tier** and set a **Name** and a **Discount Percent**: the percentage off your regular price on every enrolled product. You can also set **Auto-Advance**: a **Lifetime Revenue** threshold, a **Completed Orders** threshold, or both, to promote resellers into this tier automatically as they grow. By default, a reseller only needs to reach one threshold. Tick "Require both thresholds instead of either one" if you want both required. Tiers are ordered from entry level to top tier. When an order pushes a reseller past several thresholds at once, they land in the **highest tier they qualify for**, not the next one up. Need an exception for a single product? Use **Overrides** on the tier row. An override replaces the tier discount for one product, or for one specific variant. On the **Products** tab, flip the **Enrolled** switch for every product resellers can buy. Enrolled products are the wholesale catalog. Everything else stays invisible to them. This page never sets a separate reseller price. Resellers always pay your regular price minus their tier discount, so a price change on the product flows through automatically. **Disable Volume Discount** on a row stops your normal quantity discounts from stacking on top of the wholesale discount for that product. ## Bringing resellers in **Invite Reseller** (top right of the page) takes an email and an optional tier. If that email is new to your shop, the customer account is created for you. Without a tier, the reseller pays your regular price until you assign one, so pick one unless you have a reason not to. The invitation email carries a link that expires after **14 days**. Until it is accepted, the relationship sits in **Invited**. In Application mode, requests land on the **Applications** tab. Each shows the applicant's note and the date. **Approve** lets you pick the tier they start in. **Reject** takes an optional reason for your own records. It is not emailed automatically. A rejected applicant keeps their customer account and can apply again later. Every relationship is in exactly one state: **Invited**, **Applied**, **Active**, **Suspended**, or **Rejected**. You can suspend an active reseller and reinstate them later. Suspending pauses wholesale pricing and their API access but keeps their order history. Relationships are never deleted. Only an unaccepted invitation can be withdrawn. ## What your resellers see Resellers open `https:///customer-portal`, sign in, and select the **Reseller** tab. You can simply point partners to the **Log in** button on your storefront, or share the [reseller panel guide](/developers/reseller-panel) with them. The tab appears only when the program is enabled and enrollment is **Open** or **Application**, or when the signed-in customer already has a reseller relationship. In the customer portal of your shop, an approved reseller gets a **Reseller** tab with: * their tier, lifetime revenue, lifetime orders, and balance, with **Add funds** to top up * the **Wholesale catalog** — your enrolled products at their tier prices, with a running order summary that shows the total, the saving versus retail, and their balance after the order * **Reseller orders** — past wholesale purchases with the delivered keys and a **Copy all** button * **API keys** — up to 5 active keys for ordering from their own system The API keys are for the [Reseller API](/developers/reseller-api), which lets a reseller automate catalog reads, balance checks, and ordering. You do not have to do anything to enable it. It works for every active reseller. ## Dynamic delivery: reselling someone else's stock The relationship also works in the other direction. If **you** are an active reseller in another Shoppex shop, you can sell that shop's products in your own store without holding any stock. When a customer buys, Shoppex buys the item from your supplier automatically and hands the supplier's codes straight to your buyer. Set it up per product, in the product editor's **Supplier source** section: 1. Save the product first, then pick the **Supplier**. Only shops where you are an active reseller with an enabled program are listed. 2. Pick the **Supplier product** to buy from, and a **Supplier variant** if the supplier's product has variants. Otherwise, leave it on "Whole product". 3. Check the margin readout: **Your price**, **Purchase price**, **Margin**. It warns you if the sale price is below the wholesale price you pay. 4. Leave **Automatic fulfilment** on. Turning it off pauses supplier purchases for that product without losing the configuration. A product can have a supplier source **or** a dynamic webhook delivery URL, not both. Only one automatic fulfilment mechanism can own a delivery. If you need to switch, remove the webhook URL under Delivery & Stock first. The purchase is paid from your reseller balance at the supplier, so keep that balance funded. If the supplier cannot deliver (out of stock, the program disabled, your account suspended, or your balance too low), Shoppex refunds your buyer for that line automatically and reports it. Watch the `reseller:supplier_order.delivered` and `reseller:supplier_order.failed` [webhooks](/developers/webhook-events) if you want to be alerted. Supply chains can nest: your supplier can itself be reselling, but only three shops deep. A shop can never be its own supplier. ## Reading the numbers The **Overview** tab tracks the program: total, active, invited, and applied counts. It also shows **Revenue · 30d** and **Revenue · All Time**, calculated from completed wholesale orders with refunds excluded. You can see the spread of resellers across tiers, and your five biggest active accounts by lifetime revenue. Suspended and rejected accounts are deliberately kept out of the leaderboard. ## Automating it Everything on the enrollment side is available in the Developer API: list resellers, invite, suspend, reinstate, and read or update the program settings. See [Resellers](/developers/manage-resellers) for the endpoints. Tier authoring and product enrollment stay in the dashboard on purpose, so the price cascade has one source of truth. # API introduction Source: https://docs.shoppex.io/developers/api-overview Getting started with the Shoppex Developer API This page is the **Dev API overview**, not the best first page for every workflow. Use this quick routing: * if you want your **first generic Dev API call**, start with [Quick start](/developers/quickstart) * if you want to **build or customize a theme with AI**, start with [Editing with AI](/storefront/editing-with-ai) * if you want **theme automation endpoints** specifically, read [Visual themes](/storefront/visual-themes) first and use this API section as reference after that ## Guide vs reference * `/themes/*` explains the ThemeDocument workflow and when to use settings, document, preview, and publish operations * `/api-reference/*` explains the Dev API shape, auth, scopes, and endpoint details Use the theme pages to understand the workflow. Use the API reference when you already know the workflow and need the exact request shape. ## Official SDKs If you do not want to hand-write requests, use an official SDK for your language. Official Shoppex SDKs, install commands, public repos, and language examples. ## Base URL All API requests must be made to: ``` https://api.shoppex.io/dev/v1 ``` ## Authentication The Shoppex API uses Bearer token authentication. You can authenticate with: * an API key like `shx_...`, for server-to-server integrations you manage directly * an OAuth2 access token like `shpat_...`, for installable third-party apps An internal sync worker uses `Authorization: Bearer shx_your_api_key_here`. An installable ERP app uses `Authorization: Bearer shpat_your_access_token_here`. Include the token in the `Authorization` header: ```bash theme={"system"} Authorization: Bearer shx_your_api_key_here ``` Keep your API keys secure. Never expose them in client-side code or public repositories. API keys and OAuth2 access tokens can both be limited with scopes, for example `products.read` or `themes.write`. See [Authentication](/developers/authentication) for the full scope catalog, how to create and rotate keys, and the OAuth2 authorization flow. Use `GET /me/capabilities` to inspect an active key's scopes, enabled payment methods, and (for OAuth2) issuing client at runtime. See [Authentication](/developers/authentication#inspecting-the-active-key) for the full response shape. ## Request format All requests must: * Use `Content-Type: application/json` * Send JSON-encoded request bodies * Include the Authorization header ```bash cURL theme={"system"} curl https://api.shoppex.io/dev/v1/invoices \ -H "Authorization: Bearer shx_your_api_key_or_shpat_access_token" \ -H "Content-Type: application/json" ``` ## OAuth2 flow For third-party apps, Shoppex also supports an OAuth2 authorization-code flow. The merchant browser is redirected to `GET /dev/v1/oauth/authorize` and approves on a Shoppex-hosted screen. Your backend then exchanges the returned `shoa_...` code at `POST /dev/v1/oauth/token` for a `shpat_...` access token. The token endpoint returns standard OAuth2 JSON, not the normal Shoppex API envelope. Every other `/dev/v1/*` endpoint you call afterward still uses the standard Shoppex response format. See [Authentication](/developers/authentication) for the full flow with request and response examples. ## Payment retries and gateway switches Shoppex can create more than one internal payment attempt for the same invoice. A customer can start with PayPal, close the checkout, reopen the invoice, and finish with Stripe. This catches most people off guard. Treat the Shoppex invoice or payment ID as the source of truth, not a single provider session or order ID. ### Orders & disputes * use `GET /orders` for your operational order queue * use `POST /orders` when your backend wants to create a pending order from catalog line items * use `POST /orders/:id/fulfill` for a server-side fulfillment trigger on a pending order * use `POST /orders/:id/refund` for merchant-driven refunds without the dashboard * use `GET /disputes` for chargebacks and payment-risk review ### Invoices & payments * use `GET /invoices` when you need the lower-level invoice resource directly * use `POST /payment-links` or `POST /payment-links/:id/toggle` for sales-link workflows * use `GET /coupons/code/:code` for server-side coupon validation by code ### Subscriptions & customers * use `POST /subscriptions/:id/pause` or `PATCH /subscriptions/:id/custom-fields` for recurring billing workflows * use `POST /customers/:id/wallet/credit` or `POST /customers/:id/wallet/debit` for store-credit adjustments * use `GET /customers/:id?include_affiliate=true` when you need customer detail plus affiliate summary in one response ### Products & variants * use `GET /products/:id/variants/fields`, `POST /products/:id/variants/fields`, or `POST /products/:id/variants/fields/:fieldId/options` for variant configuration tooling * use `GET /products?include_variants=true` when you need product reads with inline variant prices ### Analytics & affiliates * use `GET /analytics/reports` or `POST /analytics/reports/:id/generate` for scheduled exports * use `GET /affiliates/customers` or `POST /affiliates/applications/:id/approve` for affiliate program automation ### Store & themes * use `GET /store/branding`, `POST /store/domains/additional`, `GET /store/domains/additional/:id/verification`, or `PUT /store/layout` for storefront automation * use `GET /themes/:id/control/document/draft`, `PUT /themes/:id/control/document`, and `POST /themes/:id/control/publish` for revision-gated ThemeDocument automation * use `GET/POST /themes/:id/control/settings` for settings-derived themes and `POST /themes/:id/control/preview` for previews ### Licenses & webhooks * use `GET /licenses`, `POST /licenses`, `PATCH /licenses/:id`, or `DELETE /licenses/:id` for license support workflows * use `POST /webhooks/logs/:id/retry` or `POST /webhooks/:id/rotate-secret` for webhook ops workflows ### Security * use `GET /security/audit-trail` or `GET /security/session-trail/:session_id` for security and support workflows Coupon note: * the public storefront flow already had `POST /v1/storefront/coupons/check` * `GET /coupons/code/:code` is the server-to-server Dev API equivalent The reliable pattern: use Shoppex invoice/payment responses, listen to Shoppex webhooks, and make your fulfillment logic idempotent. Do not assume one invoice always maps to exactly one provider-side payment session. ## Theme automation shortcut If you came here for themes, the usual endpoint family is: * `GET /themes` * `POST /themes/control/create` * `GET /themes/{id}/control/settings` * `POST /themes/{id}/control/settings` * `GET /themes/{id}/control/document` * `GET /themes/{id}/control/document/draft` * `PUT /themes/{id}/control/document` * `GET /themes/{id}/control/document/schema` * `POST /themes/{id}/control/preview` * `DELETE /themes/{id}/control/preview/{sessionId}` * `POST /themes/{id}/control/publish` These endpoints inspect, revision-save, preview, and publish validated ThemeDocuments and their settings. For document-authoritative themes, read the draft first. Send its `revision` back as `expected_revision` when saving, and send the new revision returned by the save as `expected_revision` when publishing. The MCP tools wrap this exact read → save → publish contract. `POST /themes/control/create` accepts these document-native bases: `default`, `classic`, `pulse`, `starlight`, and `clean-minimal`. Every accepted base maps to a deployed ThemeDocument composition. Retired schemes (`apex`, `nebula`, `phantom`, `shadow`, `vault`) are no longer valid bases. Existing themes on them keep rendering and stay exportable and importable. Best reading order: 1. [Editing with AI](/storefront/editing-with-ai) 2. [Visual themes](/storefront/visual-themes) 3. this API reference for the exact endpoint details ## Response format All responses return JSON with a consistent structure: ```json theme={"system"} { "data": { "id": "abc123", "status": "COMPLETED" } } ``` ```json theme={"system"} { "data": [ { "id": "abc123" }, { "id": "def456" } ], "pagination": { "next_cursor": "eyJpZCI6MTIzfQ", "has_more": true } } ``` ```json theme={"system"} { "error": { "code": "VALIDATION_ERROR", "message": "Invalid email address", "details": [ { "field": "email", "message": "must be a valid email" } ], "doc_url": "https://docs.shoppex.io/api-reference/errors" } } ``` ## HTTP status codes All error responses follow the standard error envelope format. See the [Error handling guide](/developers/errors) for the full status-code table and detailed error codes. ## Request tracing Use `X-Request-Id` to correlate your logs with Shoppex responses. Shoppex returns the same header on both success and error responses. ```http theme={"system"} X-Request-Id: sync-run-42 ``` If you send `X-Request-Id: sync-run-42`, Shoppex echoes that same id back. If you do not send one, Shoppex generates one for you. ## Rate limiting API requests are limited to protect the service. If you go over the limit, Shoppex returns a `429 Too Many Requests` response. Authenticated Dev API requests return `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`, and (when blocked) `Retry-After` headers. If you are rate limited, wait until `X-RateLimit-Reset` or `Retry-After` before retrying. See the [Error handling guide](/developers/errors#rate-limiting) for a header example and the `RATE_LIMITED` error shape. ## Idempotency All current state-changing `/dev/v1` endpoints support idempotent retries. This covers product, category, group, coupon, variant, customer, webhook, ticket, review, blacklist, license, escrow, invoice, and payment writes. Retrying `POST /customers` after a timeout will not create the same customer twice, and retrying `POST /webhooks/:id/test` will not spam duplicate test deliveries. Use one of these headers: * `Idempotency-Key` * `X-Idempotency-Key` ```bash cURL theme={"system"} curl https://api.shoppex.io/dev/v1/payments \ -X POST \ -H "Authorization: Bearer shx_your_api_key" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: payment-create-123" \ -d '{"title":"Order #1001","email":"buyer@example.com","value":49.99,"currency":"USD"}' ``` Responses include an `Idempotency-Status` header: * `created` when the request created a new payment * `cached` when Shoppex replayed the stored response * `processing` when the same key is still in flight * `bypassed` when idempotency was intentionally skipped Important behavior: * the replay key is scoped to your authenticated key, the route, and the idempotency key * Shoppex also checks the request body for the same method and path * the replay window is currently 24 hours * `5xx` responses are not cached for replay In other words: * same key + same body -> cached response * same key + different body -> validation error * same key while the first request is still in flight -> `processing` ## Pagination The Dev API uses two pagination models. Cursor-based pagination covers most resource lists, including products, invoices, and orders. Page-based pagination covers operational lists, such as webhook delivery logs, where page navigation is clearer. Use cursor-based pagination for feed-style lists like products, invoices, orders, customers, and subscriptions. ### Parameters | Parameter | Type | Default | Description | | --------- | ------- | ------- | --------------------------------- | | `limit` | integer | 50 | Number of items per page (1-100) | | `cursor` | string | - | Cursor from the previous response | ### Basic usage #### First request ```bash theme={"system"} GET /products?limit=25 ``` **Response:** ```json theme={"system"} { "data": [ { "uniqid": "prod_001", "title": "Product A" }, { "uniqid": "prod_002", "title": "Product B" }, // ... 23 more items ], "pagination": { "next_cursor": "eyJpZCI6MjUsImNyZWF0ZWRfYXQiOiIyMDI0LTAxLTAxIn0", "has_more": true } } ``` #### Next page Use the `next_cursor` value from the previous response. ```bash theme={"system"} GET /products?limit=25&cursor=eyJpZCI6MjUsImNyZWF0ZWRfYXQiOiIyMDI0LTAxLTAxIn0 ``` #### Final page The response looks like this when no items remain. ```json theme={"system"} { "data": [ { "uniqid": "prod_099", "title": "Product Y" }, { "uniqid": "prod_100", "title": "Product Z" } ], "pagination": { "next_cursor": null, "has_more": false } } ``` ### Code examples #### Fetch all pages ```typescript TypeScript theme={"system"} async function fetchAllProducts(): Promise { const allProducts: Product[] = []; let cursor: string | null = null; do { const url = new URL('https://api.shoppex.io/dev/v1/products'); url.searchParams.set('limit', '100'); if (cursor) url.searchParams.set('cursor', cursor); const response = await fetch(url.toString(), { headers: { 'Authorization': `Bearer ${API_KEY}` } }); const { data, pagination } = await response.json(); allProducts.push(...data); cursor = pagination.next_cursor; } while (cursor); return allProducts; } ``` ```python Python theme={"system"} def fetch_all_products(): all_products = [] cursor = None while True: params = {'limit': 100} if cursor: params['cursor'] = cursor response = requests.get( 'https://api.shoppex.io/dev/v1/products', headers={'Authorization': f'Bearer {API_KEY}'}, params=params ) result = response.json() all_products.extend(result['data']) if not result['pagination']['has_more']: break cursor = result['pagination']['next_cursor'] return all_products ``` #### Async generator (streaming) ```typescript TypeScript theme={"system"} async function* fetchProductsStream(): AsyncGenerator { let cursor: string | null = null; do { const url = new URL('https://api.shoppex.io/dev/v1/products'); url.searchParams.set('limit', '100'); if (cursor) url.searchParams.set('cursor', cursor); const response = await fetch(url.toString(), { headers: { 'Authorization': `Bearer ${API_KEY}` } }); const { data, pagination } = await response.json(); for (const product of data) { yield product; } cursor = pagination.next_cursor; } while (cursor); } // Usage for await (const product of fetchProductsStream()) { console.log(product.title); } ``` ### Filtering with pagination Filters work alongside pagination. ```bash theme={"system"} # Get completed invoices, paginated GET /invoices?filters=status:COMPLETED&limit=50 # Continue with cursor GET /invoices?filters=status:COMPLETED&limit=50&cursor=eyJ... ``` Always include the same filters and sorts when you follow a cursor. Changing them invalidates the cursor. ```bash theme={"system"} GET /orders?filters=status:COMPLETED,customer_email:buyer@example.com&sorts=-created_at GET /customers?filters=city:Berlin&sorts=created_at ``` ### Cursor-based endpoints | Endpoint | Default limit | Max limit | | -------------------- | ------------- | --------- | | `GET /products` | 50 | 100 | | `GET /invoices` | 50 | 100 | | `GET /customers` | 50 | 100 | | `GET /coupons` | 50 | 100 | | `GET /subscriptions` | 50 | 100 | | `GET /reviews` | 50 | 100 | | `GET /tickets` | 50 | 100 | | `GET /webhooks` | 50 | 100 | | `GET /blacklist` | 50 | 100 | Use page-based pagination for operational lists where integrators often jump to a specific page. ### Parameters | Parameter | Type | Default | Description | | --------- | ------- | ------- | -------------------------- | | `page` | integer | 1 | Page number, starting at 1 | | `limit` | integer | 25 | Number of items per page | ### Basic usage ```bash theme={"system"} GET /webhooks/logs?page=2&limit=10 ``` ```json theme={"system"} { "data": [ { "uniqid": "log_001", "status": "success" } ], "pagination": { "total": 37, "page": 2, "limit": 10, "total_pages": 4, "has_more": true } } ``` ### Page-based endpoints | Endpoint | Default limit | | ------------------------------ | ------------- | | `GET /webhooks/logs` | 25 | | `GET /affiliates/customers` | 25 | | `GET /affiliates/applications` | 25 | ### Pagination best practices For a UI, 20-25 items is plenty. For a background sync job, raise the limit to 100. Larger limits increase response time. Cursors work for immediate sequential use. They expire after 24 hours. They also become invalid if the underlying data changes significantly. An empty `data` array with `has_more: false` is valid. No items match your query. When you fetch all pages, add delays between requests to avoid rate limiting. ## Filtering Core list endpoints now support a shared filtering and sorting contract: ```bash theme={"system"} # Filter by exact fields GET /orders?filters=status:COMPLETED,customer_email:john@example.com # Sort oldest first GET /customers?sorts=created_at # Sort newest first (default) GET /invoices?sorts=-created_at ``` Supported list endpoints for this shared contract: * `GET /customers` * `GET /orders` * `GET /invoices` `filters` uses `field:value` pairs separated by commas. ```bash theme={"system"} filters=status:COMPLETED,customer_email:buyer@example.com sorts=created_at # oldest first sorts=-created_at # newest first (default) ``` Legacy single-field query params like `status` or `customerEmail` still work where already documented, but new integrations use `filters` and `sorts` instead. # Authentication Source: https://docs.shoppex.io/developers/authentication How to authenticate with the Shoppex Developer API If you are using **Claude Code, Codex, or an internal automation script** for themes, use an **API key** with scopes `themes.read themes.write`. Use OAuth2 only if you are building an external app that other merchants install. ## Authentication modes Shoppex supports two Bearer token modes for the Dev API: * API keys for server-to-server integrations you manage directly * OAuth2 access tokens for installable apps and third-party connectors Your own warehouse worker uses an API key. A third-party ERP or CRM uses OAuth2, so the merchant clicks "Connect Shoppex" instead of pasting a raw key. ## Quick decision Use this shortcut: * **I run the tool myself** -> API key * **I am building an installable third-party app** -> OAuth2 Claude Code editing your own theme? API key. Codex running your internal deployment helper? API key. ERP integration for many merchants? OAuth2. Shoppex uses API keys for authentication. Each key belongs to exactly one shop and can be limited with scopes. ### Creating an API key Go to [dashboard.shoppex.io](https://dashboard.shoppex.io) and log in. Go to **Settings** in the sidebar. In the **Developer API** section, click **Generate New API Key**. Pick the smallest scope set your integration needs. Start from a preset, or choose **Custom** to select scopes one resource at a time. To change the scopes of a key later, use **Edit Scopes** in its menu. Common scope combinations: | Integration | Scopes | | ---------------------- | ----------------------------------- | | Fulfillment / ERP sync | `orders.read` | | Chargeback tooling | `disputes.read` | | Report automation | `analytics.read`, `analytics.write` | | Theme development | `themes.read`, `themes.write` | | Catalog sync | `products.read` | | CRM sync | `customers.read` | | Webhook management | `webhooks.read`, `webhooks.write` | Copy your API key immediately. It starts with `shx_` and will not be shown again. Store your API key securely. If compromised, regenerate it immediately. ### Using your API key Include the API key in the `Authorization` header with the `Bearer` scheme: ```bash cURL theme={"system"} curl -X GET https://api.shoppex.io/dev/v1/invoices \ -H "Authorization: Bearer shx_your_api_key_here" ``` ```typescript TypeScript theme={"system"} const response = await fetch('https://api.shoppex.io/dev/v1/invoices', { headers: { 'Authorization': 'Bearer shx_your_api_key_here' } }); ``` ```python Python theme={"system"} import requests response = requests.get( 'https://api.shoppex.io/dev/v1/invoices', headers={'Authorization': 'Bearer shx_your_api_key_here'} ) ``` ```php PHP theme={"system"} $ch = curl_init('https://api.shoppex.io/dev/v1/invoices'); curl_setopt($ch, CURLOPT_HTTPHEADER, [ 'Authorization: Bearer shx_your_api_key_here' ]); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); $response = curl_exec($ch); ``` OAuth2 is the better fit when you want an external app to connect to a shop without asking the merchant for a raw API key. ### Creating an OAuth app 1. Log in to [dashboard.shoppex.io](https://dashboard.shoppex.io) 2. Go to your Dev API settings 3. Create an OAuth app with: * a name * one or more redirect URIs * the scopes the app can request 4. Copy the `client_id` and `client_secret` For example: App name `ERP Connector`, redirect URI `https://erp.example.com/oauth/callback`, scopes `orders.read customers.read`. ### Authorize endpoint The merchant browser is redirected to: ```bash theme={"system"} GET /dev/v1/oauth/authorize?response_type=code&client_id=shoc_xxx&redirect_uri=https%3A%2F%2Ferp.example.com%2Foauth%2Fcallback&scope=orders.read%20customers.read&state=sync_42 ``` If the user is logged in and allowed to approve integrations for the active shop, Shoppex first shows a Shoppex-hosted approval screen. After the merchant clicks **Approve and continue**, Shoppex issues a short-lived code and redirects back to your app. ```bash theme={"system"} https://erp.example.com/oauth/callback?code=shoa_abc123&state=sync_42 ``` Important: * the approval step is intentionally interactive, so a third-party site cannot silently mint OAuth codes for a logged-in merchant * the approval submit is protected with a Shoppex CSRF token behind the hosted approval page ### Token endpoint Exchange that code for an access token: ```bash theme={"system"} curl https://api.shoppex.io/dev/v1/oauth/token \ -X POST \ -H "Authorization: Basic $(printf 'shoc_xxx:shcs_xxx' | base64)" \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "grant_type=authorization_code&code=shoa_abc123&redirect_uri=https%3A%2F%2Ferp.example.com%2Foauth%2Fcallback" ``` Example response: ```json theme={"system"} { "access_token": "shpat_xxx", "token_type": "Bearer", "expires_in": 3600, "refresh_token": "shprt_xxx", "scope": "orders.read customers.read" } ``` Refresh flow: ```bash theme={"system"} curl https://api.shoppex.io/dev/v1/oauth/token \ -X POST \ -H "Content-Type: application/json" \ -d '{ "grant_type": "refresh_token", "refresh_token": "shprt_xxx", "client_id": "shoc_xxx", "client_secret": "shcs_xxx" }' ``` ### Using the access token After the exchange, call normal Dev API endpoints with the OAuth access token: ```bash theme={"system"} curl https://api.shoppex.io/dev/v1/orders \ -H "Authorization: Bearer shpat_your_access_token_here" ``` Important: * OAuth token responses follow the normal OAuth2 JSON format * normal `/dev/v1/*` resource calls still use the standard Shoppex Dev API response envelope * scopes still apply exactly the same way as for API keys ## Scopes Scopes follow a `resource.action` format, for example `products.read`, `orders.write`, or `themes.read`. Use `*` for full access. The most common mistake here is giving a key more scopes than it needs. A catalog sync must not also get `customers.write`. A reporting tool only needs read scopes. Grant the minimum and expand later if needed. Available scopes fall into three groups: ### Commerce scopes * `orders.read` * `disputes.read` * `subscriptions.write` * `payment_links.write` * `products.read` ### Customer & analytics scopes * `customers.write` * `analytics.read` * `analytics.write` * `affiliates.read` * `affiliates.write` ### Platform & security scopes * `webhooks.write` * `themes.read` * `themes.write` * `licenses.read` * `licenses.write` * `security.read` * `security.write` * `store.read` * `store.write` * `*` for full access OAuth2 access tokens use these same Dev API scopes. For instance, your app requests `orders.read customers.read`, the merchant approves, and the exchanged `shpat_...` token can then only read orders and customers. ### Inspecting the active key Use `GET /me/capabilities` to inspect the active key at runtime: ```bash theme={"system"} curl https://api.shoppex.io/dev/v1/me/capabilities \ -H "Authorization: Bearer shx_your_api_key_here" ``` This response tells you: * which scopes are active * whether the key has full access * which resource groups are readable or writable * which payment methods are enabled for the shop * which gated Dev API features are enabled for the shop Notably, `enabledPaymentMethods` tells you whether gateways like Stripe, PayPal, or your connected crypto provider (OxaPay, Cryptomus, NowPayments) are active. `features.nativeCryptoCheckout` tells you which non-custodial native crypto gateways are available. If you authenticated with OAuth2, the capabilities response also tells you which OAuth client issued the current access token. ## Key format | Prefix | Description | | -------- | -------------------------------------------- | | `shx_` | Shoppex API Key (32 characters after prefix) | | `shoc_` | OAuth client id | | `shcs_` | OAuth client secret | | `shoa_` | OAuth authorization code | | `shpat_` | OAuth access token | | `shprt_` | OAuth refresh token | Example: `shx_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6` ## Error responses ### Missing or invalid key ```json theme={"system"} { "error": { "code": "UNAUTHORIZED", "message": "Missing or invalid API key. Expected: Authorization: Bearer shx_...", "doc_url": "https://docs.shoppex.io/api-reference/errors" } } ``` ### Shop suspended ```json theme={"system"} { "error": { "code": "FORBIDDEN", "message": "Your shop has been suspended.", "doc_url": "https://docs.shoppex.io/api-reference/errors" } } ``` ### Missing scope ```json theme={"system"} { "error": { "code": "FORBIDDEN", "message": "Missing required API scope.", "doc_url": "https://docs.shoppex.io/api-reference/errors" } } ``` ## Regenerating keys If an API key is compromised: 1. Go to **Settings** in your dashboard 2. Revoke the affected key 3. Create a new key with the smallest required scopes 4. Update your integration with the new key The old key is immediately invalidated. All requests using it will fail with `401 Unauthorized`. ## Best practices ### Use least privilege scopes Give every integration only the scopes it really needs. A reporting tool needs `products.read` and `payments.read`, not `*`. ### Handle rate-limit headers Read `X-RateLimit-Remaining` and `Retry-After`. When remaining hits `0`, back off until the reset window passes instead of hammering the API. ### Forward a request id If your worker is processing `job_123`, send `X-Request-Id: job_123` so your logs and Shoppex support traces line up. ### Use environment variables Never hardcode API keys in your source code. ```bash theme={"system"} export SHOPPEX_API_KEY=shx_your_api_key_here ``` ```typescript theme={"system"} const apiKey = process.env.SHOPPEX_API_KEY; ``` ### Keep keys server-side Never expose your API key in client-side code (browsers, mobile apps). Make API calls from your backend server. ### Use HTTPS only Always use HTTPS when making API requests to prevent key interception. ### Monitor usage Check your dashboard regularly for unusual API activity. # Dynamic product delivery Source: https://docs.shoppex.io/developers/dynamic-delivery The callback contract used by DYNAMIC products through dynamic_webhook `dynamic_webhook` is a direct server-to-server callback that Shoppex sends when a `DYNAMIC` product needs fulfillment after a paid order, separate from normal event webhooks. This page covers the callback contract for `dynamic_webhook`. For normal Shoppex event webhooks like `order:paid`, see [Webhooks](/developers/webhooks) and [Webhook events](/developers/webhook-events). ## How it works 1. You create a product with `type: "DYNAMIC"`, set `dynamic_webhook`, and keep the generated dynamic webhook signing secret. 2. A customer pays for that product. 3. Shoppex sends `POST` to your `dynamic_webhook` URL. 4. Your server returns delivery data, or clearly states that it will fulfill the line item later. 5. Shoppex either stores the delivered content or marks the line item as `AWAITING_FULFILLMENT`. ## Webhook request ### HTTP details Shoppex sends a `POST` request with `Content-Type: application/json` and a JSON body that contains invoice, product, shop, and line item data. Your `dynamic_webhook` URL must be a public `http` or `https` address. For local development, use a tunnel such as ngrok or Cloudflare Tunnel. * `https://dev.example.com/shoppex/dynamic` — works * `https://abc123.ngrok.io/shoppex/dynamic` — works for local testing * `http://127.0.0.1:3000/...` — does not work. Shoppex cannot reach private or loopback URLs ### Example body The payload contains both camelCase and snake\_case forms for the most important fields. This is intentional, so simple handlers do not need a translation layer first. ```json theme={"system"} { "customerEmail": "buyer@example.com", "customer_email": "buyer@example.com", "productTitle": "Pro Pack", "product_title": "Pro Pack", "productType": "DYNAMIC", "product_type": "DYNAMIC", "quantity": 1, "variantId": "var_123", "variant_id": "var_123", "variantTitle": "Lifetime", "variant_title": "Lifetime", "customFields": { "discord_username": "tetra" }, "custom_fields": { "discord_username": "tetra" }, "invoice": { "id": "inv_db_123", "uniqid": "inv_123", "status": "COMPLETED", "type": "PRODUCT", "customer_email": "buyer@example.com", "currency": "USD", "subtotal": "29.99", "discount": "0.00", "tax": "0.00", "total": "29.99", "country": "US", "custom_fields": { "discord_username": "tetra" }, "created_at": "2026-03-24T13:00:00.000Z", "updated_at": "2026-03-24T13:01:00.000Z" }, "product": { "id": "prod_db_123", "uniqid": "prod_123", "title": "Pro Pack", "type": "DYNAMIC", "subtype": null, "price": "29.99", "price_display": "29.99", "currency": "USD" }, "shop": { "id": "shop_db_123", "name": "Example Shop" }, "line_item": { "id": "line_item_123", "quantity": 1, "product_id": "prod_db_123", "product_title": "Pro Pack", "product_type": "DYNAMIC", "variant_id": "var_123", "variant_title": "Lifetime", "unit_price": "29.99", "total": "29.99", "custom_fields": { "discord_username": "tetra" }, "addons": [], "metadata": {}, "bundle_config": {} }, "invoiceId": "inv_123", "invoice_id": "inv_123", "invoiceDbId": "inv_db_123", "invoice_db_id": "inv_db_123", "productId": "prod_db_123", "product_id": "prod_db_123", "shopId": "shop_db_123", "shop_id": "shop_db_123", "deliveryId": "dynamic:0d9e6f0a-1b2c-4d3e-8f90-a1b2c3d4e5f6:7c8d9e0f-1a2b-4c3d-8e9f-0a1b2c3d4e5f:default:018f6f2e-7c3a-7b21-9d4e-5a1b2c3d4e5f", "delivery_id": "dynamic:0d9e6f0a-1b2c-4d3e-8f90-a1b2c3d4e5f6:7c8d9e0f-1a2b-4c3d-8e9f-0a1b2c3d4e5f:default:018f6f2e-7c3a-7b21-9d4e-5a1b2c3d4e5f", "idempotencyKey": "dynamic:0d9e6f0a-1b2c-4d3e-8f90-a1b2c3d4e5f6:7c8d9e0f-1a2b-4c3d-8e9f-0a1b2c3d4e5f:default:018f6f2e-7c3a-7b21-9d4e-5a1b2c3d4e5f", "idempotency_key": "dynamic:0d9e6f0a-1b2c-4d3e-8f90-a1b2c3d4e5f6:7c8d9e0f-1a2b-4c3d-8e9f-0a1b2c3d4e5f:default:018f6f2e-7c3a-7b21-9d4e-5a1b2c3d4e5f" } ``` | Field | Type | Description | | ------------------------------------ | ------ | ---------------------------- | | `invoiceId` / `invoice_id` | string | Public Shoppex invoice ID | | `invoiceDbId` / `invoice_db_id` | string | Internal invoice row ID | | `productId` / `product_id` | string | Internal product row ID | | `shopId` / `shop_id` | string | Internal shop row ID | | `deliveryId` / `delivery_id` | string | Stable delivery identifier | | `idempotencyKey` / `idempotency_key` | string | Stable idempotency key | | `invoice` | object | Invoice snapshot | | `product` | object | Product snapshot | | `shop` | object | Shop snapshot | | `line_item` | object | Fulfilled line item snapshot | ### Request headers | Header | Description | | ---------------------------------- | ------------------------------------------------------------- | | `Content-Type` | Always `application/json` | | `X-Shoppex-Idempotency-Key` | Stable delivery key for deduplication | | `X-Shoppex-Delivery-Id` | Same value as the idempotency key | | `X-Shoppex-Timestamp` | Unix timestamp in seconds, used in the V2 signature | | `X-Shoppex-Signature-V2` | Timestamped HMAC-SHA256 signature: `v1,t=,h=` | | `X-Shoppex-Signature-V2-Algorithm` | `HMAC-SHA256` when a V2 signature is present | | `X-Shoppex-Signature` | Deprecated legacy HMAC-SHA512 body-only signature | | `X-Shoppex-Signature-Algorithm` | Deprecated legacy value `HMAC-SHA512` | ## Signature verification Dynamic product delivery has its own signing secret on the product. This secret is separate from normal Shoppex event webhook secrets: normal webhooks use the endpoint secret from **Settings → Webhooks**, dynamic delivery uses the product's `dynamic_webhook_secret`. When you create or update a dynamic product through the Developer API, pass `dynamic_webhook_secret` to set your own secret. If you set `dynamic_webhook` without a secret, Shoppex generates one and returns it once as `dynamic_webhook_secret` in that create or update response. Store it immediately. If the product has a signing secret, Shoppex signs `${deliveryId}.${timestamp}.${rawBody}` with HMAC-SHA256 and sends the digest in `X-Shoppex-Signature-V2`. Reject timestamps outside a 5-minute window. ```typescript theme={"system"} import crypto from 'crypto'; function verifyShoppexSignature( rawBody: string, signatureHeader: string | undefined, deliveryId: string | undefined, timestampHeader: string | undefined, ) { if (!signatureHeader || !deliveryId || !timestampHeader) return false; const segments = signatureHeader.split(',').map((part) => part.trim()); const parts = Object.fromEntries(segments.filter((part) => part.includes('=')).map((part) => { const [key, value] = part.trim().split('='); return [key, value ?? '']; })); if (!segments.includes('v1') || parts.t !== timestampHeader) return false; const timestamp = Number(parts.t); if (!Number.isFinite(timestamp)) return false; if (Math.abs(Math.floor(Date.now() / 1000) - timestamp) > 300) return false; if (!/^[0-9a-f]{64}$/i.test(parts.h ?? '')) return false; const expected = crypto .createHmac('sha256', process.env.SHOPPEX_DYNAMIC_WEBHOOK_SECRET!) .update(`${deliveryId}.${parts.t}.${rawBody}`) .digest('hex'); return crypto.timingSafeEqual(Buffer.from(parts.h, 'hex'), Buffer.from(expected, 'hex')); } ``` Shoppex keeps `X-Shoppex-Signature` as a legacy HMAC-SHA512 body-only header during the migration period. New dynamic delivery handlers must verify `X-Shoppex-Signature-V2`. Treat `X-Shoppex-Idempotency-Key` as the durable fulfillment key for this callback. Shoppex makes only one automatic request, but a merchant can retry a failed delivery manually. Your endpoint must return the same result for the same key instead of issuing a second token, license, or account. ## Webhook response ### Success Your endpoint must return `2xx` status and JSON. Recommended response: ```json theme={"system"} { "data": { "service_text": "Join the private server with the token below.", "dynamic_response": { "token": "dyn_123", "expires_at": "2026-12-31T23:59:59.000Z" }, "deliveryType": "DYNAMIC", "count": 1 } } ``` A solid integration follows this pattern: * Use `idempotencyKey` as your fulfillment key. * Return the same result if a merchant retries a failed delivery. * Keep the response short and structured. * Put the customer-facing text in `service_text`. * Put machine-readable output like tokens or credentials in `dynamic_response`. Avoid the following: * Generating a new token when Shoppex sends the same idempotency key again. * Depending on field names from only one casing style. * Returning HTML or a large non-JSON payload. Shoppex accepts three response forms: a JSON object, a JSON object with a nested `data` object, or a non-empty string. If you return a JSON object with `data`, Shoppex stores the nested `data` object: ```json theme={"system"} { "data": { "service_text": "Use this token in the bot.", "dynamic_response": { "token": "dyn_123" } } } ``` Shoppex normalizes your response into delivered items: ```json theme={"system"} { "service_text": "Use this token in the bot.", "dynamic_response": { "token": "dyn_123" }, "deliveryType": "DYNAMIC", "count": 1 } ``` If you return a plain string, Shoppex stores it as `dynamic_response`. If your system accepts the request but cannot return the delivery content within 15 seconds, respond with `200` and a clear pending status. Shoppex then marks this line item as `AWAITING_FULFILLMENT`. Deliver the content later with [`POST /dev/v1/orders/{id}/items/{item_id}/fulfill`](/developers/fulfillment#fulfill-a-line-item). The following example shows a complete callback exchange. The request body uses the same fields described above. The response is the asynchronous acknowledgement. ```http theme={"system"} POST /shoppex/dynamic HTTP/1.1 Host: vendor.example.com Content-Type: application/json X-Shoppex-Idempotency-Key: dynamic:0d9e6f0a-1b2c-4d3e-8f90-a1b2c3d4e5f6:7c8d9e0f-1a2b-4c3d-8e9f-0a1b2c3d4e5f:default:018f6f2e-7c3a-7b21-9d4e-5a1b2c3d4e5f X-Shoppex-Delivery-Id: dynamic:0d9e6f0a-1b2c-4d3e-8f90-a1b2c3d4e5f6:7c8d9e0f-1a2b-4c3d-8e9f-0a1b2c3d4e5f:default:018f6f2e-7c3a-7b21-9d4e-5a1b2c3d4e5f X-Shoppex-Timestamp: 1785823200 X-Shoppex-Signature-V2: v1,t=1785823200,h=SIGNATURE_HEX { "customerEmail": "buyer@example.com", "customer_email": "buyer@example.com", "productTitle": "Pro Pack", "product_title": "Pro Pack", "productType": "DYNAMIC", "product_type": "DYNAMIC", "quantity": 1, "variantId": "var_123", "variant_id": "var_123", "variantTitle": "Lifetime", "variant_title": "Lifetime", "customFields": { "discord_username": "tetra" }, "custom_fields": { "discord_username": "tetra" }, "invoice": { "id": "inv_db_123", "uniqid": "inv_123", "status": "COMPLETED", "type": "PRODUCT", "customer_email": "buyer@example.com", "currency": "USD", "subtotal": "29.99", "discount": "0.00", "tax": "0.00", "total": "29.99", "country": "US", "custom_fields": { "discord_username": "tetra" }, "created_at": "2026-08-04T08:39:00.000Z", "updated_at": "2026-08-04T08:40:00.000Z" }, "product": { "id": "prod_db_123", "uniqid": "prod_123", "title": "Pro Pack", "type": "DYNAMIC", "subtype": null, "price": "29.99", "price_display": "29.99", "currency": "USD" }, "shop": { "id": "shop_db_123", "name": "Example Shop" }, "line_item": { "id": "line_item_123", "quantity": 1, "product_id": "prod_db_123", "product_title": "Pro Pack", "product_type": "DYNAMIC", "variant_id": "var_123", "variant_title": "Lifetime", "unit_price": "29.99", "total": "29.99", "custom_fields": { "discord_username": "tetra" }, "addons": [], "metadata": {}, "bundle_config": {} }, "invoiceId": "inv_123", "invoice_id": "inv_123", "invoiceDbId": "inv_db_123", "invoice_db_id": "inv_db_123", "productId": "prod_db_123", "product_id": "prod_db_123", "shopId": "shop_db_123", "shop_id": "shop_db_123", "deliveryId": "dynamic:0d9e6f0a-1b2c-4d3e-8f90-a1b2c3d4e5f6:7c8d9e0f-1a2b-4c3d-8e9f-0a1b2c3d4e5f:default:018f6f2e-7c3a-7b21-9d4e-5a1b2c3d4e5f", "delivery_id": "dynamic:0d9e6f0a-1b2c-4d3e-8f90-a1b2c3d4e5f6:7c8d9e0f-1a2b-4c3d-8e9f-0a1b2c3d4e5f:default:018f6f2e-7c3a-7b21-9d4e-5a1b2c3d4e5f", "idempotencyKey": "dynamic:0d9e6f0a-1b2c-4d3e-8f90-a1b2c3d4e5f6:7c8d9e0f-1a2b-4c3d-8e9f-0a1b2c3d4e5f:default:018f6f2e-7c3a-7b21-9d4e-5a1b2c3d4e5f", "idempotency_key": "dynamic:0d9e6f0a-1b2c-4d3e-8f90-a1b2c3d4e5f6:7c8d9e0f-1a2b-4c3d-8e9f-0a1b2c3d4e5f:default:018f6f2e-7c3a-7b21-9d4e-5a1b2c3d4e5f" } HTTP/1.1 200 OK Content-Type: application/json { "status": "pending" } ``` The nested form is also accepted: ```json theme={"system"} { "data": { "status": "pending" } } ``` ### Example handler ```typescript theme={"system"} import express from 'express'; import crypto from 'crypto'; const app = express(); app.use(express.json({ verify: (req, _res, buf) => { (req as express.Request & { rawBody?: string }).rawBody = buf.toString('utf8'); }, })); const issuedTokens = new Map(); app.post('/shoppex/dynamic', async (req, res) => { const rawBody = (req as express.Request & { rawBody?: string }).rawBody ?? JSON.stringify(req.body); const signature = req.header('x-shoppex-signature-v2'); const deliveryId = req.header('x-shoppex-delivery-id'); const timestamp = req.header('x-shoppex-timestamp'); if (!signature || !deliveryId || !timestamp) { return res.status(401).json({ error: 'Missing signature' }); } const segments = signature.split(',').map((part) => part.trim()); const parts = Object.fromEntries(segments.filter((part) => part.includes('=')).map((part) => { const [key, value] = part.trim().split('='); return [key, value ?? '']; })); if ( !segments.includes('v1') || parts.t !== timestamp || Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp)) > 300 || !/^[0-9a-f]{64}$/i.test(parts.h ?? '') ) { return res.status(401).json({ error: 'Invalid signature' }); } const expectedSignature = crypto .createHmac('sha256', process.env.SHOPPEX_DYNAMIC_WEBHOOK_SECRET!) .update(`${deliveryId}.${timestamp}.${rawBody}`) .digest('hex'); if (!crypto.timingSafeEqual(Buffer.from(parts.h, 'hex'), Buffer.from(expectedSignature, 'hex'))) { return res.status(401).json({ error: 'Invalid signature' }); } const idempotencyKey = String( req.header('x-shoppex-idempotency-key') ?? req.body.idempotencyKey ?? req.body.idempotency_key ?? '' ).trim(); if (!idempotencyKey) { return res.status(400).json({ error: 'Missing idempotency key' }); } let existing = issuedTokens.get(idempotencyKey); if (!existing) { existing = { token: `dyn_${Math.random().toString(36).slice(2, 10)}`, expires_at: '2026-12-31T23:59:59.000Z', }; issuedTokens.set(idempotencyKey, existing); } return res.json({ data: { service_text: 'Use this token in the bot.', dynamic_response: existing, deliveryType: 'DYNAMIC', count: 1, }, }); }); ``` ### Retryable errors Shoppex makes one delivery attempt with a 15-second timeout. It does not retry automatically after a timeout, network error, `429`, or `5xx` response. This avoids duplicating a side effect when Shoppex cannot know whether your server already delivered the product before the connection failed. After this terminal failure, Shoppex: * marks the line item as `FAILED` * notifies the merchant * sends `order:item.delivery_failed` to subscribed merchant webhook endpoints The merchant can fulfill the failed line item manually through the dashboard or the Developer API, or retry the dynamic delivery again. A timeout or network error does not tell you whether the vendor already delivered the product. Check `X-Shoppex-Idempotency-Key` for duplicates before you take any action with a side effect. ### Non-retryable errors You must state pending clearly. An empty `2xx` response keeps its historical meaning: Shoppex treats the line item as delivered and stores a placeholder delivery note. Never use an empty body to say that you will deliver the content later. An empty or accidentally empty `2xx` response has no retry path. Shoppex already considers the item delivered, so it does not call your endpoint again for that line item. Always send the pending response shown above when you cannot deliver content immediately. ## Invoice status behavior Shoppex calls the `dynamic_webhook` URL during product fulfillment, after the invoice reaches a paid or completed state. A customer buys your dynamic product, Shoppex marks the invoice as paid, starts fulfillment, calls your endpoint, and saves your response into the invoice delivery details. | Endpoint outcome | Line item status | | ------------------------------------------------ | ------------------------------------------------------------------------------- | | Returns delivery content (`2xx`) | Delivered — Shoppex stores your response | | Returns a clear pending status (`2xx`) | `AWAITING_FULFILLMENT` until you call the fulfill endpoint | | Times out, errors, or returns a non-`2xx` status | `FAILED` — Shoppex notifies the merchant and sends `order:item.delivery_failed` | | Returns an empty `2xx` body | Delivered, with a placeholder delivery note | Setup, signatures, and retry policies Full event type reference and payload schemas # Email segmentation filters Source: https://docs.shoppex.io/developers/email-segmentation-filters JSON filter rules for email marketing audience segments Email marketing segments use a JSON object called `filter_rules`. You can create segments in the dashboard under **Marketing -> Email -> Audience -> Segments** or through `POST /dev/v1/email-marketing/segments`. All top-level rules are combined with `AND`. Empty JSON (`{}`) means all eligible subscribed contacts. For keys that accept an array, entries inside that array are combined as `OR`. For example, `"country": ["DE", "AT"]` means Germany or Austria. ## Product purchase Use product filters when you want customers who bought a specific product. Product filters match paid invoices with status `COMPLETED`, `PARTIAL_REFUND`, or `REFUNDED`. Developer invoices are excluded. ```json theme={"system"} { "product_id": "PRODUCT_ID_OR_UNIQID" } ``` Multiple products: ```json theme={"system"} { "product_ids": ["PRODUCT_ID_1", "PRODUCT_UNIQID_2"] } ``` Nested form: ```json theme={"system"} { "product": { "uniqids": ["product_abc", "product_def"] } } ``` Supported product keys: | Key | Value | | --------------------- | ------------ | | `product_id` | string | | `product_ids` | string array | | `productId` | string | | `productIds` | string array | | `product.id` | string | | `product.ids` | string array | | `product.product_id` | string | | `product.product_ids` | string array | | `product.productId` | string | | `product.productIds` | string array | | `product.uniqid` | string | | `product.uniqids` | string array | ## Spending Spending filters use the customer profile `total_spent` field. Customers who spent at least 100: ```json theme={"system"} { "min_total_spent": 100 } ``` Customers who spent at most 500: ```json theme={"system"} { "max_total_spent": 500 } ``` Range: ```json theme={"system"} { "min_total_spent": 100, "max_total_spent": 500 } ``` CamelCase is also supported: ```json theme={"system"} { "minTotalSpent": 100, "maxTotalSpent": 500 } ``` ## Order count Order count filters use the customer profile `order_count` field. ```json theme={"system"} { "min_order_count": 2 } ``` ```json theme={"system"} { "max_order_count": 10 } ``` CamelCase is also supported: ```json theme={"system"} { "minOrderCount": 2, "maxOrderCount": 10 } ``` ## Country Country filters use ISO 3166-1 alpha-2 country codes from the customer profile. ```json theme={"system"} { "country": "DE" } ``` Multiple countries: ```json theme={"system"} { "country": ["DE", "AT", "CH"] } ``` `country_code` is also supported: ```json theme={"system"} { "country_code": "US" } ``` `country_code` can also be an array. ## Tags Match one tag: ```json theme={"system"} { "tags": { "contains": "vip" } } ``` Match any tag: ```json theme={"system"} { "tags": { "any": ["vip", "wholesale"] } } ``` Require all tags: ```json theme={"system"} { "tags": { "all": ["vip", "repeat-buyer"] } } ``` ## Consent basis Segments can narrow contacts to a specific marketing consent basis. Campaign sends only include eligible subscribed contacts. ```json theme={"system"} { "consent_basis": "SOFT_OPT_IN" } ``` ```json theme={"system"} { "consent_basis": "CONSENT" } ``` `consent` is also supported: ```json theme={"system"} { "consent": "CONSENT" } ``` Both `consent_basis` and `consent` can also be arrays: ```json theme={"system"} { "consent_basis": ["CONSENT", "SOFT_OPT_IN"] } ``` ## Source Use source when you need contacts created through a specific path. ```json theme={"system"} { "source": "backfill:paid_invoice" } ``` Multiple sources: ```json theme={"system"} { "source": ["customer_portal", "backfill:paid_invoice"] } ``` Examples of common sources include `customer_portal`, `dev-api`, `manual`, `backfill:paid_invoice`, and automation sources like `automation:post_purchase`. ## Last activity Last activity filters use the email contact `updated_at` timestamp. ```json theme={"system"} { "last_activity": { "within_days": 30 } } ``` CamelCase is also supported: ```json theme={"system"} { "lastActivity": { "withinDays": 30 } } ``` ## Combined examples Customers from Germany who bought a product and spent at least 100: ```json theme={"system"} { "country": "DE", "product_id": "PRODUCT_ID_OR_UNIQID", "min_total_spent": 100 } ``` VIP customers in DACH countries who ordered at least twice: ```json theme={"system"} { "country": ["DE", "AT", "CH"], "tags": { "contains": "vip" }, "min_order_count": 2 } ``` Recent paid-invoice contacts for one product: ```json theme={"system"} { "source": "backfill:paid_invoice", "product_id": "PRODUCT_ID_OR_UNIQID", "last_activity": { "within_days": 30 } } ``` ## Data source notes * Product filters use paid invoice line items. * Spending, order count, and country filters use customer profile fields. * Tags, consent basis, source, and last activity use email audience contact fields. * Contacts must still be subscribed, send-eligible, and linked to a known customer or paid buyer. # Checkout Embed SDK Source: https://docs.shoppex.io/developers/embeds/overview Add Shoppex checkout as a modal on your existing website, with copy-paste framework snippets The Checkout Embed SDK opens Shoppex checkout in a modal, directly on your site, with a script tag and no build step. ## What this is (and what it is not) | Use case | Best choice | | ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------- | | Open checkout in a modal from any page | **Checkout Embed SDK** (`window.Shoppex`) — **Standard and Business** | | Build a full storefront UI (products/cart/state) | [**Storefront SDK**](/developers/storefront-sdk/overview) (`@shoppexio/storefront`) | | Build your own checkout UI with Shoppex handling payment and fulfillment | [**Headless Checkout SDK**](/developers/headless/checkout) (`@shoppexio/checkout-js/headless`) — **Business** | ## Quick start ```html theme={"system"} ``` **Option A: data attributes (fastest)** ```html theme={"system"} ``` **Option A2: group button** ```html theme={"system"} ``` **Option B: JavaScript API (more control)** ```html theme={"system"} ``` ## Common events ```javascript theme={"system"} document.addEventListener('shoppex:success', (event) => { console.log('Paid invoice:', event.detail.invoiceId); }); document.addEventListener('shoppex:error', (event) => { console.error('Checkout error:', event.detail.error); }); ``` ## Current behavior to know * Group embeds work through `data-shoppex-group-id` or `Shoppex.open({ groupId })`. * Group embeds show a product picker first, then continue into the normal product checkout. * The API accepts multiple `items`, but the current modal flow uses the **first valid item**. * Quantity is normalized to `1..999`. * Invalid or empty `items` do not open checkout, unless a valid `groupId` is provided. ## If you already have your own product cards This setup causes the most confusion in real projects, so here is the simple rule. * Use the **Checkout Embed SDK** when you only need Shoppex to open checkout. * Use a **product data source** when you also render your own product list, prices, images, stock, or categories. In plain terms: * `window.Shoppex` handles the modal checkout. * If you want to build custom cards around it, your app still needs product data from somewhere. Common setups: | What you are building | What you need | | ----------------------------------------------- | ---------------------------------------- | | One buy button on an existing page | Embed SDK only | | Custom landing page with your own product cards | Embed SDK + product data source | | Full storefront with cart/state/search | Storefront SDK (`@shoppexio/storefront`) | Typical product data sources: * Shoppex Storefront API * Storefront SDK (`@shoppexio/storefront`) * Your own backend that already proxies Shoppex product data Important: * `data-shoppex-product-id` is part of the public Embed SDK integration. * `data-shoppex-group-id` is also part of the public Embed SDK integration. * Local helpers, TypeScript types, or `lib/shoppex.ts`-style files are **not** required by Shoppex itself. * Those files are app-level code you can add to keep your own project organized. ```tsx theme={"system"} ``` Here, your app owns the `ProductCard`, and Shoppex owns the checkout modal. ## Framework snippets Replace all placeholder values: `PRODUCT_ID`, `GROUP_ID`, `VARIANT_ID`, and `https://your-site.com/thank-you`. ### Next.js ```tsx theme={"system"} 'use client'; import Script from 'next/script'; declare global { interface Window { Shoppex?: { open: (options: { shopId?: string; groupId?: string; items: Array<{ productId: string; variantId?: string; quantity?: number }>; theme?: 'light' | 'dark' | 'auto'; returnUrl?: string; email?: string; couponCode?: string; affiliateCode?: string; metadata?: Record; }) => void; }; } } export default function BuyButton() { const openCheckout = () => { window.Shoppex?.open({ shopId: 'YOUR_SHOP', items: [{ productId: 'PRODUCT_ID', variantId: 'VARIANT_ID', quantity: 1 }], theme: 'auto', returnUrl: 'https://your-site.com/thank-you', affiliateCode: 'CREATOR123', metadata: { source: 'landing' }, }); }; return ( <> ``` If your theme strips script tags, add the script globally, for example in footer settings or the theme template, and keep only the button markup in content. ### Webflow Add an Embed element and paste this. ```html theme={"system"} ``` If you already load the script site-wide in Webflow project settings, keep only the button in each Embed block. ### Bonus: programmatic multi-item button ```html theme={"system"} ``` The current modal flow uses the first valid item when multiple items are passed. ### Group embed button ```html theme={"system"} ``` Use this when you want buyers to choose one product from a Shoppex group inside the modal. The group view opens first, and when the buyer picks a product, Shoppex continues into the normal product checkout flow. ### Hybrid: your own product cards + Shoppex checkout Use this when you already render your own catalog UI and only want Shoppex for the checkout modal. Fetch product data however you want, render your own cards, and put `data-shoppex-product-id` on the CTA button. Shoppex handles the rest. ```tsx theme={"system"} 'use client'; import Script from 'next/script'; type Product = { id: string; title: string; price: string; imageUrl?: string; }; export function ProductGrid({ products }: { products: Product[] }) { return ( <> ``` Full config, data attributes, API methods, event contracts, CSP, and the production checklist. Interactive demo with real embed patterns. # Embed SDK reference Source: https://docs.shoppex.io/developers/embeds/reference Complete reference for the Shoppex Checkout Embed SDK, plus CSP, origin security, and the production checklist This page documents the current behavior of the Checkout Embed SDK (`window.Shoppex`): the full API, data attributes, events, security model, and troubleshooting. ## Script and integration modes ```html theme={"system"} ``` Global object: ```javascript theme={"system"} window.Shoppex ``` The SDK auto-binds clickable elements matching: * `[data-shoppex-product-id]` * `[data-shoppex-group-id]` * `[data-shoppex-checkout]` Simple button: ```html theme={"system"} ``` Multiple items with JSON: ```html theme={"system"} ``` Group button: ```html theme={"system"} ``` ```javascript theme={"system"} Shoppex.open({ shopId: 'YOUR_SHOP', items: [ { productId: 'PRODUCT_ID', variantId: 'VARIANT_ID', quantity: 1 } ], theme: 'auto', email: 'customer@example.com', couponCode: 'SAVE20', affiliateCode: 'CREATOR123', returnUrl: 'https://your-site.com/thank-you', metadata: { campaign: 'launch' } }); ``` ## Data attributes | Attribute | Required | Description | | ----------------------------- | -------- | ---------------------------------------- | | `data-shoppex-product-id` | Yes\* | Product identifier | | `data-shoppex-group-id` | Yes\*\* | Group identifier | | `data-shoppex-variant-id` | No | Variant identifier | | `data-shoppex-quantity` | No | Quantity (`1..999`, default `1`) | | `data-shoppex-theme` | No | `light`, `dark`, `auto` (default `auto`) | | `data-shoppex-return-url` | No | Return URL after checkout | | `data-shoppex-email` | No | Prefill customer email | | `data-shoppex-coupon-code` | No | Prefill coupon code | | `data-shoppex-affiliate-code` | No | Affiliate code | | `data-shoppex-referral-code` | No | Referral code alias | | `data-shoppex-metadata` | No | JSON object with string values | | `data-shoppex-items` | No | JSON array of checkout items | | `data-shoppex-shop-id` | No | Optional field in current API payload | | `data-shoppex-checkout` | No | Marker selector for custom triggers | \* Required unless `data-shoppex-items` contains at least one valid item. \*\* Required when you want to open a group instead of a product checkout. `data-shoppex-metadata` example: ```html theme={"system"} ``` ## JavaScript API ### `Shoppex.init(config?)` Initializes bindings and keyboard handlers. ```javascript theme={"system"} Shoppex.init({ nonce: 'YOUR_CSP_NONCE' }); ``` Notes: * This call is usually optional because the script initializes automatically. * It is safe to call multiple times. The first call wins. ### `Shoppex.open(options)` Opens modal checkout. ```typescript theme={"system"} type CheckoutOptions = { shopId?: string; groupId?: string; items: Array<{ productId: string; variantId?: string; quantity?: number; }>; theme?: 'light' | 'dark' | 'auto'; returnUrl?: string; email?: string; couponCode?: string; affiliateCode?: string; metadata?: Record; }; ``` Important behavior: * `items` must contain at least one valid `productId`, unless `groupId` is provided. * `groupId` opens a group picker first, then forwards the buyer into the normal product checkout page. * If more than one item is passed, the current modal flow uses the first valid item. * Calling `open()` while a modal is open closes the old modal first. ### `Shoppex.close()` Closes the modal programmatically. ```javascript theme={"system"} Shoppex.close(); ``` ## Events Listen on `document`. ```javascript theme={"system"} document.addEventListener('shoppex:success', (event) => { console.log(event.detail.invoiceId); }); document.addEventListener('shoppex:error', (event) => { console.error(event.detail.error); }); ``` Event payloads: | Event | Detail | | ----------------- | ------------------------ | | `shoppex:success` | `{ invoiceId?: string }` | | `shoppex:error` | `{ error?: string }` | Internal iframe events used by the SDK: * `shoppex:ready` * `shoppex:resize` * `shoppex:close` **Practical example** ```html theme={"system"} ``` ## URL mapping and runtime behavior The SDK resolves checkout iframe URLs to: ```text theme={"system"} https://checkout.shoppex.io/e/:productId https://checkout.shoppex.io/g/:groupId ``` Supported query params sent by the SDK: * `quantity` * `variantId` * `theme` (only when not `auto`) * `flow` * `shopId` * `returnUrl` * `email` * `couponCode` * `affiliateCode` * `metadata[key]=value` Runtime behavior: * The modal uses Shadow DOM (`mode: closed`) for style isolation. * `Escape`, a backdrop click, and the close button all close the modal. * Dynamically inserted buttons are auto-bound through a MutationObserver. * Message handling accepts only trusted checkout origins. ## Security The SDK protects modal message handling by validating the iframe origin before it processes events. It accepts messages only from trusted origins, so a forged `postMessage` event from a random origin is ignored. Trusted origins: * `https://checkout.shoppex.io` * the local development origin for the checkout app ### CSP setup If you enforce a Content Security Policy and omit the required `frame-src` and `script-src` rules, the checkout iframe is blocked entirely. Customers see a blank modal or a browser console error instead of the payment form. Always test your CSP in production, not only locally. If you run a strict CSP, allow the checkout iframe and script, and pass a nonce to `Shoppex.init`. Example policy: ```http theme={"system"} Content-Security-Policy: frame-src https://checkout.shoppex.io; script-src 'self' 'nonce-YOUR_NONCE' https://checkout.shoppex.io; style-src 'self' 'nonce-YOUR_NONCE'; ``` SDK init with nonce: ```javascript theme={"system"} Shoppex.init({ nonce: 'YOUR_NONCE' }); ``` Why this matters: * The SDK injects styles into its shadow root. * The `nonce` lets those injected styles run under a strict CSP. ### Return URL safety Never pass unvalidated user input as `returnUrl`. An attacker can exploit this as an open redirect, sending customers to a phishing page after checkout completes. Always use a hardcoded or server-validated URL. Use a trusted application URL for `returnUrl`. ```javascript theme={"system"} Shoppex.open({ items: [{ productId: 'PRODUCT_ID' }], returnUrl: 'https://app.example.com/checkout/success' }); ``` Avoid sending unvalidated user input directly as `returnUrl`. ## Production checklist The embed modal runs inside a cross-origin iframe. Most browsers block mixed content, so your host page must be served over HTTPS. Always load from `https://checkout.shoppex.io/embed/embed.iife.js`. Never self-host or proxy the script, because it must match the iframe origin. Listen for `shoppex:success` and `shoppex:error` events so your app knows when a payment completed or failed. Without these, your UI has no feedback loop. Fire your analytics conversion event inside the `shoppex:success` handler. This is the only reliable moment to attribute a sale to your funnel. Double-check that `data-shoppex-product-id` and `data-shoppex-variant-id` values match real products. Invalid IDs silently fail to open the modal. The modal adapts to `prefers-color-scheme` and small screens. Test both to catch layout issues before your customers do. Your local dev server likely has no CSP. Test your Content Security Policy on the real production domain, because CSP violations only surface there. ## Troubleshooting ### Modal does not open Checklist: * The script is loaded. * The element has a valid `data-shoppex-product-id`, `data-shoppex-group-id`, or `data-shoppex-items`. * `items` contains at least one non-empty `productId`, unless you pass a valid `groupId`. * No JavaScript error runs before the click handler. ### Wrong product opens Cause: multiple `items` currently resolve to the first valid item in the modal flow. Fix: pass one item per checkout open until multi-item modal support lands. ### Event listeners do not fire Checklist: * Listen on `document`, not only on the button element. * Make sure that checkout completed successfully for `shoppex:success`. * Verify that browser extensions or policies do not block cross-origin frames. ### Modal appears behind custom UI Use low-risk defaults: * Avoid custom overlays with extreme z-index values. * Test with your cookie or privacy banners and support widgets. ### Dynamic content buttons do not open checkout The SDK auto-observes DOM changes, but make sure that: * New nodes actually include `data-shoppex-product-id`, `data-shoppex-group-id`, or `data-shoppex-checkout`. * Your frontend does not stop propagation on click before the SDK handler runs. ### Debug helpers Simple runtime checks: ```javascript theme={"system"} console.log('Shoppex available:', typeof window.Shoppex !== 'undefined'); ``` ```javascript theme={"system"} document.addEventListener('shoppex:error', (event) => { console.error('Embed error:', event.detail.error); }); ``` Next.js, React, WordPress, and Webflow snippets, plus the hybrid product-cards pattern. # Error handling Source: https://docs.shoppex.io/developers/errors Understanding and handling API errors This page is reference material. If you are troubleshooting a theme workflow, start with the workflow pages first: * [Editing with AI](/storefront/editing-with-ai) * [Visual themes](/storefront/visual-themes) Then come back here if you need the exact error shape or status-code meaning. ## Error response format All errors follow a consistent structure: ```json theme={"system"} { "error": { "code": "VALIDATION_ERROR", "message": "Invalid email address", "doc_url": "https://docs.shoppex.io/api-reference/errors", "details": [ { "field": "email", "message": "must be a valid email" } ] } } ``` | Field | Type | Description | | --------- | ------ | ---------------------------------------- | | `code` | string | Machine-readable error code | | `message` | string | Human-readable description | | `doc_url` | string | Link to relevant documentation | | `details` | array | Field-level validation errors (optional) | *** ## HTTP status codes | Code | Name | Description | | ----- | ----------------- | ------------------------------------------------- | | `200` | OK | Request succeeded | | `201` | Created | Resource created successfully | | `204` | No Content | Success with no response body, for example DELETE | | `400` | Bad Request | Domain-specific or business rule error | | `401` | Unauthorized | Missing or invalid API key | | `403` | Forbidden | Valid key but insufficient permissions | | `404` | Not Found | Resource does not exist | | `422` | Validation Error | Invalid field values | | `429` | Too Many Requests | You went over the rate limit | | `500` | Server Error | Something went wrong on our end | *** ## Error codes reference ### Authentication errors #### UNAUTHORIZED **HTTP 401.** The API key is missing or invalid. ```json theme={"system"} { "error": { "code": "UNAUTHORIZED", "message": "Invalid API key.", "doc_url": "https://docs.shoppex.io/api-reference/errors" } } ``` **Solution:** Check that your API key is correct and included in the `Authorization` header. #### FORBIDDEN (missing scope) **HTTP 403.** Your API key is valid but lacks the required scope for this endpoint. ```json theme={"system"} { "error": { "code": "FORBIDDEN", "message": "Missing required API scope.", "doc_url": "https://docs.shoppex.io/api-reference/errors" } } ``` **Solution:** Go to **Settings → API Keys**, check which scopes your key has, and add the missing one. Use `GET /me/capabilities` to inspect active scopes at runtime. #### FORBIDDEN (shop suspended) **HTTP 403.** Your shop has been suspended by Shoppex. ```json theme={"system"} { "error": { "code": "FORBIDDEN", "message": "Your shop has been suspended.", "doc_url": "https://docs.shoppex.io/api-reference/errors" } } ``` **Solution:** Reach out through the Shoppex [Discord](https://discord.gg/VjrptT6Nvx) or [Telegram](https://t.me/shoppexhq) to resolve the suspension. This is an account-level issue, not a key configuration problem. ### Validation errors #### VALIDATION\_ERROR **HTTP 422.** One or more fields failed validation. ```json theme={"system"} { "error": { "code": "VALIDATION_ERROR", "message": "Validation failed", "details": [ { "field": "email", "message": "must be a valid email" }, { "field": "price", "message": "must be a positive number" } ] } } ``` **Solution:** Check the `details` array for specific field errors. ### What each request error means Use the status code as the first signal: * `422 VALIDATION_ERROR` means the request shape or field values are invalid * `400` means the request was understood, but a business rule rejected it * `404 ROUTE_NOT_FOUND` means the method and `/dev/v1/*` path do not match a Developer API endpoint * `404` can be generic `NOT_FOUND` or resource-specific like `PRODUCT_NOT_FOUND` For example, a wrong email format on `POST /customers` returns `422 VALIDATION_ERROR`. An expired license on `POST /licenses/validate` returns `400 LICENSE_EXPIRED`. An unknown endpoint such as `GET /shops/shop_123/revenue` returns `404 ROUTE_NOT_FOUND`. A missing product on `GET /products/prod_xyz` returns `404 PRODUCT_NOT_FOUND`. ## Theme workflow troubleshooting If you are using the theme automation flow: * `401` usually means your API key is missing or invalid * `403` usually means your key is missing `themes.read` or `themes.write` * `422` usually means the request body or params are wrong * `500` usually means the server failed while validating, previewing, publishing, or reading the theme The most common mistake: `theme.inspect` fails with `403` because your key is missing `themes.read`, and `theme.apply` fails with `403` because it needs `themes.write`. A `422` usually means a bad request body or theme id. A `500` means something broke on the server. Check the error and retry. ### Resource errors #### ROUTE\_NOT\_FOUND **HTTP 404.** The HTTP method and `/dev/v1/*` path do not match a Developer API endpoint. Authentication is not attempted because no route was selected. ```json theme={"system"} { "error": { "code": "ROUTE_NOT_FOUND", "message": "No Developer API route matches GET /dev/v1/shops/shop_123/revenue.", "doc_url": "https://docs.shoppex.io/api-reference/errors" } } ``` **Solution:** Compare the method and path with the API Reference. The current shop comes from the API key, so shop-scoped endpoints do not require a shop ID unless their reference explicitly shows one. #### NOT\_FOUND **HTTP 404.** The requested resource does not exist. ```json theme={"system"} { "error": { "code": "NOT_FOUND", "message": "Requested resource not found", "doc_url": "https://docs.shoppex.io/api-reference/errors" } } ``` **Solution:** Verify the resource ID is correct. #### PRODUCT\_NOT\_FOUND **HTTP 404.** Specific product not found. ```json theme={"system"} { "error": { "code": "PRODUCT_NOT_FOUND", "message": "Product with ID 'prod_xyz' not found", "doc_url": "https://docs.shoppex.io/api-reference/errors" } } ``` #### INVOICE\_NOT\_FOUND **HTTP 404.** Specific invoice not found. ```json theme={"system"} { "error": { "code": "INVOICE_NOT_FOUND", "message": "Invoice with ID 'inv_abc' not found", "doc_url": "https://docs.shoppex.io/api-reference/errors" } } ``` #### PAYMENT\_NOT\_FOUND **HTTP 404.** Payment not found. ```json theme={"system"} { "error": { "code": "PAYMENT_NOT_FOUND", "message": "Payment with ID 'pay_xyz' not found", "doc_url": "https://docs.shoppex.io/api-reference/errors" } } ``` #### CUSTOMER\_NOT\_FOUND **HTTP 404.** Customer not found. ```json theme={"system"} { "error": { "code": "CUSTOMER_NOT_FOUND", "message": "Customer with ID 'cust_abc' not found", "doc_url": "https://docs.shoppex.io/api-reference/errors" } } ``` #### CATEGORY\_NOT\_FOUND **HTTP 404.** Category not found. ```json theme={"system"} { "error": { "code": "CATEGORY_NOT_FOUND", "message": "Category with ID 'cat_xyz' not found", "doc_url": "https://docs.shoppex.io/api-reference/errors" } } ``` #### COUPON\_NOT\_FOUND **HTTP 404.** Coupon not found. ```json theme={"system"} { "error": { "code": "COUPON_NOT_FOUND", "message": "Coupon with ID 'coup_abc' not found", "doc_url": "https://docs.shoppex.io/api-reference/errors" } } ``` #### SUBSCRIPTION\_NOT\_FOUND **HTTP 404.** Subscription not found. ```json theme={"system"} { "error": { "code": "SUBSCRIPTION_NOT_FOUND", "message": "Subscription with ID 'sub_xyz' not found", "doc_url": "https://docs.shoppex.io/api-reference/errors" } } ``` #### TICKET\_NOT\_FOUND **HTTP 404.** Support ticket not found. ```json theme={"system"} { "error": { "code": "TICKET_NOT_FOUND", "message": "Ticket with ID 'tkt_abc' not found", "doc_url": "https://docs.shoppex.io/api-reference/errors" } } ``` #### REVIEW\_NOT\_FOUND **HTTP 404.** Review not found. ```json theme={"system"} { "error": { "code": "REVIEW_NOT_FOUND", "message": "Review with ID 'rev_xyz' not found", "doc_url": "https://docs.shoppex.io/api-reference/errors" } } ``` #### ESCROW\_NOT\_FOUND **HTTP 404.** Escrow transaction not found. ```json theme={"system"} { "error": { "code": "ESCROW_NOT_FOUND", "message": "Escrow with ID 'esc_abc' not found", "doc_url": "https://docs.shoppex.io/api-reference/errors" } } ``` ### License errors #### LICENSE\_NOT\_FOUND **HTTP 404.** License key not found. ```json theme={"system"} { "error": { "code": "LICENSE_NOT_FOUND", "message": "License key not found", "doc_url": "https://docs.shoppex.io/api-reference/errors" } } ``` #### LICENSE\_INVALID **HTTP 400.** License key is invalid or malformed. ```json theme={"system"} { "error": { "code": "LICENSE_INVALID", "message": "Invalid license key format", "doc_url": "https://docs.shoppex.io/api-reference/errors" } } ``` #### LICENSE\_EXPIRED **HTTP 400.** License key has expired. ```json theme={"system"} { "error": { "code": "LICENSE_EXPIRED", "message": "License key has expired", "doc_url": "https://docs.shoppex.io/api-reference/errors" } } ``` #### LICENSE\_HWID\_MISMATCH **HTTP 400.** Hardware ID does not match the registered device. ```json theme={"system"} { "error": { "code": "LICENSE_HWID_MISMATCH", "message": "Hardware ID mismatch. License is bound to a different device.", "doc_url": "https://docs.shoppex.io/api-reference/errors" } } ``` ### Coupon errors #### COUPON\_EXPIRED **HTTP 400.** Coupon has expired. ```json theme={"system"} { "error": { "code": "COUPON_EXPIRED", "message": "This coupon has expired", "doc_url": "https://docs.shoppex.io/api-reference/errors" } } ``` #### COUPON\_MAX\_USES\_REACHED **HTTP 400.** Coupon has reached maximum uses. ```json theme={"system"} { "error": { "code": "COUPON_MAX_USES_REACHED", "message": "This coupon has reached its maximum number of uses", "doc_url": "https://docs.shoppex.io/api-reference/errors" } } ``` ### Idempotency errors #### IDEMPOTENCY\_CONFLICT **HTTP 422.** The same idempotency key was used with a different request body. ```json theme={"system"} { "error": { "code": "IDEMPOTENCY_CONFLICT", "message": "Idempotency key already used with a different request body", "doc_url": "https://docs.shoppex.io/api-reference/errors" } } ``` **Solution:** Each unique request body needs its own idempotency key. If you are retrying a failed request, make sure the body matches the original. See the [Idempotency section](/developers/api-overview#idempotency) for details. ### Rate limiting #### RATE\_LIMITED **HTTP 429.** Too many requests. ```json theme={"system"} { "error": { "code": "RATE_LIMITED", "message": "Rate limit exceeded. Retry after the reset window.", "doc_url": "https://docs.shoppex.io/api-reference/errors" } } ``` Response headers: * `X-RateLimit-Limit` * `X-RateLimit-Remaining` * `X-RateLimit-Reset` * `Retry-After` when blocked Rate limits are plan-dependent, so treat any numeric values you see elsewhere as illustrative. Check the response headers from your own requests for your current limits. **Solution:** Wait for `Retry-After` or `X-RateLimit-Reset` before retrying. *** ## Handling errors in code Every Dev API error response also returns `X-Request-Id` in the headers. Log that value together with `error.code` when you need support or want to trace a failing request. ```typescript TypeScript theme={"system"} async function createPayment(title: string, email: string, value: number) { const response = await fetch('https://api.shoppex.io/dev/v1/payments', { method: 'POST', headers: { 'Authorization': `Bearer ${API_KEY}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ title, email, value, currency: 'USD' }) }); const result = await response.json(); if (!response.ok) { const error = result.error; switch (error.code) { case 'VALIDATION_ERROR': // Handle field-level errors error.details?.forEach(d => console.error(`${d.field}: ${d.message}`)); break; case 'RATE_LIMITED': // Wait and retry const retryAfter = Number(response.headers.get('retry-after') ?? '2'); console.log(`Rate limited. Waiting ${retryAfter}s before retry...`); await new Promise(r => setTimeout(r, retryAfter * 1000)); return createPayment(title, email, value); case 'UNAUTHORIZED': console.error('Invalid API key'); break; default: console.error(`Error: ${error.message}`); } throw new Error(error.message); } return result.data; } ``` ```python Python theme={"system"} import requests import time def create_payment(title: str, email: str, value: float): response = requests.post( 'https://api.shoppex.io/dev/v1/payments', headers={ 'Authorization': f'Bearer {API_KEY}', 'Content-Type': 'application/json' }, json={'title': title, 'email': email, 'value': value, 'currency': 'USD'} ) result = response.json() if not response.ok: error = result['error'] if error['code'] == 'VALIDATION_ERROR': for detail in error.get('details', []): print(f"{detail['field']}: {detail['message']}") elif error['code'] == 'RATE_LIMITED': retry_after = int(response.headers.get('retry-after', '2')) print(f"Rate limited. Waiting {retry_after}s before retry...") time.sleep(retry_after) return create_payment(title, email, value) # Retry raise Exception(error['message']) return result['data'] ``` *** ## Best practices Always log the `error.code` for debugging. It is more reliable than parsing messages. Implement exponential backoff for rate limits. Check `X-RateLimit-Reset` header. Validate inputs client-side to catch errors early and reduce API calls. For payment creation, use idempotency keys to safely retry failed requests. # Asynchronous and manual fulfillment Source: https://docs.shoppex.io/developers/fulfillment Deliver paid order line items later through a dynamic webhook, the dashboard, or the Developer API Payment and delivery are separate in Shoppex. An order can be paid with `status: "COMPLETED"` while one of its line items is still waiting for delivery. Use asynchronous fulfillment when your system needs more than 15 seconds to generate content. Use manual fulfillment when a merchant needs to prepare an account, link, code, or file after payment. Fulfillment is tracked per line item. The invoice payment status does not change when a line item moves between delivery states. ## Delivery states | State | Meaning | | ---------------------- | ---------------------------------------------------------------------------------- | | `PENDING` | The invoice is not paid yet, or automated dynamic delivery is currently in flight. | | `AWAITING_FULFILLMENT` | Shoppex expects the vendor or merchant to provide content later. | | `DELIVERED` | Delivery content was stored. `delivered_at` contains the delivery time. | | `FAILED` | Automated dynamic delivery failed. Merchant action is required. | A line item enters `AWAITING_FULFILLMENT` in either of these cases: * its dynamic delivery callback returns a clear pending acknowledgement: `{"status":"pending"}` * it belongs to a `DYNAMIC` product that has no `dynamic_webhook` URL configured The second case is the manual fulfillment lane. After payment, the merchant can deliver the line item from the dashboard or through the Developer API. ## Read fulfillment state Order read responses expose these fields on every item. The example below shows the relevant response fields: ```json theme={"system"} { "data": { "id": "order_db_123", "uniqid": "order_123", "status": "COMPLETED", "items": [ { "id": "018f6f2e-7c3a-7b21-9d4e-5a1b2c3d4e5f", "delivery_status": "AWAITING_FULFILLMENT", "delivered_at": null } ] } } ``` `id` is the line item ID used as `{item_id}` in the fulfillment endpoints. The order path accepts the same order identifier used by the existing order endpoints. ## Fulfill a line item ```http theme={"system"} POST /dev/v1/orders/{id}/items/{item_id}/fulfill ``` The order must be paid with `status: "COMPLETED"`, and the line item must not already be delivered. Provide at least one of `message`, `codes`, `links`, or `files`. ```bash cURL theme={"system"} curl https://api.shoppex.io/dev/v1/orders/order_123/items/018f6f2e-7c3a-7b21-9d4e-5a1b2c3d4e5f/fulfill \ -X POST \ -H "Authorization: Bearer shx_your_api_key" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: fulfill-order_123-018f6f2e-7c3a-7b21-9d4e-5a1b2c3d4e5f-v1" \ -d '{ "message": "Your account is ready.", "codes": ["KEY-1", "KEY-2"], "links": [ { "label": "Open your account", "url": "https://vendor.example.com/access/abc123" } ], "files": [ { "attachment_id": "018f6f2e-7cb1-7a04-92f9-1cc8d7c8f413" } ], "notify_customer": true }' ``` A successful response returns the delivered line item content: ```json theme={"system"} { "data": { "line_item_id": "018f6f2e-7c3a-7b21-9d4e-5a1b2c3d4e5f", "delivery_status": "DELIVERED", "delivered_at": "2026-08-04T09:15:00.000Z", "delivered_items": { "delivery_type": "MANUAL", "deliveryType": "MANUAL", "service_text": "Your account is ready.", "serviceText": "Your account is ready.", "license_keys": [ { "key": "KEY-1" }, { "key": "KEY-2" } ], "licenseKeys": [ { "key": "KEY-1" }, { "key": "KEY-2" } ], "external_urls": [ { "label": "Open your account", "url": "https://vendor.example.com/access/abc123" } ], "externalUrls": [ { "label": "Open your account", "url": "https://vendor.example.com/access/abc123" } ], "downloads": [ { "id": "018f6f2e-7cb1-7a04-92f9-1cc8d7c8f413", "attachmentId": "018f6f2e-7cb1-7a04-92f9-1cc8d7c8f413", "title": "setup-guide.pdf", "filename": "setup-guide.pdf", "fileName": "setup-guide.pdf", "extension": "pdf", "size": 184320 } ], "count": 5, "fulfilled_by": "API", "fulfilled_at": "2026-08-04T09:15:00.000Z" } } } ``` Each `files[].attachment_id` must refer to an existing attachment owned by the same shop. Upload attachments in the dashboard before calling this endpoint. If any attachment is missing or belongs to another shop, the entire request fails and no partial delivery is stored. ### Customer email `notify_customer` controls the buyer's "product ready" email: * Omit it, or set it to `true`, to request the customer email after the content is stored. * Set it to `false` to store the content without sending that email. The shop's normal customer-email settings and notification preferences still apply. Shoppex also sends the `order:item.delivered` event after a successful fulfillment. ### Error responses Both endpoints reject invalid requests with the standard error envelope. The `message` distinguishes the cause: | HTTP | Cause | Retryable? | | ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | --------------------------------------------------------- | | `404 NOT_FOUND` | Order or line item does not exist for this shop (also returned for malformed line-item ids) | No — check the ids from the order response | | `422 VALIDATION_ERROR` — "Order is not in a fulfillable state" | The order is not `COMPLETED` yet (unpaid, refunded, voided, or stock-held) | Yes — once the order is paid | | `422 VALIDATION_ERROR` — "already has delivered content" | The line item was already delivered (possibly by a concurrent delivery) | No | | `422 VALIDATION_ERROR` — content message | Invalid body: empty content, malformed link URL, oversized fields | No — fix the request body and use a new `Idempotency-Key` | | `422 VALIDATION_ERROR` — "Attachment … does not exist" | `files[].attachment_id` missing or owned by another shop | No — upload the attachment first | | `422 VALIDATION_ERROR` — "Only failed deliveries can be retried" | Retry called on a line item that is not `FAILED` | No | | `422 VALIDATION_ERROR` — "No failed automated delivery exists" | Retry called where no dynamic-delivery attempt failed | No — fulfill the item manually instead | ## Idempotency Both fulfillment endpoints support the standard `Idempotency-Key` header. Reuse the same key when retrying the same request after a network interruption. Shoppex scopes the key to the authenticated API key and route and replays the stored response during the 24-hour idempotency window. For example, every retry of the delivery above must keep `Idempotency-Key: fulfill-order_123-018f6f2e-7c3a-7b21-9d4e-5a1b2c3d4e5f-v1`. Rejected responses (4xx) are replayed too: after correcting a rejected request body — for example a wrong `attachment_id` — send the corrected request with a **new** `Idempotency-Key`. Reusing the old key returns either the stored error or a key-conflict error. Idempotency does not allow a line item to be delivered twice. Once `delivered_at` is set, another fulfillment attempt is rejected even if it uses a different key. ## Retry failed dynamic delivery ```http theme={"system"} POST /dev/v1/orders/{id}/items/{item_id}/retry-delivery ``` This endpoint re-queues a failed dynamic delivery and moves the line item from `FAILED` to `PENDING`. It does not take a request body. ```bash cURL theme={"system"} curl https://api.shoppex.io/dev/v1/orders/order_123/items/018f6f2e-7c3a-7b21-9d4e-5a1b2c3d4e5f/retry-delivery \ -X POST \ -H "Authorization: Bearer shx_your_api_key" \ -H "Idempotency-Key: retry-order_123-018f6f2e-7c3a-7b21-9d4e-5a1b2c3d4e5f-v1" ``` ```json theme={"system"} { "data": { "line_item_id": "018f6f2e-7c3a-7b21-9d4e-5a1b2c3d4e5f", "delivery_status": "PENDING", "retried": true } } ``` Retrying can cause a double delivery. A timeout or network error does not tell you whether the vendor already finished the delivery. If the vendor already delivered the product before Shoppex received the failure, check the vendor system. Make sure it checks `X-Shoppex-Idempotency-Key` for duplicates before you retry. Only a `FAILED` dynamic line item with a failed delivery event can be retried. If no retryable event exists, fulfill the line item manually instead. ## Fulfillment webhook events These are normal merchant webhook events. Subscribe to them in **Settings → Webhooks** or through the Developer API. They use the normal event-webhook signature, not the product's dynamic delivery signing secret. ### `order:item.delivered` Sent after late content is stored through manual or API fulfillment. ```json theme={"system"} { "event": "order:item.delivered", "data": { "invoiceId": "order_123", "lineItemId": "018f6f2e-7c3a-7b21-9d4e-5a1b2c3d4e5f", "productId": "product_db_123", "fulfilledBy": "API" }, "created_at": 1785834900 } ``` ### `order:item.delivery_failed` Sent when the single dynamic delivery attempt ends in a terminal failure. Shoppex also marks the line item `FAILED` and notifies the merchant. ```json theme={"system"} { "event": "order:item.delivery_failed", "data": { "invoiceId": "order_123", "lineItemId": "018f6f2e-7c3a-7b21-9d4e-5a1b2c3d4e5f", "productId": "product_db_123", "error": "Dynamic delivery request timed out after 15000ms" }, "created_at": 1785834000 } ``` These events report fulfillment state. They do not change or replace payment events such as `order:paid`. Implement the synchronous and asynchronous callback contract. Verify normal merchant webhook signatures and review event payloads. # Headless checkout SDK Source: https://docs.shoppex.io/developers/headless/checkout Build your own checkout UI with @shoppexio/checkout-js/headless while Shoppex handles payment sessions, webhooks, invoices, and fulfillment. The Headless Checkout SDK is for merchants who want a fully custom checkout page on their own domain. You own the layout, fields, and buyer experience. Shoppex still creates the invoice, starts the payment session, handles 3DS or redirects, receives webhooks, and fulfills the order. Use this when the hosted checkout page or modal embed is not flexible enough. Headless checkout requires an active Business plan. ## What you need * A storefront or app page on your own domain. * An active Business plan. * A headless checkout publishable key from the dashboard. * At least one allowed origin for the site that will call the browser API. * `@shoppexio/checkout-js` installed in your frontend app. ## Dashboard setup Open **Dashboard -> Settings -> Developer -> Headless Checkout**. Click **Generate key**. Copy the `pk_live_...` key immediately. The full key is shown only once. Add the exact origin that hosts your checkout page, for example `https://yourstore.com`. For local testing, add the exact local origin too, for example `http://localhost:3000`. An origin is only the scheme, host, and optional port. Do not include a path. | URL you paste | Saved origin | | ---------------------------- | ----------------------- | | `https://vyy.gg/checkout` | `https://vyy.gg` | | `https://www.vyy.gg` | `https://www.vyy.gg` | | `http://localhost:3000/cart` | `http://localhost:3000` | If your site works on both `https://vyy.gg` and `https://www.vyy.gg`, add both origins. Never put a secret `shx_...` API key in browser code. Headless checkout uses `pk_live_...` or `pk_test_...` publishable keys only. ## Install ```bash theme={"system"} npm install @shoppexio/checkout-js # or bun add @shoppexio/checkout-js ``` ## React example ```tsx theme={"system"} import { ShoppexCheckoutProvider, useCheckoutSession, useStripePaymentSession, ShoppexPoweredBy, } from '@shoppexio/checkout-js/headless/react'; function CheckoutForm() { const { session, loading, error } = useCheckoutSession({ product_id: 'PROD_123', email: 'customer@example.com', quantity: 1, }); const stripe = useStripePaymentSession(session?.id, { returnUrl: 'https://yourstore.com/thanks', }); if (loading) return

Preparing checkout...

; if (error) return

{error.message}

; if (!session) return null; return (
{ event.preventDefault(); void stripe.confirm(); }} >

{session.product?.title ?? 'Checkout'}

Total: {session.breakdown.total} {session.currency}

); } export default function Page() { return ( ); } ``` `ShoppexPoweredBy` is currently required for every Headless Checkout. The SDK checks that the badge is visible before payment confirmation. The server owns this directive; the browser cannot disable it. ## API shape The browser SDK sends the publishable key in the `x-shoppex-publishable-key` header. Shoppex also checks the request `Origin` header against your allowed origins. The SDK can: * create a checkout session * read the current session status * apply or remove coupons * set tips * select add-ons * save buyer email, marketing preference, billing address, and method-specific terms acceptance * save product custom fields and delivery instructions * start a gateway payment session * complete zero-total checkouts * request and verify Customer Balance OTPs, then apply full or partial balance payments * submit browser-bound proof for manual payment methods * capture/finalize PayPal and SumUp payments * fetch delivery after the payment is complete The session response includes line items, totals, authoritative gateway fee previews, payment-method presentation and requirements, buyer identity state, Customer Balance availability, product custom-field definitions, delivery instructions, shop branding, terms, and delivery state. Render against the gateway `kind`, not the provider name, so your UI works across card, redirect, crypto, balance, and manual payment flows. ## Buyer details and payment requirements Each entry in `session.gateways_available` contains: * `fee_preview`: the server-calculated fee for that method * `presentation`: merchant label, button label, icon, and provider-attribution preference * `requirements`: whether method terms and a billing address must be saved `presentation.hide_provider_attribution` only controls the payment provider's attribution. It does not remove the required `ShoppexPoweredBy` badge. Persist the details before starting the gateway: ```ts theme={"system"} await client.updateSession(session.id, { email: 'customer@example.com', payment_method_terms_accepted: true, billing_address: { name: 'Customer Name', line1: '1 Main Street', city: 'Berlin', country: 'DE', postal_code: '10115', }, custom_fields: { line_item_id: { discord_username: 'customer' }, }, delivery_instructions: { line_item_id: 'Send to customer on Discord', }, }); ``` ## Free and Customer Balance checkout ```ts theme={"system"} if (!session.payment_required) { await client.completeFreeCheckout(session.id); } await client.requestBalanceOtp(session.id, 'customer@example.com'); const verified = await client.verifyBalanceOtp( session.id, 'customer@example.com', '123456', ); await client.payWithBalance(session.id, verified.session_token); ``` Customer Balance uses the same wallet, policy, attempt, reconciliation, stock-hold, and completion-access services as Hosted Checkout. ## Crypto progress and underpayments Call `getSession()` or the React `refresh()` action to read the authoritative payment projection. `session.payment_detail` includes the exact decimal strings needed for a crypto progress or underpayment UI: ```ts theme={"system"} const detail = session.payment_detail; if (typeof detail?.confirmations_needed === 'number') { console.log(`${detail.confirmations ?? 0}/${detail.confirmations_needed}`); } if (detail?.remaining && detail.buyer_actionable !== false) { console.log(`Send ${detail.remaining} ${detail.crypto_currency ?? 'crypto'}`); } if (detail?.buyer_actionable === false) { console.log(detail.buyer_action_reason); } if (detail?.received && detail.received_fiat_estimate) { console.log( `Received on-chain: ${detail.received} ${detail.crypto_currency} (~${detail.received_fiat_estimate} ${detail.fiat_currency})`, ); console.log(`Credited by provider: ${detail.credited_fiat ?? '0'} ${detail.fiat_currency}`); } ``` The detail contains `expected`, `received`, `remaining`, `crypto_currency`, `buyer_actionable`, `buyer_action_reason`, `provider_status`, `credited_fiat`, `received_fiat_estimate`, `fiat_currency`, `confirmations`, and `confirmations_needed` when those values are available. `credited_fiat` is the amount the provider applied to the invoice. `received_fiat_estimate` is only a display estimate for crypto observed on-chain and must never be counted as paid. Keep the amount values as strings. Do not recalculate the remainder with JavaScript floating-point numbers. ## Embed provider adapters Stripe uses `useStripePaymentSession`. The framework-agnostic Headless export also includes: ```ts theme={"system"} import { mountSquareCard, mountNmiCardFields, mountSumupCard, mountPaypalButtons, } from '@shoppexio/checkout-js/headless'; ``` These helpers mount provider-owned fields into your element and call the same Shoppex session/finalize endpoints as Hosted Checkout. Square and NMI perform their tokenized second session call; SumUp finalizes after the widget succeeds; PayPal captures the approved order. NMI includes Kount and 3D Secure when the merchant gateway requires them. ## Manual payment proof When a manual payment session has `require_proof: true`, use `proof_type` to render a note input, image input, or both. Then submit the proof with the completion grant returned by the start call: ```ts theme={"system"} await client.submitManualProof(session.id, start.completion_access_grant, { customer_note: 'Bank transfer sent', image: proofFile, }); ``` The SDK registers completion access before uploading. A publishable key alone cannot submit proof for another buyer's invoice. ## Troubleshooting ### Generate key fails Check these first: * The shop has an active Business plan. * You are using the shop owner account or a team member with permission to manage webhooks/developer settings. * Your dashboard session is fresh. Refresh the page or log out and back in. If it still fails, open browser DevTools -> Network and inspect: ```text theme={"system"} POST /v1/dashboard/checkout/headless/rotate-key ``` Common responses: | Status | Meaning | | ---------------------------- | ---------------------------------------------------------------------------- | | `403 BUSINESS_PLAN_REQUIRED` | The shop is not on an active Business plan. | | `401` | The dashboard session or shop permission is invalid. | | `500` | Backend or database issue. Contact support with the request id if available. | ### Add origin fails Use a full `http://` or `https://` origin. These are valid: ```text theme={"system"} https://vyy.gg https://www.vyy.gg http://localhost:3000 ``` These are not valid saved origins: ```text theme={"system"} vyy.gg https://vyy.gg/checkout ftp://vyy.gg ``` The dashboard normalizes paths away before saving, but the backend still rejects non-http origins and malformed values. If the UI only shows a generic error, inspect this request: ```text theme={"system"} PUT /v1/dashboard/checkout/headless/origins ``` ### Browser API calls fail Check the browser response code: | Status | Meaning | | ---------------------------- | -------------------------------------------------------------------------------------------------- | | `401` | Missing, malformed, or unknown publishable key. Make sure it starts with `pk_live_` or `pk_test_`. | | `403 ORIGIN_NOT_ALLOWED` | The calling site's origin is not in the allowed origins list. | | `403 BUSINESS_PLAN_REQUIRED` | Headless checkout is not enabled for the shop's current plan. | ## Related docs Pick the right integration shape for custom storefronts, apps, and backend flows. Use the hosted modal when you do not need a fully custom checkout UI. Read public product, cart, and storefront data from browser code. Fulfill orders and update your app after Shoppex receives payment events. # Headless commerce with Shoppex Source: https://docs.shoppex.io/developers/headless/overview Run Shoppex as the commerce engine behind any frontend — your UI, our orders, payments, invoices, licenses, subscriptions, and fulfillment. Shoppex works as a **commerce engine behind your own frontend**. You own the UI, routing, and brand experience. Shoppex owns the catalog, payments, invoices, subscriptions, license delivery, and fulfillment. You do not need to use a Shoppex theme or a Shoppex storefront domain as the customer-facing surface. Everything here is reachable from a plain Next.js, Nuxt, SvelteKit, Astro, Remix, React Native, or native mobile app. ## When to go headless * **You already have a brand site** — you run a marketing site, landing pages, or an app. You want checkout, invoices, and licenses without redesigning around a theme. * **You want custom UX** — your storefront is part of a larger product. Examples include a Discord bot UI, a SaaS paywall, a mobile app, or an internal admin tool. A hosted shop does not fit. * **You want API-first automation** — you generate catalog entries, licenses, or orders programmatically and want the frontend to stay thin. * **You ship to native clients** — you need commerce inside iOS, Android, or Electron apps where a hosted web checkout is not ideal. ## What you keep, what Shoppex runs | You keep | Shoppex runs | | --------------------------------- | -------------------------------------------- | | UI, routing, layout, auth surface | Catalog, pricing, invoices, orders | | Brand domain + SEO | Payment session creation across PSPs | | Customer session / login form | PSP handling + 3DS + crypto | | Product pages and cart UI | License keys, serial pools, dynamic delivery | | Email + notification style | Subscriptions, trials, renewals, dunning | | Anything you want to own | Webhooks, fraud shield, blacklist, disputes | ## Pick your integration shape Server-to-server. Bearer API keys or OAuth2. Full control over catalog, orders, customers, licenses, subscriptions, webhooks. Browser-side reads plus hosted-checkout redirect. Good fit for SPAs and SSR storefronts. Drop-in modal checkout on any existing site with one script tag. Build your own checkout UI with a publishable key while Shoppex handles payment sessions. ### Decision tree Use the **Dev API** with `@shoppexio/sdk` (Node, Python, PHP) or plain HTTP. Use the **Storefront SDK** (`@shoppexio/storefront`) for public read endpoints and hosted-checkout redirect. Use the **Checkout Embed SDK** — drop one script tag, add `data-shoppex-*` attributes, done. Use the **Headless Checkout SDK** (`@shoppexio/checkout-js/headless`) with a dashboard-generated publishable key and allowed origins. That is the normal case. Dev API for server workflows, Storefront SDK for browser reads, Embed SDK for buy buttons. If you are unsure which to pick, start with the [Next.js quickstart](/developers/headless/quickstart-nextjs). It builds a custom storefront in Next.js 16 in under 15 minutes. For a working foundation instead of building from scratch, use the [Storefront Starter](/developers/headless/storefront-starter). ## Reference setups Three shapes cover almost every headless build. Every one of them uses the same three pipes: reads through the Storefront SDK or Dev API, checkout through a hosted session or the Embed SDK, and signed webhooks back to your server. ### Setup A — pure SPA (React / Vue / Svelte / Astro Islands) Best when your frontend is static or client-rendered and your only backend is your webhook receiver. | Piece | Responsibility | | -------------- | ------------------------------------------------------------------------------- | | Browser SPA | UI, product grid, cart UI, buy button with `@shoppexio/storefront` or Embed SDK | | Webhook worker | Verify `X-Shoppex-Signature-V2`, run fulfillment, save order state | | Shoppex | Catalog, PSP handling, invoice, license delivery, webhook delivery | Pick this for landing pages, marketing sites, and lightweight storefronts where you do not want to run a full origin server. Do not ship `shx_*` API keys in the SPA bundle. Server-side reads need a small backend, either a worker or a Route Handler. If you need to transform data before it reaches the browser, use Setup B. ### Setup B — Next.js SSR (or Nuxt / SvelteKit / Remix) Best when you want SEO, per-request personalization, and a single deployable unit. | Piece | Responsibility | | ----------------- | ----------------------------------------------------------------- | | Server Components | Catalog reads, personalization, pricing overrides, session lookup | | Server Actions | Create payment session, return redirect URL | | Route Handlers | Verify and handle signed webhooks | | Shoppex | Everything payment, invoice, license, subscription | Pick this for any storefront where SEO matters, where you have a login, or where you serve per-customer content. The [Next.js quickstart](/developers/headless/quickstart-nextjs) builds exactly this shape. Server Actions and Route Handlers must be the only place you touch `SHOPPEX_API_KEY`. Wrap your SDK client in `import 'server-only'`. Next.js 16 request APIs are async, so use `await cookies()`, `await headers()`, `await params`, and `await searchParams`. Use constant-time signature comparison (`timingSafeEqual`) in your webhook route. ### Setup C — mobile and backend-for-frontend Best for native iOS, Android, React Native, or Electron apps, and for any client that must not hold long-lived API keys. | Piece | Responsibility | | ---------- | ---------------------------------------------------------------------------------------- | | Native app | Your UI, your auth, deep-link handler for the return URL | | BFF | Exchange user session for scoped Shoppex calls, create payment sessions, run fulfillment | | Shoppex | Everything commerce | The app sends its own session token to your BFF, the BFF creates the payment with the Dev API, and the app opens the hosted checkout URL in the system browser. Shoppex redirects the buyer back to your app and sends the signed webhook to your BFF, so your backend holds the final source of truth. Use a universal link or app link as `return_url` so the operating system hands control back to your app cleanly. Never bundle the `shx_*` key in the app binary — always route through your BFF. For Apple App Store apps selling digital goods consumed in-app, App Store review rules can require In-App Purchase instead of a web checkout. Check the current rules before you ship. ### Data flow per checkout stage | Stage | Who acts | Event | | -------------------------------- | ----------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | | Browse | Your frontend reads from Shoppex (Storefront SDK or Dev API proxy) | — | | Add to cart | Your frontend (local state) | — | | Checkout start (ad-hoc amount) | Your server calls `POST /dev/v1/payments` | — | | Checkout start (catalog product) | Your server calls `POST /dev/v1/orders` with `items[].product_id` | — | | Payment UI | Shoppex hosted checkout / Embed SDK | — | | Payment success | Shoppex sends webhooks | `order:paid`, and `subscription:created` / `subscription:renewed` when the product is a subscription | | Fulfillment | Your webhook handler grants access, sends license, sets up subscription | — | | Return | Customer hits your `return_url` | — | Server-side cart state is not required for any of these setups today. Carts live in your frontend (local state, cookie, or your own database) until you create the payment session. ## Authentication | Surface | Token type | Where it lives | | -------------------------- | ----------------------------------------------------- | -------------------------------------------- | | Dev API (server-to-server) | API key `shx_*` or OAuth2 access token | Your server env / secret manager | | Storefront SDK (browser) | None for public reads — shop slug identifies the shop | Browser bundle | | Checkout Embed SDK | None — shop id + product id in markup | Browser bundle | | Customer portal OTP | Email + one-time code verified server-side by Shoppex | Your login UI calls the public OTP endpoints | Which side holds the key depends on the setup. In Setup A it is the worker or small backend, in Setup B the Server Components and Route Handlers, and in Setup C the BFF only. Customer OTP (`/v1/customer/auth/otp/request` and `/verify`) works as a drop-in auth for Setup A. Setup B and C usually keep their own auth and use Shoppex for commerce only. For headless frontends, pass an optional `redirect_url` on `/v1/customer/auth/otp/request`. The URL is validated against the shop's allowed callback hosts (`.myshoppex.io` or any enabled custom domain) and returned on a successful `/verify` response. Invalid or disallowed hosts are rejected with `400`. Never ship `shx_*` API keys to the browser. They authenticate as your shop and can read or mutate anything in scope. Keep them on the server. Package names are intentionally separate: use `@shoppexio/storefront` in customer-facing browser and headless code, and use `@shoppexio/sdk` only in trusted backend code that talks to the Developer API. See [Authentication](/developers/authentication) for API keys, OAuth2 authorization code flow, scopes, and key rotation. ## Webhooks and idempotency Your backend receives signed events after state changes: * `order:created`, `order:paid`, `order:cancelled`, `order:disputed` * `product:created`, `product:edited`, `product:stock`, `product:dynamic` * `subscription:created`, `subscription:renewed`, `subscription:cancelled`, `subscription:trial:started`, `subscription:trial:ended`, `subscription:upcoming` * `query:created`, `query:replied`, `feedback:received`, `affiliate:payout_requested` Order and subscription events also have `:product` variants (for example `order:paid:product`) when you need full product payloads. For paid purchases that reduce stock, handle `order:paid:product`. Use `product:stock` for direct catalog stock edits. New handlers must verify the timestamped `X-Shoppex-Signature-V2` header. All three setups share the same reliability rules: * Send `Idempotency-Key` on every mutating Dev API call. A UUID v4 is fine. Shoppex stores the response for 24 hours and replays it on retry. * Make webhook handlers idempotent. Shoppex retries on non-2xx. Use the event's `data.uniqid` and `event` tuple as your deduplication key. * Acknowledge webhooks fast. Queue heavy fulfillment work and return 2xx within a few seconds. See [Webhooks](/developers/webhooks) for the event catalog, signing, retries, and the dynamic delivery contract for `DYNAMIC` products. # Next.js quickstart Source: https://docs.shoppex.io/developers/headless/quickstart-nextjs Build a custom storefront in Next.js 16 with Shoppex as the commerce engine — products, checkout, and webhook fulfillment in under 15 minutes. Build a custom storefront on Next.js 16 (App Router) that: 1. Lists products from your Shoppex catalog on the server. 2. Starts a checkout session from a Server Action. 3. Redirects the customer to Shoppex hosted checkout. 4. Receives a signed webhook when the order is paid and runs your own fulfillment. Time budget: **15 minutes**. You need a Shoppex shop and an API key (`shx_*`). If you do not have an API key yet, open **Dashboard → Settings → Developer API**. Click **Generate New API Key**, then pick scopes `products.read payments.write webhooks.read`. Copy the key — it is shown once. *** ## 1. Set up the project ```bash theme={"system"} npx create-next-app@latest my-shop --typescript --app --tailwind cd my-shop npm install @shoppexio/sdk ``` Add your API key to `.env.local`: ```bash theme={"system"} SHOPPEX_API_KEY=shx_your_api_key SHOPPEX_WEBHOOK_SECRET=whsec_your_webhook_secret ``` `SHOPPEX_API_KEY` must only exist on the server. Never prefix it with `NEXT_PUBLIC_`. Create a tiny server-only client wrapper at `lib/shoppex.ts`: ```typescript theme={"system"} import { ShoppexClient } from '@shoppexio/sdk'; import 'server-only'; export const shoppex = new ShoppexClient({ apiKey: process.env.SHOPPEX_API_KEY!, }); ``` *** ## 2. List products on the server `app/products/page.tsx` reads the catalog on every request. Because this is a Server Component, your API key never leaves the server. ```tsx theme={"system"} import Link from 'next/link'; import { shoppex } from '@/lib/shoppex'; type ProductListItem = { id: string; uniqid: string; title: string; price: number; currency: string; }; export default async function ProductsPage() { const { data: products } = await shoppex.products.list({ limit: 25 }); return (

Shop

    {products.map((product) => (
  • {product.title}

    {product.price} {product.currency}

    View →
  • ))}
); } ``` *** ## 3. Start checkout from a Server Action A product page with a Server Action that creates a payment and redirects to hosted checkout. The API key stays on the server. The browser only ever sees the resulting `checkout_url`. `app/products/[id]/page.tsx`: ```tsx theme={"system"} import { redirect } from 'next/navigation'; import { shoppex } from '@/lib/shoppex'; type ProductDetail = { id: string; uniqid: string; title: string; price: number; currency: string; }; export default async function ProductPage({ params, }: { params: Promise<{ id: string }>; }) { const { id } = await params; const { data: product } = await shoppex.products.get(id); async function startCheckout(formData: FormData) { 'use server'; const email = formData.get('email') as string; const payment = await shoppex.raw.POST<{ data: { url: string; checkout_url?: string } }>( '/dev/v1/payments', { body: { title: product.title, email, value: product.price, currency: product.currency, return_url: `${process.env.NEXT_PUBLIC_SITE_URL}/orders/thanks`, cancel_url: `${process.env.NEXT_PUBLIC_SITE_URL}/products/${product.id}`, }, } ); redirect(payment.data.checkout_url ?? payment.data.url); } return (

{product.title}

{product.price} {product.currency}

); } ``` `return_url` is where Shoppex sends the customer after a successful payment. `cancel_url` is where they go if they abandon checkout. If you prefer the Stripe-style alias, `POST /dev/v1/checkout/sessions` is available too. The published JS SDK does not wrap that route yet, so the quickstart uses `POST /dev/v1/payments`, which already has first-class SDK support. This quickstart uses `POST /dev/v1/payments` because it is the smallest ad-hoc checkout example (title + amount). That path does **not** link checkout to the catalog product you fetched above. If the product is a **subscription**, or you need serial/file/DYNAMIC delivery, use **`POST /dev/v1/orders`** with `items: [{ product_id: product.uniqid, quantity: 1 }]` instead. See [Subscriptions](/developers/subscriptions#headless-developer-api-checkout) and [SaaS Paywall](/developers/headless/overview). *** ## 4. Verify the signed webhook Create a webhook endpoint in the Shoppex dashboard (**Settings → Webhooks → Add Endpoint**). Point it at `https://your-site.com/api/webhooks/shoppex` and subscribe to `order:paid`. Copy the signing secret into `SHOPPEX_WEBHOOK_SECRET`. `app/api/webhooks/shoppex/route.ts`: ```typescript theme={"system"} import { createHmac, timingSafeEqual } from 'node:crypto'; import { NextRequest, NextResponse } from 'next/server'; export async function POST(request: NextRequest) { const signature = request.headers.get('x-shoppex-signature-v2'); const deliveryId = request.headers.get('x-shoppex-delivery'); const timestamp = request.headers.get('x-shoppex-timestamp'); if (!signature || !deliveryId || !timestamp) { return NextResponse.json({ error: 'missing signature' }, { status: 401 }); } const raw = await request.text(); const segments = signature.split(',').map((part) => part.trim()); const parts = Object.fromEntries(segments.filter((part) => part.includes('=')).map((part) => { const [key, value] = part.trim().split('='); return [key, value ?? '']; })); if ( !segments.includes('v1') || parts.t !== timestamp || Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp)) > 300 || !/^[0-9a-f]{64}$/i.test(parts.h ?? '') ) { return NextResponse.json({ error: 'invalid signature' }, { status: 401 }); } const expected = createHmac('sha256', process.env.SHOPPEX_WEBHOOK_SECRET!) .update(`${deliveryId}.${timestamp}.${raw}`) .digest('hex'); const sigBuf = Buffer.from(parts.h, 'hex'); const expBuf = Buffer.from(expected, 'hex'); if (sigBuf.length !== expBuf.length || !timingSafeEqual(sigBuf, expBuf)) { return NextResponse.json({ error: 'invalid signature' }, { status: 401 }); } const event = JSON.parse(raw) as { event: string; data: { uniqid: string; status: string; customer_email: string; total: number }; created_at: number; }; if (event.event === 'order:paid') { // Your fulfillment: grant access, send license, set up subscription, and more. console.log(`Order ${event.data.uniqid} paid for ${event.data.customer_email}`); } return NextResponse.json({ received: true }); } ``` **Always verify the signature with constant-time comparison.** The `timingSafeEqual` call above is what prevents timing-side-channel attacks. Never compare signatures with `===`. Return 2xx within a few seconds. Shoppex retries failed deliveries with backoff. If fulfillment takes longer than a couple of seconds, queue the real work in a background job and ack the webhook immediately. *** ## 5. Local testing Run the app and point a tunnel at it so the webhook is reachable: ```bash theme={"system"} npm run dev # in a second terminal: ngrok http 3000 ``` Set the webhook URL in the Shoppex dashboard to the ngrok URL + `/api/webhooks/shoppex`. In the dashboard, click **Send Test Event** on your webhook to verify the signature check passes. *** ## What you built | Layer | What it does | | ----------------------------------- | ---------------------------------------------------------------- | | `app/products/page.tsx` | Server-side catalog read, no API key in the browser | | `app/products/[id]/page.tsx` | Server Action creates a payment and redirects to hosted checkout | | `app/api/webhooks/shoppex/route.ts` | Signed webhook receiver with constant-time HMAC verification | | `lib/shoppex.ts` | `server-only` SDK wrapper | Your frontend, your routing, your brand. Shoppex handled the PSP selection, 3DS, the hosted checkout page, the payment confirmation, and the event delivery. It will also handle refunds, disputes, and subscriptions when you add them. *** ## Next steps Every event Shoppex sends, with sample payloads. Three reference setups including mobile and backend-for-frontend. Prefer a modal over a redirect? Swap the Server Action for a buy button. Subscriptions, licenses, coupons, customers, and more. # Storefront starter Source: https://docs.shoppex.io/developers/headless/storefront-starter Start from a self-hosted Next.js storefront with Shoppex products, cart, checkout, customer portal links, and webhook fulfillment already wired. The Shoppex Storefront Starter is for merchants who want a custom storefront without rebuilding commerce. Simple example: ```txt theme={"system"} yourdomain.com -> your Next.js storefront checkout.shoppex.io -> Shoppex hosted checkout account.yourdomain.com -> Shoppex customer portal ``` Use this starter when you want to own the frontend. Use a hosted Shoppex theme when you want Shoppex to own the storefront UI too. ## Create a storefront ```bash theme={"system"} bunx create-shoppex-storefront my-store cd my-store bun install bun run dev ``` For non-interactive setup: ```bash theme={"system"} bunx create-shoppex-storefront my-store \ --shop-slug your-shop \ --customer-portal-url https://account.yourdomain.com \ --checkout-mode cart ``` The command creates a Next.js app, writes `.env.local`, and points the starter at your Shoppex shop. ## What is included * **Storefront UI** — homepage, product grid, product detail pages, product images, variants, stock count, and sold-out states. * **Cart and checkout** — browser-local cart, checkout review, email capture, coupon field, terms acknowledgement, and Shoppex invoice handoff. * **Customer portal link** — link buyers to orders, downloads, license keys, and support through the Shoppex customer portal. * **Webhook example** — signed webhook route for paid order events, with a Discord notification example for fulfillment visibility. ## Configure Shoppex Set these values in `.env.local`: ```txt theme={"system"} NEXT_PUBLIC_SHOPPEX_SHOP_SLUG=your-shop NEXT_PUBLIC_CUSTOMER_PORTAL_URL=https://account.yourdomain.com SHOPPEX_WEBHOOK_SECRET=whsec_your_secret SHOPPEX_DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/... ``` Then edit: ```txt theme={"system"} shoppex.config.ts theme.config.ts ``` Simple example: ```ts theme={"system"} export const shoppexConfig = { shopSlug: process.env.NEXT_PUBLIC_SHOPPEX_SHOP_SLUG ?? "demo", checkoutMode: "cart", showStockCount: true, customerPortal: { mode: "branded-subdomain", url: process.env.NEXT_PUBLIC_CUSTOMER_PORTAL_URL ?? "https://demo.myshoppex.io/dashboard", }, }; ``` ## Checkout modes | Mode | Use it for | | --------- | ------------------------------------------------------------------- | | `cart` | Normal stores with multiple products and a cart drawer | | `buy-now` | One-product stores where the product page goes straight to checkout | | `embed` | Simple product-button flows that use the Checkout Embed SDK | The default is `cart`. ## Cart model The starter cart lives in the buyer's browser until checkout. At checkout, Shoppex rechecks: * product availability * selected variants * quantity limits * prices * coupons * stock If something changed, the checkout page shows a recovery state instead of a generic error. Simple example: ```txt theme={"system"} Buyer has 5 items in cart Shop stock changes to 2 Checkout asks the buyer to reduce the quantity before payment ``` The starter does not reserve stock when a buyer adds an item to the cart. Stock is validated when the Shoppex invoice is created. ## Customer portal For a fully self-hosted storefront, use a branded account subdomain: ```txt theme={"system"} yourdomain.com -> Next.js storefront account.yourdomain.com -> Shoppex customer portal ``` Fastest setup: ```txt theme={"system"} https://your-shop.myshoppex.io/dashboard ``` Use `/dashboard` only when the storefront domain is routed through the Shoppex Storefront Worker. ## Moving from another platform Treat this as a fresh storefront cutover. Move these into Shoppex: * products * prices * variants * stock * checkout settings * fulfillment webhooks The starter does not automatically import old order history, old customer accounts, or previous platform-specific embeds. ## Deploy Deploy the generated project to Vercel and set the same environment variables there. ```txt theme={"system"} NEXT_PUBLIC_SHOPPEX_SHOP_SLUG NEXT_PUBLIC_CUSTOMER_PORTAL_URL SHOPPEX_WEBHOOK_SECRET SHOPPEX_DISCORD_WEBHOOK_URL ``` After deploy, create a Shoppex webhook endpoint: ```txt theme={"system"} https://yourdomain.com/api/shoppex/webhook ``` Subscribe it to paid order events such as `order:paid` and `order:paid:product`. ## Next steps Understand the three integration shapes: Dev API, Storefront SDK, and Checkout Embed SDK. Add modal checkout to existing pages or product buttons. Read public product and storefront data from your custom frontend. Verify paid order events and run fulfillment after payment. # Invoices Source: https://docs.shoppex.io/developers/invoices Understanding the invoice lifecycle and statuses Every payment in Shoppex starts with an invoice. A customer can check out through your storefront, click a payment link, or your backend can create one through the API. The invoice is always the central object that tracks who is paying, what they are buying, and whether the payment succeeded. This guide covers the Shoppex invoice model used by both hosted checkout flows and the Developer API. An invoice can go through more than one payment attempt before it reaches its final state. For example, a customer can try PayPal, abandon it, and come back with a credit card. It is still the same invoice. ## Invoice statuses | Status | Description | | --------------------------- | ----------------------------------------------- | | `PENDING` | Invoice created, awaiting payment | | `COMPLETED` | Payment completed successfully | | `PARTIAL` | Partial payment received (crypto underpayment) | | `VOIDED` | Invoice cancelled or expired | | `WAITING_FOR_CONFIRMATIONS` | Crypto payment pending blockchain confirmations | | `CUSTOMER_DISPUTE_ONGOING` | Customer opened a dispute/chargeback | | `REVERSED` | Payment reversed (chargeback won by customer) | | `REFUNDED` | Payment was refunded | ## Creating an invoice ### Via dashboard Go to **Invoices → Create Invoice** Select products and quantities Enter customer email (optional) Choose allowed payment methods Click **Create** ### Via API Use the [POST /payments](/developers/payments) endpoint of the Developer API to create an invoice programmatically: ```typescript TypeScript theme={"system"} const response = await fetch('https://api.shoppex.io/dev/v1/payments', { method: 'POST', headers: { 'Authorization': 'Bearer YOUR_API_KEY', 'Content-Type': 'application/json' }, body: JSON.stringify({ title: 'Order #456', email: 'customer@example.com', value: 29.99, currency: 'EUR' }) }); const { data } = await response.json(); // Redirect to data.url for checkout ``` ## Completing or processing an invoice The dashboard calls the manual merchant action **Process Invoice**. The public Developer API calls the same operation **complete invoice**: ```http theme={"system"} POST /dev/v1/invoices/{uniqid}/complete ``` Use it only after your server has independently confirmed payment. The invoice must still be `PENDING` or `PENDING_PAYMENT`. An already completed or otherwise non-completable invoice returns `422 VALIDATION_ERROR`. Every request must include an `Idempotency-Key` header. See [Manual payment auto-completion](/developers/manual-payments) for request examples, idempotency, webhook verification, and common errors. ## Invoice expiration By default, Shoppex-hosted invoice payment windows expire after **24 hours**. You can customize the default expiration in **Settings → Invoices**. The current Dev API payment example on this page does not accept an `expires_at` request field. Configure a different default expiration in the dashboard instead. Expired invoices cannot be paid. If the customer still wants to pay, create a new invoice. ## Partial payments Partial payments can occur with cryptocurrency when the customer sends slightly less than required. You have three options: * **Accept as paid** — Mark the invoice as paid manually from the dashboard. Use this when the shortfall is negligible and you want to fulfill the order immediately. * **Request the remaining amount** — The customer pays the difference. The invoice stays in `PARTIAL` status until the remaining amount arrives, then it transitions to `COMPLETED`. * **Refund** — Cancel and refund the partial payment. The invoice moves to `VOIDED`, and you can create a new one if needed. ## Webhooks Get notified when invoice status changes: ```json theme={"system"} { "event": "order:paid", "data": { "uniqid": "abc123def456", "type": "PRODUCT", "status": "COMPLETED", "gateway": "STRIPE", "total": 29.99, "total_display": 29.99, "currency": "USD", "customer_email": "customer@example.com", "product_id": "prod_xyz", "product_title": "Pro License", "created_at": "2026-01-15T09:10:00.000Z", "updated_at": "2026-01-15T09:10:50.000Z" }, "created_at": 1705314650 } ``` See [Webhooks](/developers/webhooks) for setup instructions. ## Integration rule If you sync invoices into your own system, use the Shoppex invoice status as the final source of truth. The first payment attempt can fail and the second can succeed. The invoice ends as `COMPLETED` regardless of how many tries it took. React to the final invoice state through webhook events, not through individual gateway callbacks. This is the single most important integration rule in Shoppex. Learn about payment gateways, configuration, and processing. Set up real-time notifications for invoice status changes. # Resellers Source: https://docs.shoppex.io/developers/manage-resellers Automate your wholesale reseller program from the Developer API. These endpoints let a merchant read and steer their own wholesale reseller program: list reseller accounts, invite new ones, suspend and reinstate them, and read or update the program settings. They are part of the merchant [Developer API](/developers/api-overview) and authenticate with a normal `shx_` API key. The API resellers themselves use to buy wholesale stock is a different API. See the [Reseller API](/developers/reseller-api). Tier authoring, product enrollment, and per-product price overrides are dashboard-only. They are configuration for the price cascade, not automation, and a second write path for them creates a second source of truth. `GET /dev/v1/reseller-tiers` is read-only for exactly that reason. Resellers themselves sign in at `https:///customer-portal` on your storefront and open the **Reseller** tab to view their tier, catalog, balance, orders, and API keys. *** ## Scopes | Scope | Grants | | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | | `resellers.read` | `GET /dev/v1/resellers`, `GET /dev/v1/resellers/{id}`, `GET /dev/v1/reseller-program`, `GET /dev/v1/reseller-tiers` | | `resellers.write` | `POST /dev/v1/resellers/invite`, `POST /dev/v1/resellers/{id}/suspend`, `POST /dev/v1/resellers/{id}/reinstate`, `PATCH /dev/v1/reseller-program` | A key with only `resellers.read` gets `403 FORBIDDEN` on any of the write routes. Reseller scopes are not implied by `customers.*`, even though the dashboard puts Resellers under Customers. *** ## Endpoints ### GET /dev/v1/resellers Page-based list, newest first. | Query | Type | Default | | ---------- | ------------------------------------------------------- | ------- | | `status` | `INVITED`, `APPLIED`, `ACTIVE`, `SUSPENDED`, `REJECTED` | all | | `search` | matches the reseller email, max 128 chars | — | | `page` | integer ≥ 1 | `1` | | `per_page` | integer 1–100 | `25` | ```bash theme={"system"} curl "https://api.shoppex.io/dev/v1/resellers?status=ACTIVE&per_page=50" \ -H "Authorization: Bearer shx_your_key_here" ``` ```json theme={"system"} { "data": { "items": [ { "id": "019cb402-1111-7000-8000-000000000001", "customer_id": "019cb402-2222-7000-8000-000000000002", "email": "wholesale@example.com", "status": "ACTIVE", "tier": { "id": "019cb402-3333-7000-8000-000000000003", "name": "Gold", "discount_percent": 15 }, "application_note": null, "lifetime_revenue": "1250.00", "lifetime_orders": 7, "approved_at": "2026-07-01T10:00:00.000Z", "suspended_at": null, "rejected_at": null, "created_at": "2026-06-01T10:00:00.000Z", "updated_at": "2026-07-01T10:00:00.000Z" } ], "total": 1, "page": 1, "per_page": 50 } } ``` ### GET /dev/v1/resellers/ Returns `{ "data": { "reseller": { … } } }` with the same object. `404 RESELLER_NOT_FOUND` when the ID belongs to another shop or does not exist. ### POST /dev/v1/resellers/invite Sends the wholesale invitation email and creates the relationship in `INVITED` state. ```json theme={"system"} { "email": "new@example.com", "tier_id": "019cb402-3333-7000-8000-000000000003" } ``` `tier_id` is optional. Omit it to use the program's default tier. If neither is set the call fails with `422` and the message asks you to pick a tier. Requires an enabled program. Inviting an email that is already `INVITED`, `APPLIED`, `ACTIVE`, or `SUSPENDED` returns `409 CONFLICT`. A previously `REJECTED` email can be re-invited. ### POST /dev/v1/resellers//suspend Blocks wholesale pricing and revokes Reseller API access for that account. Only an `ACTIVE` reseller can be suspended. Anything else returns `422`. The row is never deleted, so the status history stays intact. ### POST /dev/v1/resellers//reinstate Returns a `SUSPENDED` reseller to `ACTIVE`. Only a `SUSPENDED` reseller can be reinstated. Reinstating sends no webhook. There is no `reseller:reinstated` event. Shoppex only sends `reseller:suspended` on the way out. ### GET /dev/v1/reseller-program ```json theme={"system"} { "data": { "program": { "id": "019cb402-8888-7000-8000-000000000008", "enabled": true, "enrollment_mode": "APPLICATION", "application_message": "Tell us about your business.", "require_application_note": true, "default_tier_id": "019cb402-3333-7000-8000-000000000003", "created_at": "2026-06-01T10:00:00.000Z", "updated_at": "2026-07-01T10:00:00.000Z" } } } ``` The row is created lazily. The first read on a shop that never opened the Resellers page returns a disabled program in `MANUAL` mode rather than a 404. ### PATCH /dev/v1/reseller-program Partial update. Every field is optional. ```json theme={"system"} { "enabled": true, "enrollment_mode": "OPEN", "default_tier_id": "019cb402-3333-7000-8000-000000000003" } ``` | Field | Notes | | -------------------------- | ---------------------------------------------------------------------------------------------------- | | `enabled` | Master switch for the whole program | | `enrollment_mode` | `MANUAL` (invite only), `APPLICATION` (customers apply, you approve), `OPEN` (customers self-enroll) | | `application_message` | Shown to applicants, max 2000 chars, nullable | | `require_application_note` | Forces applicants to write a note | | `default_tier_id` | Tier used when an invite does not name a tier, and for open enrollment | `enrollment_mode: "OPEN"` without a `default_tier_id` returns `422`. Open enrollment turns customers into resellers without review, so the tier they land in has to be decided up front. An unknown `default_tier_id` returns `404 RESELLER_TIER_NOT_FOUND`. ### GET /dev/v1/reseller-tiers Read-only, ordered by `sort_order`. ```json theme={"system"} { "data": { "tiers": [ { "id": "019cb402-3333-7000-8000-000000000003", "name": "Gold", "discount_percent": 15, "auto_advance_revenue": "5000.00", "auto_advance_orders": 20, "auto_advance_requires_both": false, "sort_order": 1, "member_count": 3, "is_default": true, "created_at": "2026-06-01T10:00:00.000Z", "updated_at": "2026-07-01T10:00:00.000Z" } ] } } ``` `auto_advance_revenue` and `auto_advance_orders` are the thresholds that promote a reseller into this tier automatically after an order completes. Either being `null` means it is not part of the condition. `auto_advance_requires_both: false` means hitting either threshold is enough. *** ## Idempotency All three write routes accept the standard `Idempotency-Key` header described in [Errors](/developers/errors). It is optional here, unlike on the reseller-facing order endpoint where it is mandatory. # Complete or process manual-payment invoices Source: https://docs.shoppex.io/developers/manual-payments Complete or process storefront manual-payment invoices after your external gateway confirms payment Shoppex **custom manual gateways** (Settings → Payments → Manual) support payment instructions or buyer redirects with template variables such as `{{amount}}`, `{{currency}}`, and `{{invoice_id}}`. There is **no built-in inbound IPN URL** on manual gateways today. When your own payment provider confirms a transfer, your server must call the **Developer API** to complete the invoice. This is the API equivalent of the dashboard's **Process Invoice** action. This pattern works for **storefront and payment-link invoices**, not only for `POST /dev/v1/payments`. Use `POST /dev/v1/invoices/{uniqid}/complete`. ## End-to-end flow ```mermaid theme={"system"} sequenceDiagram participant Buyer participant Shoppex participant YourGateway participant YourServer Buyer->>Shoppex: Select manual gateway + confirm sent Shoppex->>YourServer: order:manual_payment_pending (optional) Buyer->>YourGateway: Pays (bank, local PSP, etc.) YourGateway->>YourServer: Provider webhook YourServer->>Shoppex: POST /dev/v1/invoices/{uniqid}/complete Shoppex->>YourServer: order:paid + fulfillment ``` 1. Buyer starts manual checkout → invoice moves to `PENDING_PAYMENT` / awaiting merchant confirmation. 2. Shoppex can notify you with **`order:manual_payment_pending`** (configure under **Settings → Webhooks**). 3. Your external gateway sends its own webhook when money arrives. 4. Your adapter calls **`POST /dev/v1/invoices/{uniqid}/complete`** with your Shoppex API key. 5. Shoppex runs the normal completion pipeline (delivery, emails, **`order:paid`**). Always verify the provider webhook on **your** server before calling Shoppex. Never expose your `shx_*` API key to the browser or to the payment provider's client-side SDK. *** ## Prerequisites | Requirement | Details | | -------------- | ------------------------------------------------------------------------------------------------------ | | API key | Dashboard → **Settings → Developer API**. Scope: `invoices.write` (or `*`). | | Invoice status | `PENDING` or `PENDING_PAYMENT` | | Invoice ID | UUID from checkout URL (`/invoice/{uniqid}`) or `order:manual_payment_pending` payload (`data.uniqid`) | *** ## Complete an invoice (minimal) ```bash cURL theme={"system"} curl -X POST "https://api.shoppex.io/dev/v1/invoices/INVOICE_UUID/complete" \ -H "Authorization: Bearer shx_your_api_key" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: complete-INVOICE_UUID" \ -d '{ "note": "Paid via my local gateway", "suppress_emails": false }' ``` ```typescript TypeScript (@shoppexio/sdk) theme={"system"} import { Shoppex } from '@shoppexio/sdk'; const shoppex = new Shoppex({ apiKey: process.env.SHOPPEX_API_KEY! }); await shoppex.invoices.complete(invoiceUniqid, { note: 'Paid via my local gateway', }); ``` ```python Python theme={"system"} import os import requests response = requests.post( f"https://api.shoppex.io/dev/v1/invoices/{invoice_uniqid}/complete", headers={ "Authorization": f"Bearer {os.environ['SHOPPEX_API_KEY']}", "Content-Type": "application/json", "Idempotency-Key": f"complete-{invoice_uniqid}", }, json={"note": "Paid via my local gateway"}, timeout=30, ) response.raise_for_status() ``` **Success:** `200` with a stable completion result: ```json theme={"system"} { "data": { "uniqid": "33333333-3333-4333-8333-333333333333", "status": "COMPLETED", "message": "Invoice completed successfully." } } ``` Call `GET /dev/v1/invoices/{uniqid}` afterwards if you need the current full invoice representation. **Common errors:** | HTTP | Meaning | | ----- | --------------------------------------------------------- | | `401` | Invalid or missing API key | | `404` | Invoice not found for your shop | | `422` | Invoice already `COMPLETED`, or status is not completable | The **`Idempotency-Key`** header is required so that a retry after a network drop does not apply the same side effect twice. If you do not pass a key, the Shoppex SDK creates the same key every time for the same request. *** ## Listen for `order:manual_payment_pending` Register a shop webhook (**Settings → Webhooks**) for `order:manual_payment_pending`. When a buyer submits manual payment instructions, Shoppex POSTs a signed payload to your URL. Extract the invoice UUID from `data.uniqid` and store it with your gateway's pending-payment record if needed. Verify signatures with `X-Shoppex-Signature-V2`. See [Webhooks](/developers/webhooks). *** ## Cloudflare Worker adapter (copy-paste) This worker receives a **generic** JSON webhook from your payment provider, optionally checks a shared secret, then completes the Shoppex invoice. **Worker secrets (Wrangler):** * `SHOPPEX_API_KEY` — your `shx_*` key with `invoices.write` * `PROVIDER_WEBHOOK_SECRET` — shared secret your gateway sends (header or HMAC). Omit checks only in local dev **Expected provider JSON shape (customize to your PSP):** ```json theme={"system"} { "invoice_id": "4ea04c92-5cc3-4ea8-845c-cd3c7085796c", "status": "paid", "amount": "49.99", "currency": "USD" } ``` ```typescript worker.ts theme={"system"} interface ProviderWebhook { invoice_id?: string; status?: string; amount?: string | number; currency?: string; } const SHOPPEX_API = 'https://api.shoppex.io'; export default { async fetch(request: Request, env: Env): Promise { if (request.method !== 'POST') { return new Response('Method Not Allowed', { status: 405 }); } const sharedSecret = request.headers.get('x-provider-secret'); if (!env.PROVIDER_WEBHOOK_SECRET || sharedSecret !== env.PROVIDER_WEBHOOK_SECRET) { return new Response('Unauthorized', { status: 401 }); } let payload: ProviderWebhook; try { payload = await request.json() as ProviderWebhook; } catch { return new Response('Bad Request', { status: 400 }); } const invoiceUniqid = payload.invoice_id?.trim().toLowerCase(); if (!invoiceUniqid) { return new Response('Missing invoice_id', { status: 400 }); } if (payload.status?.toLowerCase() !== 'paid') { return new Response('Ignored', { status: 200 }); } const completeResponse = await fetch( `${SHOPPEX_API}/dev/v1/invoices/${invoiceUniqid}/complete`, { method: 'POST', headers: { Authorization: `Bearer ${env.SHOPPEX_API_KEY}`, 'Content-Type': 'application/json', 'Idempotency-Key': `complete-${invoiceUniqid}`, }, body: JSON.stringify({ note: `Auto-completed from provider webhook (amount=${payload.amount ?? 'n/a'} ${payload.currency ?? ''})`.trim(), }), }, ); if (completeResponse.status === 422) { const body = await completeResponse.text(); if (/already completed/i.test(body)) { return new Response('Already completed', { status: 200 }); } return new Response(body, { status: 422 }); } if (!completeResponse.ok) { const body = await completeResponse.text(); return new Response(body, { status: 502 }); } return new Response('OK', { status: 200 }); }, }; interface Env { SHOPPEX_API_KEY: string; PROVIDER_WEBHOOK_SECRET: string; } ``` Deploy with Wrangler, then point your gateway's webhook URL at the worker. Pass `{{invoice_id}}` in your manual gateway redirect URL or instructions so the provider echoes it back. For redirect manual gateways, include `{{invoice_id}}` in the external checkout URL query string so your PSP webhook can return the same ID. *** ## Redirect manual gateway example **Redirect URL in dashboard:** ``` https://pay.example.com/checkout?amount={{amount}}¤cy={{currency}}&reference={{invoice_id}}&email={{customer_email}} ``` When `reference` comes back on the provider webhook, map it to `invoice_id` in the worker above. *** ## Security checklist * Verify **provider** signatures or shared secrets before calling Shoppex. * Optionally re-fetch the invoice with `GET /dev/v1/invoices/{uniqid}` and compare `total` / `currency` to the provider amount before completing. * Keep `shx_*` keys server-side only. * Use `Idempotency-Key` on every completion call. * Return `200` to your provider only after Shoppex accepts the completion (or the invoice is already completed). *** ## Manual confirmation in the dashboard If you do not automate completion, open the invoice in the dashboard when status is **Awaiting Manual Confirmation**, then **Confirm Payment → Process Invoice**. The Dev API `complete` call runs the same backend pipeline. *** ## Related docs Hosted checkout and Developer API overview Verify `order:manual_payment_pending` and `order:paid` Full API reference including `POST /dev/v1/invoices/{uniqid}/complete` Connect Stripe, PayPal, Cash App, and manual methods # Payments Source: https://docs.shoppex.io/developers/payments Accept payments through the API, payment links, or your storefront There are three ways to accept payments with Shoppex. Customers browse your shop and check out directly Share a link that takes customers straight to checkout Create invoices programmatically from your app This guide covers both hosted/public Shoppex flows (storefront, payment links) and Developer API flows (`POST /dev/v1/payments`, `POST /dev/v1/orders`). *** ## The payment flow You do not need to integrate with Stripe or PayPal directly. Shoppex handles all gateway communication for you. ## Important integration rule One Shoppex invoice can have more than one payment attempt over time. A customer can open checkout, try PayPal, abandon it, come back, and finish with Stripe. This surprises most people integrating for the first time. Redirect the customer to the Shoppex checkout URL, listen for webhook events, and fulfill based on the final invoice status. Do not build your fulfillment logic around a single provider-side session ID. *** ## Method 1: Storefront (hosted, public) Your storefront at `yourshop.shoppex.io` is ready by default: 1. Customer browses products 2. Adds to cart 3. Completes checkout 4. Receives product automatically **Best for:** Digital products, subscriptions, simple e-commerce *** ## Method 2: Payment Links (hosted flow) Create a link that goes directly to checkout. Share it on social media, in emails, or anywhere else. ### Create through the dashboard 1. Go to **Products → Your Product** 2. Click **Copy Payment Link** 3. Share the URL: `https://yourshop.shoppex.io/product/your-product` ### Create through the API ```typescript theme={"system"} const response = await fetch('https://api.shoppex.io/dev/v1/payments', { method: 'POST', headers: { 'Authorization': 'Bearer YOUR_API_KEY', 'Content-Type': 'application/json' }, body: JSON.stringify({ title: 'Pro License', email: 'customer@example.com', value: 49.99, currency: 'USD', webhook: 'https://your-site.com/api/shoppex/payment-webhook' }) }); const { data } = await response.json(); console.log(data.url); // https://checkout.shoppex.io/invoice/4ea04c92-5cc3-4ea8-845c-cd3c7085796c console.log(data.webhook_secret); // returned once when webhook is provided ``` If you pass `webhook`, Shoppex sends payment lifecycle events only for that created payment or invoice to that URL. Store `webhook_secret` immediately. Shoppex does not return it on later reads. The `webhook` value must be your own HTTP(S) endpoint. Discord webhook URLs are not compatible with Shoppex developer-event payloads. Configure Discord notifications under **Notifications → Sales Alerts** instead. **Best for:** Social media sales, email campaigns, one-off payments *** ## Method 3: API integration (Developer API) Use the Developer API when you need your own checkout UI or a backend-driven payment flow. ### Payment vs order `POST /dev/v1/payments` and `POST /dev/v1/orders` look similar, but they solve different problems. Use **`POST /dev/v1/payments`** when you have your own checkout and want a hosted payment URL. You do not get Shoppex catalog line items or automatic product delivery. Use **`POST /dev/v1/orders`** when you want to charge for real Shoppex products. It includes line items, variants, and automatic delivery (files, serials, subscriptions, or `DYNAMIC`). The quick rule: `payments` = generic developer payment. `orders` = catalog-backed Shoppex order. **Subscriptions, serials, files, and DYNAMIC delivery always require `POST /dev/v1/orders`.** `POST /dev/v1/payments` creates a one-off developer invoice with a free-form title and amount. It does not link to catalog products, does not create subscription records, and does not appear in the **Subscriptions** dashboard tab. A common integration mistake: load a product from `GET /dev/v1/products`, then call `POST /dev/v1/payments` with that product's title and price. That disconnects checkout from the catalog. Pass the product `uniqid` in `POST /dev/v1/orders` instead. The API returns `422` when `POST /dev/v1/payments` receives catalog fields such as `product_id` or `items`. Recurring-billing custom fields such as `plan_type` still create the payment, but the `201` response includes a `warnings` array with code `subscription_checkout_mismatch` pointing you to `POST /dev/v1/orders`. ### Check which payment methods are enabled Use `GET /dev/v1/me/capabilities` to see which payment methods the shop currently accepts. A common mistake: `GET /dev/v1/payments` returns existing payment records, not the shop's payment configuration. If you want to know which gateways (Stripe, PayPal, Crypto) are enabled, use `GET /dev/v1/me/capabilities` instead. Crypto payments require a connected crypto provider: your own OxaPay, Cryptomus, or NowPayments account (connected in the dashboard under Settings → Payments → Crypto), or a Native Crypto wallet. `GET /dev/v1/me/capabilities` shows the enabled payment methods and native crypto gateways for your shop. ### Skip the crypto picker on hosted checkout If you want to keep Shoppex hosted checkout but open a concrete coin directly, pass: * `gateway`: the merchant crypto provider you want Shoppex to use * `crypto_gateway`: the concrete coin or network the buyer pays with This works for merchant crypto providers like `OXAPAY`, `CRYPTOMUS`, and `NOWPAYMENTS`. ```bash cURL theme={"system"} curl -X POST https://api.shoppex.io/dev/v1/payments \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "title": "Discord Boost Package", "email": "customer@example.com", "value": 59.99, "currency": "USD", "gateway": "OXAPAY", "crypto_gateway": "TRON" }' ``` Example response shape: ```json theme={"system"} { "data": { "uniqid": "4ea04c92-5cc3-4ea8-845c-cd3c7085796c", "crypto_gateway": "TRON", "url": "https://checkout.shoppex.io/invoice/4ea04c92-5cc3-4ea8-845c-cd3c7085796c?selected_gateway=TRON&auto_start_gateway=1&auto_start_crypto=1", "url_branded": "https://checkout.shoppex.io/invoice/4ea04c92-5cc3-4ea8-845c-cd3c7085796c?selected_gateway=TRON&auto_start_gateway=1&auto_start_crypto=1&shop=12345" } } ``` Simple example: * `gateway: "OXAPAY"` + `crypto_gateway: "TRON"` -> hosted checkout opens directly on TRON * `gateway: "CRYPTOMUS"` + `crypto_gateway: "USDT_TRC20"` -> hosted checkout opens directly on USDT TRC20 * `gateway: "NOWPAYMENTS"` + `crypto_gateway: "BITCOIN"` -> hosted checkout opens directly on Bitcoin through your NowPayments account * `gateway: "NATIVE_CRYPTO"` + `crypto_gateway: "LITECOIN"` -> Shoppex derives a non-custodial Litecoin receive address from your active native wallet If you omit `crypto_gateway`, the buyer lands on the normal generic crypto payment selection first. If you only send `crypto_gateway` without `gateway`, Shoppex uses your active native wallet when it covers that coin. Otherwise, Shoppex uses your connected crypto provider. Either way, Shoppex starts the crypto session immediately. If you have no connected crypto provider and no native wallet, the request fails with a validation error. ### Native crypto payments (BTC, LTC, USDT-TRC20, SOL, USDT-SPL) Use this flow when you want crypto paid directly into your own non-custodial wallet. Shoppex assigns a fresh receive address from your active native wallet. The address is derived from your wallet key, or pre-created on your device for Solana. Shoppex watches the chain and marks the payment paid after the chain's confirmation policy. Shoppex does not create a provider payment and does not hold keys or funds. Check availability first: ```bash cURL theme={"system"} curl https://api.shoppex.io/dev/v1/me/capabilities \ -H "Authorization: Bearer YOUR_API_KEY" ``` Look for: ```json theme={"system"} { "data": { "features": { "nativeCryptoCheckout": { "available": true, "enabled": true, "gateways": ["BITCOIN", "LITECOIN", "USDT_TRC20", "SOLANA", "USDT_SOL"] } } } } ``` Create a native Litecoin payment: ```bash cURL theme={"system"} curl -X POST https://api.shoppex.io/dev/v1/payments \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "title": "Discord Boost Package", "email": "customer@example.com", "value": 59.99, "currency": "USD", "gateway": "NATIVE_CRYPTO", "crypto_gateway": "LITECOIN" }' ``` Example response shape: ```json theme={"system"} { "data": { "uniqid": "4ea04c92-5cc3-4ea8-845c-cd3c7085796c", "gateway": "NATIVE", "crypto_gateway": "LITECOIN", "crypto_address": "ltc1qexampleaddress", "crypto_amount": 0.42133742, "crypto_received": 0, "crypto_uri": "litecoin:ltc1qexampleaddress?amount=0.42133742", "crypto_confirmations_needed": 2, "status": "PENDING", "url": "https://checkout.shoppex.io/invoice/4ea04c92-5cc3-4ea8-845c-cd3c7085796c" } } ``` If `gateway` is `NATIVE_CRYPTO`, Shoppex fails if no active native wallet exists for the requested chain. If you only send `crypto_gateway` (for example `"BITCOIN"` or `"SOLANA"`), an active native wallet is prioritized over external providers offering the same coin. ### White-label crypto checkout (removed) Shoppex removed managed white-label crypto checkout, together with the `white_label` request parameter. Crypto payments always use your own accounts now: * Native Crypto for non-custodial receive addresses from your own wallet (BTC, LTC, USDT-TRC20, SOL, USDT-SPL) * Your own OxaPay, Cryptomus, or NowPayments account for other coins Historical white-label crypto payments can still be read through the Developer API and can still complete through the normal payment lifecycle. Schema validation rejects requests that still send `white_label`. ### Create a payment ```typescript TypeScript theme={"system"} const response = await fetch('https://api.shoppex.io/dev/v1/payments', { method: 'POST', headers: { 'Authorization': 'Bearer YOUR_API_KEY', 'Content-Type': 'application/json' }, body: JSON.stringify({ title: 'Order #123', email: 'customer@example.com', value: 29.99, currency: 'USD', // Optional settings gateway: 'STRIPE', // Force specific gateway return_url: 'https://yoursite.com/success', cancel_url: 'https://yoursite.com/cancelled' }) }); const { data } = await response.json(); // Redirect customer to checkout window.location.href = data.url; ``` ```python Python theme={"system"} import requests response = requests.post( 'https://api.shoppex.io/dev/v1/payments', headers={ 'Authorization': 'Bearer YOUR_API_KEY', 'Content-Type': 'application/json' }, json={ 'title': 'Order #123', 'email': 'customer@example.com', 'value': 29.99, 'currency': 'USD' } ) data = response.json()['data'] print(f"Checkout URL: {data['url']}") ``` ```bash cURL theme={"system"} curl -X POST https://api.shoppex.io/dev/v1/payments \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "title": "Order #123", "email": "customer@example.com", "value": 29.99, "currency": "USD" }' ``` If you create the payment with `gateway: 'PANDABASE'`, Shoppex also returns `checkout_url` and `session_id`. * Use `data.checkout_url` if you want the direct Pandabase checkout session URL. * Keep listening for Shoppex webhooks for the final paid or cancelled state. Server-side completion is also different: * `POST /dev/v1/payments/:id/complete` confirms a generic developer payment only * `POST /dev/v1/orders/:id/fulfill` completes and fulfills a catalog-backed order * `POST /dev/v1/orders/:id/complete` is the same completion pipeline, exposed as an alias for integrations that look for a `complete` endpoint ### Handle the webhook After payment, Shoppex sends a webhook to your server. Use the `webhook_secret` returned by `POST /dev/v1/payments` for a per-payment webhook. Use the endpoint secret from **Settings → Webhooks** only for global webhook endpoints. ```typescript theme={"system"} import crypto from 'crypto'; function verifyWebhook( payload: string, signatureHeader: string, deliveryId: string, timestampHeader: string, secret: string, ): boolean { const segments = signatureHeader.split(',').map((part) => part.trim()); const hasV1Marker = segments.includes('v1'); const parts = Object.fromEntries(segments.filter((part) => part.includes('=')).map((part) => { const [key, value] = part.trim().split('='); return [key, value ?? '']; })); if (!deliveryId || !hasV1Marker || parts.t !== timestampHeader) { return false; } const timestamp = Number(parts.t); if (!Number.isFinite(timestamp)) return false; if (Math.abs(Math.floor(Date.now() / 1000) - timestamp) > 300) { return false; } if (!/^[0-9a-f]{64}$/i.test(parts.h ?? '')) return false; const expected = crypto .createHmac('sha256', secret) .update(`${deliveryId}.${parts.t}.${payload}`) .digest('hex'); if (parts.h.length !== expected.length) return false; return crypto.timingSafeEqual( Buffer.from(parts.h, 'hex'), Buffer.from(expected, 'hex'), ); } app.post('/webhooks/shoppex', async (req, res) => { // Verify signature (important!) const signature = req.headers['x-shoppex-signature-v2'] as string; const deliveryId = req.headers['x-shoppex-delivery'] as string; const timestamp = req.headers['x-shoppex-timestamp'] as string; if (!verifyWebhook(req.rawBody, signature, deliveryId, timestamp, WEBHOOK_SECRET)) { return res.status(401).send('Invalid signature'); } const { event, data } = req.body; switch (event) { case 'order:paid': // Payment successful - fulfill the order await fulfillOrder(data.uniqid, data.customer_email); break; case 'order:cancelled': // Payment failed or expired await handleFailedPayment(data.uniqid); break; } res.status(200).send('OK'); }); ``` Always verify webhook signatures in production. New integrations must use `X-Shoppex-Signature-V2`. See [Webhooks](/developers/webhooks) for details. ## Dynamic product fulfillment If you use products with `type: "DYNAMIC"`, there is one more callback contract to implement: `dynamic_webhook`. This is separate from normal Shoppex event webhooks and uses the product's dynamic webhook signing secret, not the secret from **Settings → Webhooks**. The `order:paid` webhook tells your app that the invoice is paid. The `dynamic_webhook` is a separate fulfillment callback that asks your server for the delivered token, key, or access data. Use the dedicated contract docs here: * [Dynamic product delivery](/developers/dynamic-delivery) *** ## Invoice vs payment link | Feature | Invoice (API) | Payment Link | | --------------------- | --------------------- | --------------- | | **Use case** | Custom checkout flows | Quick sharing | | **Customer email** | Required | Optional | | **Multiple products** | Yes | Single product | | **Custom fields** | Yes | Product default | | **Expiration** | Configurable | No expiration | | **Tracking** | Full invoice data | Basic analytics | **Rule of thumb:** * Use **Payment Links** for simple, shareable checkouts * Use **Invoices** when you need control over the checkout or customer data *** ## Payment gateways Configure your payment providers in **Settings → Payments**. For gateway-specific setup, supported methods, and fees, see [Payment gateways](/payments/gateways). For a developer integration, the enabled gateways decide two things: which `gateway` value you can pass to `POST /dev/v1/payments` or `POST /dev/v1/orders`, and which `crypto_gateway` values are valid. Use `GET /dev/v1/me/capabilities` to check what is enabled for the shop before you hardcode a gateway value. *** ## Testing payments Enable **Test Mode** in Settings before going live. ### Test card numbers | Card | Number | Result | | ---------- | --------------------- | ------------- | | Visa | `4242 4242 4242 4242` | Success | | Mastercard | `5555 5555 5555 4444` | Success | | Declined | `4000 0000 0000 0002` | Declined | | 3D Secure | `4000 0025 0000 3155` | Requires auth | Use any future expiry date and any 3-digit CVC. These numbers are Stripe-specific. PayPal and crypto gateways have their own sandbox modes. ### Test checklist Create an invoice through the dashboard or the API. Pay with test card `4242 4242 4242 4242`. Check that your webhook endpoint received `order:paid`. Confirm the product was delivered (email, license, download). Use [ngrok](https://ngrok.com) to test webhooks locally: ```bash theme={"system"} ngrok http 3000 # Use the generated URL as your webhook endpoint ``` *** ## Common scenarios ### Sell a digital product 1. Create a product with **File** delivery type. 2. Upload your file. 3. Share your storefront or payment link. 4. The customer pays, then receives the download automatically. ### Sell software licenses 1. Create a product with **Serials** delivery type. 2. Add license keys, one per line. 3. The customer pays, then receives a unique license key. ### Custom checkout in your app 1. Create a payment through the API with the customer email. 2. Redirect the customer to `data.url`. 3. Listen for the `order:paid` webhook. 4. Fulfill the order in your system. ### Custom manual gateway (local PSP) 1. Create a **Manual** gateway with a redirect URL and `{{invoice_id}}` template variables. 2. Listen for `order:manual_payment_pending` on your shop webhook. 3. When your PSP confirms payment, call `POST /dev/v1/invoices/{uniqid}/complete`. 4. Shoppex delivers the product and sends `order:paid`. See [Manual payment auto-completion](/developers/manual-payments) for a Cloudflare Worker example. ### Recurring subscription 1. Create a product with **Subscription** type. 2. Set the billing interval (monthly, yearly, and so on). 3. The customer pays, then Shoppex creates the subscription. 4. Renewals happen automatically. See [Subscriptions](/developers/subscriptions) for details. *** Deep dive into invoice lifecycle and statuses Set up real-time notifications Set up recurring billing Explore all endpoints # Quick start Source: https://docs.shoppex.io/developers/quickstart Make your first API call in under 2 minutes This page is the **generic Dev API quickstart**. If your real goal is: * **theme editing with AI** -> start with [Editing with AI](/storefront/editing-with-ai) * **theme automation endpoints** -> read [Visual themes](/storefront/visual-themes) This page is best when you want your first normal `/dev/v1/*` request. If you prefer an official SDK instead of raw `fetch` or `requests`, start with [SDKs & libraries](/developers/sdks). Shoppex has packages for Node.js/Bun (`@shoppexio/sdk`), Python (`shoppexio`), and PHP (`shoppexio/shoppex-php`). 1. Log in to [dashboard.shoppex.io](https://dashboard.shoppex.io) 2. Go to **Settings → API Keys** 3. Click **Generate New Key** 4. Copy the key (starts with `shx_`) API keys are shown only once. Store it securely before closing the dialog. This request fetches your shop information to verify that everything works: ```bash cURL theme={"system"} curl https://api.shoppex.io/dev/v1/me \ -H "Authorization: Bearer shx_your_api_key" ``` ```typescript TypeScript theme={"system"} const response = await fetch('https://api.shoppex.io/dev/v1/me', { headers: { 'Authorization': 'Bearer shx_your_api_key' } }); const { data } = await response.json(); console.log(data.name); // Your shop name ``` ```python Python theme={"system"} import requests response = requests.get( 'https://api.shoppex.io/dev/v1/me', headers={'Authorization': 'Bearer shx_your_api_key'} ) data = response.json()['data'] print(data['name']) # Your shop name ``` ```typescript JS SDK theme={"system"} import { ShoppexClient } from '@shoppexio/sdk'; const client = new ShoppexClient({ apiKey: process.env.SHOPPEX_API_KEY!, }); const me = await client.me.get(); console.log(me.data?.name); ``` ```python Python SDK theme={"system"} from shoppexio import ShoppexClient client = ShoppexClient(api_key='shx_your_api_key') me = client.me.get() print(me.data.name) ``` **Response:** ```json theme={"system"} { "data": { "id": 12345, "name": "My Awesome Shop", "currency": "USD", "created_at": 1704067200 } } ``` Now create a payment link: ```bash cURL theme={"system"} curl -X POST https://api.shoppex.io/dev/v1/payments \ -H "Authorization: Bearer shx_your_api_key" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: payment-create-order-123" \ -d '{ "title": "Order #123", "email": "customer@example.com", "value": 29.99, "currency": "USD" }' ``` ```typescript TypeScript theme={"system"} const response = await fetch('https://api.shoppex.io/dev/v1/payments', { method: 'POST', headers: { 'Authorization': 'Bearer shx_your_api_key', 'Content-Type': 'application/json' }, body: JSON.stringify({ title: 'Order #123', email: 'customer@example.com', value: 29.99, currency: 'USD' }) }); const { data } = await response.json(); console.log(data.url); // Checkout URL ``` ```python Python theme={"system"} import requests response = requests.post( 'https://api.shoppex.io/dev/v1/payments', headers={ 'Authorization': 'Bearer shx_your_api_key', 'Content-Type': 'application/json' }, json={ 'title': 'Order #123', 'email': 'customer@example.com', 'value': 29.99, 'currency': 'USD' } ) data = response.json()['data'] print(data['url']) # Checkout URL ``` ```typescript JS SDK theme={"system"} import { ShoppexClient } from '@shoppexio/sdk'; const client = new ShoppexClient({ apiKey: process.env.SHOPPEX_API_KEY!, }); const payment = await client.payments.create({ title: 'Order #123', email: 'customer@example.com', value: 29.99, currency: 'USD', }); console.log(payment.data?.url); ``` ```python Python SDK theme={"system"} from shoppexio import ShoppexClient client = ShoppexClient(api_key='shx_your_api_key') payment = client.payments.create({ 'title': 'Order #123', 'email': 'customer@example.com', 'value': 29.99, 'currency': 'USD', }) print(payment.data.url) ``` **Response:** ```json theme={"system"} { "data": { "uniqid": "abc123def456...", "url": "https://checkout.shoppex.io/invoice/abc123def456...", "url_branded": "https://checkout.shoppex.io/invoice/abc123def456...?shop=12345", "total": 29.99, "currency": "USD", "status": "PENDING", "created_at": 1704067200 } } ``` Redirect your customer to the `url` to complete payment. `POST /payments` creates a generic developer payment. It does not trigger Shoppex product fulfillment. Use it when you want a payable invoice or hosted checkout URL. Use `POST /orders` instead when you need Shoppex product line items, delivery, and automatic fulfillment. If you force `gateway: "PANDABASE"`, the response can also include: * `checkout_url`: the direct Pandabase checkout session URL * `session_id`: the Pandabase session reference Shoppex stores to match the webhook to this payment automatically If you want hosted checkout to open directly on a concrete crypto coin or network, use a merchant crypto provider together with `crypto_gateway`. Simple example: ```json theme={"system"} { "gateway": "OXAPAY", "crypto_gateway": "TRON" } ``` That returns a `url` like: ```text theme={"system"} https://checkout.shoppex.io/invoice/abc123def456...?selected_gateway=TRON&auto_start_gateway=1&auto_start_crypto=1 ``` After payment, Shoppex sends a webhook to your server: ```json theme={"system"} { "event": "order:paid", "data": { "uniqid": "inv_def456", "customer_email": "customer@example.com", "total": 29.99, "currency": "USD" } } ``` Learn how to set up and verify webhooks Implement the fulfillment callback for `DYNAMIC` products You made your first API call and created a payment. From here, most developers either hook up webhooks for automated fulfillment or explore the full endpoint reference at [API introduction](/developers/api-overview). # Reseller API Source: https://docs.shoppex.io/developers/reseller-api Programmatic wholesale purchasing for resellers of a Shoppex store. The Reseller API lets an approved reseller browse a merchant's wholesale catalog, check their balance, and buy stock programmatically. These are the same operations the Reseller tab of the customer portal exposes, but as a machine interface. It is a **separate API from the merchant Developer API**. Different base path, different credential family, different key prefix. A merchant automating their own reseller program uses [`/dev/v1/resellers`](/developers/manage-resellers) instead. | | Reseller API | Developer API | | ---------------------- | ----------------------------- | ------------------- | | Base path | `/reseller/v1` | `/dev/v1` | | Key prefix | `shxr_` | `shx_` | | Who holds the key | the reseller | the merchant | | Where keys are created | customer portal, Reseller tab | dashboard, API keys | Wholesale orders are paid from your reseller balance, not with a card. Top up your balance in the portal before ordering. This API has no payment step. *** ## Authentication Send your key as a bearer token on every request: ```bash theme={"system"} curl https://api.shoppex.io/reseller/v1/me \ -H "Authorization: Bearer shxr_your_key_here" ``` Keys are created in the customer portal of the merchant's store, under the **Reseller** tab. The full key is shown once at creation. Shoppex only stores its hash, so a lost key cannot be recovered, only replaced. Revoking a key is permanent. Open `https:///customer-portal` on the merchant's storefront, sign in with your customer account, and select the **Reseller** tab. You can create API keys and top up your balance there — see the [reseller panel guide](/developers/reseller-panel). If needed, use `https://portal.shoppex.io` as the central portal fallback. The key determines both the shop and the reseller identity. Passing a shop or reseller identifier in the request is not possible and any such parameter is ignored. ### 401 — invalid, unknown, or revoked key Returned for a missing or malformed `Authorization` header, an unrecognised key, and a revoked key. The message is always `Invalid API key.` Shoppex does not distinguish the cases, so a revoked key cannot be used to probe which keys ever existed. ### 403 — program disabled or account not active `The reseller program of this shop is not active.` when the merchant turned the program off. `This reseller account is suspended.` when the merchant suspended you. Any other non-active status returns `This reseller account cannot use the API.` ### 429 — rate limited See [Rate limits](#rate-limits). Retry after the window in `retry-after`. *** ## Errors Every failure uses one envelope: ```json theme={"system"} { "error": { "code": "VALIDATION_ERROR", "message": "Validation failed", "doc_url": "https://docs.shoppex.io/reseller-api/errors", "details": [ { "field": "per_page", "message": "per_page must be 100 or less" } ] } } ``` `details` appears only on `VALIDATION_ERROR`. `error.balance` appears only on `INSUFFICIENT_BALANCE`. | Code | Status | When | | -------------------------- | ------ | ---------------------------------------------------------------------------- | | `UNAUTHORIZED` | 401 | Missing, unknown, or revoked key | | `FORBIDDEN` | 403 | Program disabled, or reseller not `ACTIVE` | | `IDEMPOTENCY_KEY_REQUIRED` | 400 | `Idempotency-Key` missing or longer than 128 characters on order create | | `INSUFFICIENT_BALANCE` | 402 | Wholesale total is more than available balance | | `NOT_FOUND` | 404 | Generic not found | | `ORDER_NOT_FOUND` | 404 | Unknown order, or an order belonging to another reseller | | `PRODUCT_NOT_FOUND` | 404 | Product not enrolled in the program, or unknown | | `CONFLICT` | 409 | Idempotency-key reuse with a different payload, or a request still in flight | | `VALIDATION_ERROR` | 422 | Body or query failed validation | | `RATE_LIMITED` | 429 | More requests sent than the rate limit allows | | `INTERNAL_ERROR` | 500 | Unexpected server error | Every response carries an `x-request-id` header, whether it succeeds or fails. Quote it when reporting a problem to the merchant. An order belonging to a different reseller returns `ORDER_NOT_FOUND`, not `403`. Ownership is checked before existence, so the API never reveals that an order exists but is not yours. *** ## Idempotency `POST /reseller/v1/orders` **requires** an `Idempotency-Key` header. It is the only endpoint that requires one, and the only one that reads it. ```bash theme={"system"} curl -X POST https://api.shoppex.io/reseller/v1/orders \ -H "Authorization: Bearer shxr_your_key_here" \ -H "Idempotency-Key: restock-2026-07-24-001" \ -H "Content-Type: application/json" \ -d '{"items":[{"product_id":"019cb402-5555-7000-8000-000000000005","quantity":5}]}' ``` Rules: * **Same key, same payload** → the stored response is replayed. No second order, no second charge. The response carries `idempotency-status: cached`, and `data.order.reused` is `true`. * **Same key, different payload** → `409 CONFLICT`. * **Same key while the first call is still running** → `409 CONFLICT` with `idempotency-status: processing`. Retry shortly. * **Keys live 24 hours.** After that the key is free again and a reuse creates a real order. * Maximum key length is 128 characters. `X-Idempotency-Key` is accepted as a synonym. **4xx responses are cached.** A `402 INSUFFICIENT_BALANCE` replayed under the same key returns the same 402, even after you have topped up. Use a **new** Idempotency-Key after a top-up. Otherwise the retry keeps failing for 24 hours. Only `5xx` and `429` responses are never cached, so a transient outage cannot permanently poison a key. Beyond the cache, the order itself carries an idempotency hash in the database. A duplicate cannot be charged twice even if the cache is unavailable. That backstop answers the same way the cache does: the original order for the same basket, or `409 CONFLICT` for a different one. The `idempotency-status` response header tells you which path ran: `created`, `cached`, `processing`, or `bypassed`. *** ## Rate limits A token bucket per key: **30 requests burst, refilling 10 tokens every 2 seconds**. It applies uniformly to every endpoint, reads included. | Header | Meaning | | ----------------------- | ------------------------------------------------------- | | `x-ratelimit-limit` | Bucket capacity | | `x-ratelimit-remaining` | Tokens left | | `x-ratelimit-reset` | Seconds until the next refill | | `retry-after` | Seconds to wait, sent only when the request is rejected | Rejected requests return `429` with code `RATE_LIMITED`. An invalid key never consumes a token. *** ## GET /reseller/v1/me Your identity, tier, and progress toward the next tier. ```bash theme={"system"} curl https://api.shoppex.io/reseller/v1/me \ -H "Authorization: Bearer shxr_your_key_here" ``` ```json theme={"system"} { "data": { "shop": { "id": "019cb402-0000-7000-8000-000000000000", "name": "Demo Store", "slug": "demo", "currency": "EUR" }, "reseller": { "id": "019cb402-1111-7000-8000-000000000001", "status": "ACTIVE", "email": "wholesale@example.com" }, "tier": { "id": "019cb402-3333-7000-8000-000000000003", "name": "Gold", "discount_percent": 15 }, "progress": { "lifetime_revenue": "120.00", "lifetime_orders": 4, "next_tier": { "id": "019cb402-4444-7000-8000-000000000004", "name": "Platinum", "discount_percent": 25, "requirements": { "revenue": "500.00", "orders": 20, "requires_both": false }, "remaining": { "revenue": "380.00", "orders": 16 } } } } } ``` `next_tier` is `null` when no higher tier has an auto-advance threshold configured. Inside `requirements`, `revenue` and `orders` are independently nullable. The merchant can configure only one of them. `requires_both: false` means either threshold alone advances you. *** ## GET /reseller/v1/balance ```json theme={"system"} { "data": { "currency": "EUR", "balance": "200.00", "hold_balance": "10.00", "available": "190.00", "is_active": true } } ``` `available` is `balance - hold_balance` and is the figure an order is checked against. A reseller who has never topped up reads all zeros. *** ## GET /reseller/v1/products The wholesale catalog: only products the merchant enrolled in the program, priced for your tier. | Query | Type | Default | | ---------- | --------------------- | ------- | | `search` | string, max 128 chars | — | | `page` | integer ≥ 1 | `1` | | `per_page` | integer 1–100 | `25` | ```json theme={"system"} { "data": [ { "product_id": "019cb402-5555-7000-8000-000000000005", "uniqid": "prod_1", "title": "Pro License", "type": "SERIALS", "stock": 12, "supplier_backed": false, "orderable": true, "currency": "EUR", "base_price": "100.00", "wholesale_price": "85.00", "discount_percent": 15, "applied_override": "tier", "volume_discount_enabled": true, "variants": [ { "variant_id": "019cb402-6666-7000-8000-000000000006", "title": "1 month", "stock": 5, "supplier_backed": false, "orderable": true, "base_price": "50.00", "wholesale_price": "42.50", "discount_percent": 15, "applied_override": "tier" } ] } ], "pagination": { "page": 1, "per_page": 25, "total": 1 }, "currency": "EUR" } ``` `applied_override` tells you which rung of the price cascade set your price. It is `variant` (a per-variant override), `product` (a per-product override), or `tier` (your tier's flat discount). `stock: -1` means unlimited. Check `orderable`, not `stock`, before you order a row. `stock` is the merchant's own local stock. When `supplier_backed` is `true`, the merchant fulfils that product through their own supplier. The row carries no local units by design. Its real quantity limit sits with that supplier, so this API does not report a number for it. A row with `"orderable": false` is the only one `POST /reseller/v1/orders` refuses as out of stock. On a product that has variants, the top-level `orderable` is a gate for the **whole product**. It is `true` when the product itself is orderable **or** at least one of its variants is. So filtering a listing on the top-level flag never hides something you can buy. But once a product is in, still check each variant's own `orderable` to pick the option to order. `supplier_backed` is never summed up across rows: it always describes the exact row it sits on. *** ## POST /reseller/v1/orders Buys stock at wholesale price and pays from your balance in one step. Requires `Idempotency-Key`. ```json theme={"system"} { "items": [ { "product_id": "019cb402-5555-7000-8000-000000000005", "variant_id": null, "quantity": 5 } ] } ``` * 1–100 items per order, each `quantity` an integer between 1 and 100000. * `variant_id` is optional. Omit or send `null` for products without variants. Returns `201`: ```json theme={"system"} { "data": { "order": { "uniqid": "019cb402-7777-7000-8000-000000000007", "url": "https://checkout.shoppex.io/invoice/019cb402-7777-7000-8000-000000000007", "status": "COMPLETED", "currency": "EUR", "total": "425.00", "created_at": "2026-07-24T09:00:00.000Z", "reused": false, "tier": { "id": "019cb402-3333-7000-8000-000000000003", "name": "Gold", "discount_percent": 15 }, "items": [ { "product_id": "019cb402-5555-7000-8000-000000000005", "variant_id": null, "quantity": 5, "base_price": "100.00", "unit_price": "85.00", "discount_percent": 15, "line_total": "425.00", "volume_discount_applied": "0.00" } ] }, "deliverables": [ { "product_id": "019cb402-5555-7000-8000-000000000005", "product_title": "Pro License", "variant_id": null, "variant_title": null, "quantity": 5, "delivered": true, "delivered_at": "2026-07-24T09:00:01.000Z", "serials": ["CODE-1", "CODE-2", "CODE-3", "CODE-4", "CODE-5"] } ], "partially_delivered": false, "refunded_amount": null } } ``` Read the deliverables, not just the status. If the merchant ran short of stock, the order still completes for what can be delivered. The undeliverable lines come back with `delivered: false` and an empty `serials` array. `partially_delivered` becomes `true`, and `refunded_amount` carries the amount credited back to your balance. `partially_delivered` also becomes `true` when a line delivered fewer codes than it ordered. Compare `serials` against `quantity` per line rather than assuming an all-or-nothing line. `refunded_amount` is the total credited back to your balance for this order, whatever caused the refund. When your balance is too low, the call returns `402` and tells you the gap: ```json theme={"system"} { "error": { "code": "INSUFFICIENT_BALANCE", "message": "Your reseller balance is not sufficient for this order.", "doc_url": "https://docs.shoppex.io/reseller-api/errors", "balance": { "required": "425.00", "available": "190.00", "currency": "EUR" } } } ``` Top up in the portal, then retry **with a new Idempotency-Key**. The 402 is cached under the old one. *** ## GET /reseller/v1/orders | Query | Type | Default | | ---------- | ------------- | ------- | | `page` | integer ≥ 1 | `1` | | `per_page` | integer 1–100 | `25` | ```json theme={"system"} { "data": [ { "uniqid": "019cb402-7777-7000-8000-000000000007", "status": "COMPLETED", "currency": "EUR", "total": "425.00", "item_count": 1, "created_at": "2026-07-24T09:00:00.000Z" } ], "pagination": { "page": 1, "per_page": 25, "total": 1 } } ``` *** ## GET /reseller/v1/orders/ One order with its delivered codes. This is the endpoint to poll or re-read when you need the serials again. They are the same codes a normal buyer sees. ```json theme={"system"} { "data": { "uniqid": "019cb402-7777-7000-8000-000000000007", "status": "COMPLETED", "currency": "EUR", "total": "425.00", "item_count": 1, "created_at": "2026-07-24T09:00:00.000Z", "tier_name": "Gold", "tier_discount_percent": 15, "partially_delivered": false, "refunded_amount": null, "lines": [ { "product_id": "019cb402-5555-7000-8000-000000000005", "product_title": "Pro License", "variant_id": null, "variant_title": null, "quantity": 5, "unit_price": "85.00", "total": "425.00", "delivered": true, "delivered_at": "2026-07-24T09:00:01.000Z", "serials": ["CODE-1", "CODE-2", "CODE-3", "CODE-4", "CODE-5"] } ] } } ``` `serials` is always an array. It is empty when nothing has been delivered for that line, and never `null`. `delivered` is `delivered_at !== null`. *** ## Conventions * Money is always a **decimal string** (`"425.00"`), never a float. Percentages are numbers. * Timestamps in `data` are ISO 8601 strings. * Lists return `data` plus `pagination` (`page`, `per_page`, `total`). Pagination is page-based, not cursor-based. * Field names are `snake_case` throughout. # Reseller panel Source: https://docs.shoppex.io/developers/reseller-panel Where resellers manage their tier, balance, wholesale orders, and API keys. Every Shoppex store ships a reseller panel inside its customer portal. As an approved reseller you use it to check your tier and balance, buy stock at wholesale prices, track your orders, and create the API keys the [Reseller API](/developers/reseller-api) uses. Nothing needs to be installed — if the merchant runs a reseller program, the panel is already there. ## Open the panel The portal lives on the merchant's own storefront domain: ``` https:///customer-portal ``` Any storefront's **Log in** button takes you to the same place. Sign in with your customer email — the portal sends a one-time code, there is no password. Customer portal sign-in screen asking for an email address to send a one-time code Once signed in, open the **Reseller** tab. The Reseller tab appears when the merchant's program is enabled and enrollment is set to **Open** or **Application**, or when you already have a reseller relationship with the store. Invite-only programs stay hidden — invited resellers arrive through the link in their invitation email. ## Tier and balance The top of the panel shows your wholesale tier with its discount, your lifetime totals, and your prepaid reseller balance. Wholesale orders are paid from this balance — top it up with **Add funds** before ordering. Funds on hold belong to orders that are still being fulfilled. Reseller panel overview showing the Gold tier with 25 percent discount, lifetime revenue, and the prepaid reseller balance with an Add funds button ## Buy at wholesale prices The **Wholesale catalog** lists every product the merchant enrolled, at your tier price next to the struck-through retail price. Pick quantities and place the order — it is charged to your balance immediately, with no checkout step. Wholesale catalog with tier prices and discount badges, and the reseller orders list below it * **Fulfilled by supplier** rows carry no local stock; the supplier delivers them as part of your order. * If part of an order cannot be supplied, the unfilled amount is refunded to your balance automatically. **Reseller orders** below the catalog keeps the full history. Open an order to see its line items and the delivered serials. ## API keys The **API keys** section issues credentials for the [Reseller API](/developers/reseller-api) — the machine interface to the same catalog, balance, and ordering operations. Keys are prefixed `shxr_`, shown once at creation, and you can hold up to 5 active keys. Revoking a key is permanent. Reseller orders history and the API keys section with masked keys, their status, and a New key button Selling on your own Shoppex store? You do not need the API for fulfillment: connect your product to the supplier's product in your dashboard and each sale places the wholesale order automatically. See [Dynamic delivery](/developers/dynamic-delivery). # SDKs & libraries Source: https://docs.shoppex.io/developers/sdks Official and community SDKs for the Shoppex API ## Official SDKs Official JavaScript/TypeScript SDK for `/dev/v1/*`. ```bash theme={"system"} npm install @shoppexio/sdk ``` JavaScript storefront SDK for products, carts, and hosted checkout. ```bash theme={"system"} npm install @shoppexio/storefront ``` Matching Developer API packages for backend integrations. ```bash theme={"system"} pip install shoppexio composer require shoppexio/shoppex-php ``` *** ## Choosing an SDK | What you are building | SDK | | -------------------------------------- | -------------------------------------------------------- | | Node/Bun backend calling `/dev/v1/*` | `@shoppexio/sdk` | | Python worker syncing customers | `shoppexio` | | Laravel/PHP backend integration | `shoppexio/shoppex-php` | | Frontend with products, cart, checkout | `@shoppexio/storefront` | | Language without an official SDK | Raw HTTP with the [OpenAPI spec](#openapi-specification) | `@shoppexio/sdk` is the server-side Developer API SDK. It has no CDN or browser global build and must not be bundled into customer-facing frontend code because it uses API keys. Browser storefronts use `@shoppexio/storefront` or the Storefront SDK CDN instead. For example, a Node backend creating orders uses `@shoppexio/sdk`, a Python cron job reading customers uses `shoppexio`, and a Laravel admin tool rotating webhooks uses `shoppexio/shoppex-php`. ## Quick examples ```typescript JavaScript / TypeScript theme={"system"} import { ShoppexClient } from '@shoppexio/sdk'; const client = new ShoppexClient({ apiKey: process.env.SHOPPEX_API_KEY!, }); const products = await client.products.list(); console.log(products.data[0]?.title); await client.invoices.issueReplacement('019c525f-3333-7333-8333-333333333333', { quantity: 1, product_id: 'prod_1', reason: 'previous replacement failed', override_remaining_quantity: true, }, { idempotencyKey: 'replacement-019c525f-3333-7333-8333-333333333333-1', }); ``` ```python Python theme={"system"} from shoppexio import ShoppexClient client = ShoppexClient(api_key="shx_your_api_key") products = client.products.list() print(products.data[0].title if products.data else "No products") ``` ```php PHP theme={"system"} 'shx_your_api_key']); $products = $client->products()->list(); echo $products->data[0]->title ?? 'No products'; ``` ## Public repositories * JavaScript Developer API SDK: [ShoppexIO/sdk-js](https://github.com/ShoppexIO/sdk-js) * JavaScript Storefront SDK: [ShoppexIO/storefront-js](https://github.com/ShoppexIO/storefront-js) * Python SDK: [ShoppexIO/sdk-python](https://github.com/ShoppexIO/sdk-python) * PHP SDK: [ShoppexIO/sdk-php](https://github.com/ShoppexIO/sdk-php) *** ## REST API The REST API works directly with any HTTP client, no SDK required. Every endpoint in the [API introduction](/developers/api-overview) includes auto-generated code examples for **cURL**, **Python**, **JavaScript**, **PHP**, **Go**, **Java**, and **Ruby**. *** ## OpenAPI specification Generate your own client using our OpenAPI spec: ``` https://docs.shoppex.io/openapi.json ``` Use tools like: * [openapi-generator](https://openapi-generator.tech/) — generate clients for 50+ languages * [Postman](https://www.postman.com/) — import for API testing and exploration *** ## Webhooks For webhook integration, see our [Webhooks Guide](/developers/webhooks) which covers: * Setting up webhook endpoints * Verifying signatures * Handling events * Retry logic Complete list of webhook events and payloads # Storefront SDK Source: https://docs.shoppex.io/developers/storefront-sdk/overview Build headless or embedded storefronts with the browser-safe Shoppex Storefront SDK The Storefront SDK builds custom storefronts outside the Shoppex hosted theme runtime, for example a Next.js site, a plain HTML embed, or an app with its own checkout handoff. Hosted Shoppex storefronts use ThemeDocuments, not this SDK. Start at [Themes](/storefront/themes) for hosted theme work. ## Package names Use `@shoppexio/storefront` in browser and headless storefront code. Use `@shoppexio/sdk` only on trusted servers that call the Developer API. Simple example: * Browser product grid: `@shoppexio/storefront` * Backend order automation: `@shoppexio/sdk` * Hosted Shoppex theme: ThemeDocument plus the platform commerce runtime Do not ship a secret `shx_...` API key in browser code. `@shoppexio/sdk` is for trusted backend code only. ## What the SDK does Fetch store metadata, products, groups, reviews, and listing data from public storefront endpoints. Add, update, and remove cart items. Cart data stays saved locally in the browser. Create a checkout handoff and redirect customers to Shoppex hosted checkout. Works with React, Vue, Svelte, Astro, plain HTML, or any browser runtime. ## Install the SDK Use the CDN build for the fastest start, with one script tag and no build step. Use npm instead if you have a bundler and want TypeScript types. ```html theme={"system"} ``` For ESM imports without npm, load the module build directly. ```html theme={"system"} ``` Install the SDK as a dependency with npm, yarn, or pnpm. ```bash npm theme={"system"} npm install @shoppexio/storefront ``` ```bash yarn theme={"system"} yarn add @shoppexio/storefront ``` ```bash pnpm theme={"system"} pnpm add @shoppexio/storefront ``` Then import and use the SDK. ```typescript theme={"system"} import shoppex from '@shoppexio/storefront'; shoppex.init('your-store'); ``` ## Initialize and run your first request After you load the SDK, initialize it with your store slug. ```javascript theme={"system"} shoppex.init('your-store', { locale: 'en', // Optional: default language currency: 'USD', // Optional: default currency }); ``` Your store's unique identifier. Find this in your Shoppex dashboard under Settings. Default language for the SDK. Affects price formatting and checkout language. Override the store's default currency. Must be a valid ISO 4217 currency code. Initialize the SDK, fetch the store's products, then add the first one to the cart. ```javascript theme={"system"} shoppex.init('your-store'); const { data } = await shoppex.getStorefront(); data.products.forEach((product) => { console.log(product.title, product.price); }); shoppex.addToCart(data.products[0].uniqid, '', 1); ``` This loads every product in the store and adds the first one to the cart with no variant selected. See the [reference](/developers/storefront-sdk/reference) for other ways to fetch products and the full set of cart options. A full flow, from catalog read to checkout, looks like this. ```html theme={"system"} ``` Check that the SDK loaded and initialized before you call other methods. ```javascript theme={"system"} // Check if SDK is loaded if (typeof shoppex !== 'undefined') { console.log('SDK loaded'); } // Check if SDK is initialized if (shoppex.isInitialized()) { console.log('SDK initialized for:', shoppex.getConfig().storeSlug); } ``` ## TypeScript and browser support The SDK includes TypeScript definitions. Import types directly. ```typescript theme={"system"} import shoppex, { type Product, type CartItem, type ShoppexConfig } from '@shoppexio/storefront'; ``` The SDK supports all modern browsers. | Browser | Minimum version | | ------- | --------------- | | Chrome | 60+ | | Firefox | 55+ | | Safari | 12+ | | Edge | 79+ | Internet Explorer is not supported. The SDK uses modern JavaScript features such as Promises, async/await, and ES6+ syntax. ## Use the Developer API for server work The Storefront SDK is browser-safe and public. It does not replace the Developer API. Use the Developer API from your server when you need to: * create catalog-backed orders * manage customers * validate licenses * fulfill orders * manage webhooks * read private operational data # Storefront SDK reference Source: https://docs.shoppex.io/developers/storefront-sdk/reference Complete method and type reference for the Shoppex Storefront SDK This page lists every method and type in `@shoppexio/storefront`, grouped by object: Store, Products, Cart, Checkout, Reviews and invoices, and Types. All async SDK methods return `SDKResponse`, a wrapper containing `data`, `error`, and `status` fields. See [Response types](#response-types) for the full definition. ## Store The Store object gives access to your shop's public information, including name, branding, and settings. For simple cases such as displaying the store logo, use `getStoreLogoUrl()` or `getStoreBannerUrl()`. They are lightweight wrappers that avoid fetching the full store metadata. ### getStore Fetches the store's public metadata. ```javascript theme={"system"} const { data: store } = await shoppex.getStore(); console.log(store.name); // "My Awesome Store" console.log(store.currency); // "USD" ``` **Response** Unique store identifier Store display name URL-friendly store identifier Custom domain if configured Store description Default currency (ISO 4217) Logo image URL Banner image URL Average store rating (1-5) Whether terms of service are enabled Social media links Discord URL Twitter URL Instagram URL Facebook URL Telegram URL YouTube URL Reddit URL TikTok URL **Example: display store header** ```javascript theme={"system"} async function renderStoreHeader() { const { data: store } = await shoppex.getStore(); document.getElementById('store-header').innerHTML = `

${store.name}

${store.rating ? `${store.rating.toFixed(1)} / 5` : ''} `; } ``` ### getStoreLogoUrl Returns the store's logo URL directly. ```javascript theme={"system"} const logoUrl = await shoppex.getStoreLogoUrl(); if (logoUrl) { document.getElementById('logo').src = logoUrl; } ``` **Response** Logo URL or `null` if no logo is set ### getStoreBannerUrl Returns the store's banner URL directly. ```javascript theme={"system"} const bannerUrl = await shoppex.getStoreBannerUrl(); if (bannerUrl) { document.getElementById('hero').style.backgroundImage = `url(${bannerUrl})`; } ``` **Response** Banner URL or `null` if no banner is set ### Store error handling ```javascript theme={"system"} try { const { data, success, message } = await shoppex.getStore(); if (!success) { console.error('Failed to load store:', message); return; } // Use store data } catch (error) { if (error.name === 'NotInitializedError') { console.error('SDK not initialized. Call shoppex.init() first.'); } else if (error.name === 'NetworkError') { console.error('Network error. Check your connection.'); } } ``` ## Products Fetch products from your store, including variants, addons, and custom fields. ### getProducts Fetches all products from the store. ```javascript theme={"system"} const { data: products } = await shoppex.getProducts(); products.forEach(product => { console.log(product.title, product.price); }); ``` **Response** Array of products Unique product identifier Product name Base price as string (for precision). Use `shoppex.formatPrice()` to display. Currency code (ISO 4217) URL-friendly product identifier Full product description (HTML) Merchant-defined extra description tabs (for example, Features, Specifications). `content` is HTML. Empty array when the product has no extra tabs. Array of product images Optimized cover image for cards, listings, search results, and other non-zoomed UI. High-resolution primary image for product detail pages, galleries, and zoom views. Optional product video URL (YouTube, Streamable, or Vimeo). `null` when no video is configured. Available variants (for example, size, color) Optional add-ons Price-based variants (for example, subscription tiers) Custom input fields for checkout Available stock quantity Product categories as objects with `uniqid` and `title` Product prices are returned as `string` types to preserve decimal precision. Always use `shoppex.formatPrice()` for display. Do not calculate directly with price strings. Use product images by surface: * `cdn_image_url` for product cards, category grids, cart rows, and search results * `detail_image_url` for product detail pages, image galleries, and zoom * `images[]` for the full gallery Simple example: if your custom storefront currently renders the PDP hero from `cdn_image_url`, switch that hero to `detail_image_url` to get the higher-resolution image. **Example: product grid** ```javascript theme={"system"} async function renderProducts() { const { data: products } = await shoppex.getProducts(); const container = document.getElementById('products'); container.innerHTML = products.map(product => `
${product.title}

${product.title}

${shoppex.formatPrice(product.price, product.currency)} ${product.stock < 10 ? 'Only ' + product.stock + ' left!' : ''}
`).join(''); } ``` ### getProduct Fetches a single product by ID or slug. ```javascript theme={"system"} const { data: product } = await shoppex.getProduct('prod_abc123'); console.log(product.title); console.log(product.variants); ``` **Parameters** Product unique ID or URL slug **Example: product detail page** ```javascript theme={"system"} async function renderProductDetail(productId) { const { data: product } = await shoppex.getProduct(productId); const galleryImages = product.images?.length ? product.images : [{ url: product.detail_image_url || product.cdn_image_url, alt: product.title }].filter(img => img.url); document.getElementById('product-detail').innerHTML = `

${product.title}

${shoppex.formatPrice(product.price, product.currency)}

${product.description}
${product.variants?.length ? ` ` : ''} ${product.video_link ? `
` : ''}
`; } ``` ### getCategories Fetches all unique product category IDs from your store. ```javascript theme={"system"} const { data: categories } = await shoppex.getCategories(); // Returns array of category uniqids (strings) // ["cat_abc123", "cat_def456", "cat_ghi789"] ``` **Example: category filter** ```javascript theme={"system"} async function renderCategoryFilter() { const { data: categories } = await shoppex.getCategories(); document.getElementById('category-filter').innerHTML = ` `; } async function filterByCategory(category) { const { data: products } = await shoppex.getProducts(); const filtered = category ? products.filter(p => p.categories?.includes(category)) : products; renderProducts(filtered); } ``` ### Product groups Stores can organize products into groups (for example, "Server Boosts", "Tokens"). Groups come from `getStorefront()`. ```javascript theme={"system"} const { data: storefront } = await shoppex.getStorefront(); storefront.groups.forEach(group => { console.log(group.title, group.products_count); }); ``` Breaking change in `@shoppexio/storefront` 1.0.0 (and the underlying storefront API): groups no longer embed full product objects in `products_bound`. Each group now carries `product_uniqids`, an array of product references, and every public product, standalone and group-bound, appears exactly once in the flat products list. If your integration reads `group.products_bound`, it sees `undefined` and must migrate to the lookup pattern below. **Group object** Unique group identifier Group name References to the group's products. Resolve them against the flat products list. The full product objects are not embedded in the group. Number of products in the group Display order of the group **Resolving group products** Build a lookup from the flat products list and resolve each group's references. ```javascript theme={"system"} const { data: storefront } = await shoppex.getStorefront(); const byUniqid = new Map(storefront.products.map(p => [p.uniqid, p])); storefront.groups.forEach(group => { const groupProducts = (group.product_uniqids ?? []) .map(uniqid => byUniqid.get(uniqid)) .filter(Boolean); console.log(group.title, groupProducts.map(p => p.title)); }); ``` If you install the SDK from npm, the same resolution is available as a named import. ```javascript theme={"system"} import { getStorefrontGroupProducts } from '@shoppexio/storefront'; const groupProducts = getStorefrontGroupProducts(group, storefront.products); ``` Migrating from 0.3.x? Replace every `group.products_bound` read with the lookup above. `getProducts()` now returns the complete flat catalog, group-bound products included, so you no longer need to merge group products into your listing yourself. If you call the REST API directly, `/v1/storefront/products/public/:slug` groups carry `product_uniqids`. The `/v1/storefront/products/shop/:name` endpoint no longer returns groups at all. Read groups from the public catalog or bootstrap payload instead. ### Working with variants Products can have multiple variant types. **Standard variants** Variants such as size or color that do not change the price. ```javascript theme={"system"} const product = await shoppex.getProduct('prod_abc'); product.variants.forEach(variant => { console.log(variant.id, variant.title); // "var_1", "Small" // "var_2", "Medium" // "var_3", "Large" }); ``` **Price variants** Variants that have different prices. ```javascript theme={"system"} product.price_variants.forEach(pv => { console.log(pv.id, pv.label, pv.price); // "pv_1", "Basic", 9.99 // "pv_2", "Pro", 29.99 // "pv_3", "Enterprise", 99.99 }); ``` **Addons** Optional extras the customer can add. ```javascript theme={"system"} product.addons.forEach(addon => { console.log(addon.id, addon.name, addon.price, addon.required); // "addon_1", "Priority Support", 5.00, false // "addon_2", "Extended Warranty", 10.00, false }); ``` ### Products error handling ```javascript theme={"system"} const { data, success, message } = await shoppex.getProduct('invalid-id'); if (!success) { console.error('Product not found:', message); // Show 404 page or redirect } ``` ## Cart The Cart object manages shopping cart state in the browser's localStorage. Cart data stays saved across page refreshes and browser sessions. Cart data is stored in the browser's `localStorage`. If the user clears browser storage, the cart is lost. ### getCart Returns all items currently in the cart. ```javascript theme={"system"} const cartItems = shoppex.getCart(); cartItems.forEach(item => { console.log(item.product_id, item.quantity); }); ``` **Response** Product unique identifier Selected variant ID (empty string if no variant) Item quantity Selected add-ons Custom field values Selected price variant ID ### getCartItemCount Returns the total number of items in the cart. ```javascript theme={"system"} const count = shoppex.getCartItemCount(); document.getElementById('cart-badge').textContent = count; ``` ### addToCart Adds an item to the cart, or increments quantity if it already exists. ```javascript theme={"system"} // Basic usage shoppex.addToCart('prod_abc123', 'var_001', 1); // With options shoppex.addToCart('prod_abc123', 'var_001', 2, { addons: [{ id: 'addon_1', quantity: 1 }], custom_fields: { 'License Name': 'John Doe' }, price_variant_id: 'pv_pro' }); ``` **Parameters** Product unique identifier Variant ID. Use empty string `''` for products without variants. Number of items to add Add-ons to include Custom field values Selected price variant **Example: add to cart button** ```javascript theme={"system"} function handleAddToCart(productId, variantId) { const quantity = parseInt(document.getElementById('quantity').value) || 1; // Collect selected addons const addons = []; document.querySelectorAll('.addon-checkbox:checked').forEach(cb => { addons.push({ id: cb.value, quantity: 1 }); }); shoppex.addToCart(productId, variantId, quantity, { addons }); // Update UI updateCartBadge(); showNotification('Added to cart!'); } ``` ### updateCartItem Updates an existing cart item. ```javascript theme={"system"} shoppex.updateCartItem('prod_abc123', 'var_001', { quantity: 5, addons: [{ id: 'addon_2', quantity: 1 }] }); ``` **Parameters** Product unique identifier Variant ID New quantity Updated add-ons Updated custom fields ### removeFromCart Removes an item from the cart. ```javascript theme={"system"} shoppex.removeFromCart('prod_abc123', 'var_001'); ``` **Parameters** Product unique identifier Variant ID ### clearCart Removes all items from the cart. ```javascript theme={"system"} shoppex.clearCart(); ``` ### Cart backup The SDK can back up the cart before checkout, to restore it if checkout is cancelled. **createCartBackup** ```javascript theme={"system"} // Called automatically before checkout shoppex.createCartBackup(); ``` **restoreCartFromBackup** ```javascript theme={"system"} // Restore cart after cancelled checkout const restored = shoppex.restoreCartFromBackup(); if (restored) { console.log('Cart restored'); } else { console.log('No backup available'); } ``` ### Complete cart UI example ```html theme={"system"}

Shopping Cart

``` ## Checkout The Checkout object redirects customers to Shoppex hosted checkout. Checkout is fully hosted by Shoppex for PCI compliance. ### checkout Redirects the customer to the checkout page with their cart contents. ```javascript theme={"system"} await shoppex.checkout(); ``` **Parameters** Pre-applied coupon code Automatically redirect to checkout page Override checkout language **Example: checkout with coupon** ```javascript theme={"system"} const couponCode = document.getElementById('coupon-input').value; if (couponCode) { // Validate coupon first const { data } = await shoppex.validateCoupon(couponCode); if (!data.valid) { alert('Invalid coupon code'); return; } } // Proceed to checkout await shoppex.checkout(couponCode); ``` ### buildCheckoutUrl Builds the checkout URL without redirecting. Use this to open checkout in a new tab or iframe. ```javascript theme={"system"} const checkoutUrl = await shoppex.buildCheckoutUrl('SAVE10', { locale: 'de' }); console.log(checkoutUrl); // https://yourstore.shoppex.io/checkout?coupon=SAVE10&locale=de&cart=... ``` **Parameters** Coupon code to pre-apply Checkout language (for example, 'en', 'de', 'fr') **Example: open checkout in new tab** ```javascript theme={"system"} async function openCheckoutInNewTab() { const url = await shoppex.buildCheckoutUrl(); window.open(url, '_blank'); } ``` ### buildCheckoutUrlSync (deprecated) `buildCheckoutUrlSync` is deprecated and throws immediately. Use `buildCheckoutUrl()` instead. It handles both default and custom domains. ### Coupons **validateCoupon** Validates a coupon code before checkout. Affiliate and referral codes are separate from coupons. Use `validateAffiliateCode` or `applyAffiliateCode` for those instead. ```javascript theme={"system"} const { data } = await shoppex.validateCoupon('SAVE10', { productId: 'prod_abc123', variantId: 'variant_lifetime', }); if (data.valid) { console.log('Discount:', data.discount, data.discount_type); // 10, "percentage" → 10% off // 5.00, "fixed" → $5 off } else { console.log('Invalid or expired coupon'); } ``` **Parameters** Coupon code to validate Product ID to check product-specific coupons Selected variant ID to check variant-specific coupons. Requires `options.productId`. **Response** Whether the coupon is valid Discount amount Either "percentage" or "fixed" Whether the coupon is restricted to selected products Whether the coupon is restricted to selected variants One of "all", "products", "variants", or "products\_and\_variants" Public product IDs the coupon can apply to Variant IDs the coupon can apply to ### Affiliate codes **validateAffiliateCode** Validates an affiliate or referral code without storing it. ```javascript theme={"system"} const result = await shoppex.validateAffiliateCode('creator10'); if (result.success && result.data.valid) { console.log(result.data.affiliate_code); } else if (result.data?.program_enabled === false) { console.log('Affiliate program is disabled for this shop'); } ``` **applyAffiliateCode** Validates and stores the normalized affiliate code. `checkout()` sends the stored code as `affiliate_code`. ```javascript theme={"system"} const affiliate = await shoppex.applyAffiliateCode('creator10'); await shoppex.checkout({ coupon: 'SAVE10', affiliateCode: affiliate.data?.affiliate_code, }); ``` **Example: coupon input with validation** ```html theme={"system"}
``` ### Checkout flow ``` ┌─────────────────────────────────────────────────────────────────┐ │ Your Website │ ├─────────────────────────────────────────────────────────────────┤ │ 1. Customer browses products │ │ 2. Adds items to cart (SDK stores in localStorage) │ │ 3. Clicks "Checkout" │ │ 4. SDK calls shoppex.checkout() │ └─────────────────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────────┐ │ Shoppex Checkout │ │ (yourstore.shoppex.io) │ ├─────────────────────────────────────────────────────────────────┤ │ 5. Customer enters email, billing info │ │ 6. Selects payment method (Stripe, PayPal, Crypto) │ │ 7. Completes payment │ │ 8. Receives confirmation + delivery │ └─────────────────────────────────────────────────────────────────┘ ``` ### After checkout After successful checkout, the cart is automatically cleared. If you need to handle the return, read the order ID from the URL. ```javascript theme={"system"} // Check URL for order confirmation const urlParams = new URLSearchParams(window.location.search); const orderId = urlParams.get('order'); if (orderId) { // Customer returned from successful checkout showOrderConfirmation(orderId); } ``` ## Reviews and invoices This section documents two separate objects: shop reviews and invoice status. ### getShopReviews Fetches all public reviews for the store. ```javascript theme={"system"} const { data: reviews } = await shoppex.getShopReviews(); reviews.forEach(review => { console.log(review.rating, review.comment); }); ``` **Response** Review unique identifier Star rating (1-5) Review text ISO timestamp **Example: reviews section** ```javascript theme={"system"} async function renderReviews() { const { data: reviews } = await shoppex.getShopReviews(); const container = document.getElementById('reviews'); // Calculate average const avgRating = reviews.reduce((sum, r) => sum + r.rating, 0) / reviews.length; container.innerHTML = `

Customer Reviews

${'★'.repeat(Math.round(avgRating))}${'☆'.repeat(5 - Math.round(avgRating))} ${avgRating.toFixed(1)} / 5 (${reviews.length} reviews)
${reviews.map(review => `
${'★'.repeat(review.rating)}${'☆'.repeat(5 - review.rating)}

${review.comment || ''}

${new Date(review.created_at).toLocaleDateString()}
`).join('')}
`; } ``` ### getInvoice Fetches full invoice details. ```javascript theme={"system"} const { data: invoice } = await shoppex.getInvoice('inv_abc123'); console.log(invoice.status); // "COMPLETED" console.log(invoice.total); // 29.99 console.log(invoice.products); // [{ title, quantity, price }] ``` **Response** Invoice unique identifier Invoice status (PENDING, COMPLETED, CANCELLED, and more) Total amount Currency code Purchased products ### getInvoiceStatus Lightweight endpoint for status polling. Use this instead of `getInvoice` for real-time updates. ```javascript theme={"system"} const { data } = await shoppex.getInvoiceStatus('inv_abc123'); console.log(data.status); // "PENDING" | "COMPLETED" | "CANCELLED" ``` **Example: order status page** ```javascript theme={"system"} async function pollOrderStatus(invoiceId) { const statusEl = document.getElementById('order-status'); const checkStatus = async () => { const { data } = await shoppex.getInvoiceStatus(invoiceId); switch (data.status) { case 'PENDING': statusEl.innerHTML = 'Waiting for payment...'; break; case 'PROCESSING': statusEl.innerHTML = 'Processing payment...'; break; case 'COMPLETED': statusEl.innerHTML = 'Order complete! Check your email.'; clearInterval(pollInterval); break; case 'CANCELLED': statusEl.innerHTML = 'Order cancelled'; clearInterval(pollInterval); break; } }; // Poll every 5 seconds const pollInterval = setInterval(checkStatus, 5000); checkStatus(); // Initial check } // Get invoice ID from URL const invoiceId = new URLSearchParams(window.location.search).get('invoice'); if (invoiceId) { pollOrderStatus(invoiceId); } ``` ### Formatting utilities **formatPrice** Formats a price with a currency symbol. ```javascript theme={"system"} shoppex.formatPrice(29.99, 'USD'); // "$29.99" shoppex.formatPrice(29.99, 'EUR'); // "€29.99" shoppex.formatPrice(29.99, 'EUR', 'de'); // "29,99 €" ``` **Parameters** Price amount ISO 4217 currency code Locale for formatting **createFormatter** Creates a reusable `Intl.NumberFormat` instance. ```javascript theme={"system"} const formatter = shoppex.createFormatter('EUR', 'de'); formatter.format(29.99); // "29,99 €" formatter.format(100); // "100,00 €" ``` ## Customer account Calls for a signed-in customer: sign-in, order history, licenses, subscriptions, support and profile. They work only on a storefront served on the merchant's own domain by Shoppex, because that is where the session lives. The session is an HttpOnly cookie set by the Shoppex edge, and the shop is derived from the host. So none of these calls take or return a session token, and none of them take a shop. Do not send either. | Call | Endpoint | Purpose | | ------------------------------------------------------------------------------------------------ | ----------------------------------------------------------- | --------------------------------------------------------- | | `requestOtp(email)` | `POST /api/customer/auth/otp/request` | Email a single-use sign-in code | | `verifyOtp(email, otp)` | `POST /api/customer/auth/otp/verify` | Complete sign-in. The cookie arrives with the response | | `logout()` | `POST /api/customer/auth/logout` | End the session | | `me()` | `GET /api/customer/me` | Who is signed in, if anyone | | `dashboard()` | `GET /api/customer/dashboard` | Overview: customer, counters, store credit, recent orders | | `orders({ page, limit })` | `GET /api/customer/invoices` | Order history, one page at a time | | `order(uniqid)` | `GET /api/customer/invoice/` | One order with line items, delivery and payment | | `resetLicenseHwid(uniqid)` | `POST /api/customer/licenses//reset-hwid` | Unbind a licence from its device | | `subscriptionBillingHistory(uniqid)` | `GET /api/customer/subscriptions//billing-history` | Past charges for one subscription | | `cancelSubscription(uniqid, options)`, `pauseSubscription(uniqid)`, `resumeSubscription(uniqid)` | `POST /api/customer/subscriptions//…` | Change a subscription's standing | | `favorites()`, `addFavorite(productUniqid)`, `removeFavorite(productUniqid)` | `GET`, `PUT` and `DELETE /api/customer/favorites` | Saved products. Adding is a `PUT`, not a `POST` | | `affiliate()` | `GET /api/customer/affiliate` | Referral summary | | `createTicket(payload)`, `ticket(uniqid)`, `replyToTicket(uniqid, message)` | `POST` and `GET /api/customer/tickets…` | Support conversations | | `updateProfile({ name })` | `POST /api/customer/profile` | Change the display name | | `updateAvatar(file)`, `removeAvatar()` | `POST` and `DELETE /api/customer/profile/avatar` | Profile photo | | `emailPreferences()`, `updateEmailPreferences(patch)` | `GET` and `PATCH /api/customer/email-marketing/preferences` | Marketing opt-in and per-list subscriptions | | `sessions()`, `revokeSession(id)`, `revokeAllSessions()` | `GET` and `DELETE /api/customer/sessions` | Signed-in devices | **Licences, subscriptions and tickets have no list call.** Those three collections arrive inside `dashboard()`; the endpoints above act on one item you already have an id for. A `GET /api/customer/licenses` does not exist and answers `404` at the edge. ```javascript theme={"system"} await shoppex.requestOtp('buyer@example.com'); const signedIn = await shoppex.verifyOtp('buyer@example.com', '123456'); if (signedIn.success) { const { data } = await shoppex.dashboard(); console.log(data.customer.email, data.stats.invoices_count); } ``` Order history is page based. `has_more` says whether a next page exists; there is no cursor. ```javascript theme={"system"} const { data } = await shoppex.orders({ page: 1, limit: 10 }); data.invoices.forEach((order) => console.log(order.uniqid, order.status)); console.log(data.pagination.has_more); ``` The endpoint list above is complete. Endpoints outside it, including license renewal, affiliate payouts, the subscription payment-method portal, reseller surfaces and wallet transfers, answer `404` at the edge. Link customers to the hosted flow for those. Building the account pages into a code storefront, including routing and the states to handle: [Customer accounts](/storefront/code-storefront-accounts). ## Types The SDK is written in TypeScript and exports all type definitions. Install with npm to get full IntelliSense support. ### Configuration types ```typescript theme={"system"} interface ShoppexConfig { storeSlug: string; locale?: string; currency?: string; apiBaseUrl?: string; } interface ShoppexInitOptions { locale?: string; currency?: string; apiBaseUrl?: string; } ``` ### Response types ```typescript theme={"system"} interface SDKResponse { success: boolean; data?: T; message?: string; } ``` ### Store types ```typescript theme={"system"} interface Shop { id: string; name: string; slug: string; domain?: string; description?: string; currency: string; logo?: string; banner?: string; rating?: number; tos_enabled?: boolean; } ``` ### Product types ```typescript theme={"system"} interface ProductCategory { uniqid: string; title: string; } interface Product { uniqid: string; title: string; slug?: string; description?: string; price: string; // String for precision price_display?: string; // Formatted display price currency: string; stock?: number; cdn_image_url?: string | null; // Card/listing/search image detail_image_url?: string | null; // PDP/gallery/zoom image images: ProductImage[]; variants?: ProductVariant[]; addons?: ProductAddon[]; price_variants?: PriceVariant[]; custom_fields?: string | unknown[] | null; categories?: ProductCategory[]; } interface ProductImage { id: string; url: string; cloudflare_image_id?: string; alt?: string; } interface ProductVariant { id: string; title: string; price?: number; stock?: number; is_featured?: boolean; } interface ProductAddon { id: string; name: string; price: number; required?: boolean; } interface PriceVariant { id: string; label: string; price: number; is_featured?: boolean; } interface CustomFieldDefinition { id: string; name: string; type: 'text' | 'textarea' | 'select' | 'checkbox'; required?: boolean; options?: string[]; } ``` `is_featured` on `ProductVariant` and `PriceVariant` marks the one variant the merchant wants to present as “Most Popular.” At most one variant per product carries it. It does not change the selected variant, price, stock, or checkout behavior. Image fields have different jobs: * `cdn_image_url` is the optimized storefront cover for cards and lists * `detail_image_url` is the higher-resolution primary image for product detail pages * `images[]` contains the gallery If you run a headless storefront, keep cards on `cdn_image_url` and switch your PDP hero and gallery to `detail_image_url`. ### Cart types ```typescript theme={"system"} interface CartItem { product_id: string; variant_id: string; quantity: number; addons?: CartAddon[]; custom_fields?: Record; price_variant_id?: string; } interface CartAddon { id: string; quantity?: number; } interface CartAddOptions { addons?: CartAddon[]; custom_fields?: Record; price_variant_id?: string; } interface CartUpdateOptions { quantity?: number; addons?: CartAddon[]; custom_fields?: Record; price_variant_id?: string; } ``` ### Checkout types ```typescript theme={"system"} interface CheckoutOptions { autoRedirect?: boolean; locale?: string; } interface CheckoutResult { success: boolean; redirectUrl?: string; message?: string; } interface CouponValidation { valid: boolean; discount?: number; discount_type?: 'percentage' | 'fixed'; message?: string; } ``` ### Invoice types ```typescript theme={"system"} interface Invoice { uniqid: string; status: string; total: number; currency: string; gateway?: string; products: InvoiceProduct[]; created_at: string; } interface InvoiceProduct { product_id: string; title: string; quantity: number; price: number; } ``` ### Review types ```typescript theme={"system"} interface Feedback { id: string; rating: number; comment?: string; created_at: string; } ``` ### Error types ```typescript theme={"system"} class ShoppexError extends Error { readonly code: string; readonly statusCode?: number; } class NotInitializedError extends ShoppexError { // Thrown when SDK methods are called before init() // code: 'NOT_INITIALIZED' } class NetworkError extends ShoppexError { // Thrown on HTTP/network failures // code: 'NETWORK_ERROR' } class ValidationError extends ShoppexError { // Thrown on input validation failures // code: 'VALIDATION_ERROR' readonly invalidFields?: string[]; } class CartError extends ShoppexError { // Thrown on cart operation errors // code: 'BASKET_ERROR' } ``` ### Usage with TypeScript ```typescript theme={"system"} import shoppex, { type Product, type ProductVariant, type CartItem, type Invoice, type SDKResponse } from '@shoppexio/storefront'; // Initialize shoppex.init('my-store'); // Typed responses async function loadProducts(): Promise { const response: SDKResponse = await shoppex.getProducts(); if (!response.success || !response.data) { throw new Error(response.message || 'Failed to load products'); } return response.data; } // Type-safe cart operations function addProductToCart(product: Product, variant?: ProductVariant): void { shoppex.addToCart( product.uniqid, variant?.id ?? '', 1 ); } // Typed invoice handling async function checkOrderStatus(invoiceId: string): Promise { const { data } = await shoppex.getInvoiceStatus(invoiceId); return data.status; } ``` # Subscriptions Source: https://docs.shoppex.io/developers/subscriptions Set up recurring billing and subscription management Shoppex handles recurring payments automatically. Create subscription products, and Shoppex manages renewals, retries, and cancellations. This guide is about the subscription flow itself. For Developer API endpoints and webhook event names, use the API reference and webhook docs. ## Creating a subscription product Go to **Products → Create Product** and select **Subscription** as the product type. Set the billing interval: * **Daily** — charged every day * **Weekly** — charged every 7 days * **Monthly** — charged on the same day each month * **Yearly** — charged annually Offer a free trial before the first charge: * 7-day trial * 14-day trial * 30-day trial * Custom duration ## Handling failed payments When a renewal payment fails, Shoppex follows four steps: 1. **Retries automatically** — 3 attempts over 7 days 2. **Notifies the customer** — sends an email with a payment update link 3. **Marks as `past_due`** — the subscription continues during the grace period 4. **Cancels** — after all retries fail Configure retry behavior in **Settings → Subscriptions**. ## Customer portal Give customers control over their subscriptions through the Shoppex billing portal. Each customer receives a unique portal link in their confirmation emails where they can: * View billing history * Update payment method * Cancel subscription * Download invoices The customer portal link is included in all subscription-related emails sent to customers. ## Webhooks Subscribe to these events for subscription updates: | Event | Description | | ------------------------ | ------------------------ | | `subscription:created` | New subscription started | | `subscription:cancelled` | Subscription cancelled | Shoppex supports more subscription webhook events than the short table above, including `subscription:updated`, `subscription:renewed`, `subscription:upcoming`, and trial events. See the [Webhooks Guide](/developers/webhooks) for setup instructions. ## Prorating When customers upgrade or downgrade: * **Upgrade**: Shoppex charges the difference immediately * **Downgrade**: Shoppex applies a credit to the next invoice Shoppex calculates proration based on the days remaining in the current billing period. *** ## Headless / Developer API checkout Dashboard checkout and payment links already use Shoppex subscription products. For a custom frontend or backend-driven flow, start checkout with **`POST /dev/v1/orders`**, not `POST /dev/v1/payments`. | Endpoint | Subscription support | | ----------------------- | ------------------------------------------------------------------ | | `POST /dev/v1/orders` | Yes — when `items[].product_id` points to a `SUBSCRIPTION` product | | `POST /dev/v1/payments` | No — one-off developer invoice only, no subscription record | ```typescript theme={"system"} const response = await fetch('https://api.shoppex.io/dev/v1/orders', { method: 'POST', headers: { Authorization: 'Bearer YOUR_API_KEY', 'Content-Type': 'application/json', 'Idempotency-Key': crypto.randomUUID(), }, body: JSON.stringify({ customer_email: 'customer@example.com', custom_fields: { app_user_id: '154' }, items: [{ product_id: 'YOUR_SUBSCRIPTION_PRODUCT_UNIQID', quantity: 1 }], }), }); const { data } = await response.json(); // Redirect the buyer to hosted checkout, then listen for subscription:* webhooks. ``` After payment completes, Shoppex creates the subscription record, shows it in **Dashboard → Subscriptions**, and sends events such as `subscription:created` and `subscription:renewed`. Custom fields such as `plan_type: monthly` do not create subscriptions by themselves. Shoppex does not read them. They are plain metadata on the invoice. Recurring billing requires a real `SUBSCRIPTION` catalog product and the `orders` checkout path. *** ## Managing subscriptions through the API The Dev API exposes full subscription management. Here are the most common operations: | Action | Endpoint | | ------------------------ | ------------------------------------------------ | | List all subscriptions | `GET /dev/v1/subscriptions` | | Get subscription details | `GET /dev/v1/subscriptions/{id}` | | Cancel a subscription | `POST /dev/v1/subscriptions/{id}/cancel` | | Pause billing | `POST /dev/v1/subscriptions/{id}/pause` | | Resume billing | `POST /dev/v1/subscriptions/{id}/resume` | | Change plan | `POST /dev/v1/subscriptions/{id}/change-plan` | | Issue a refund | `POST /dev/v1/subscriptions/{id}/refund` | | List available plans | `GET /dev/v1/subscriptions/{id}/plan-options` | | Update custom fields | `PATCH /dev/v1/subscriptions/{id}/custom-fields` | | View billing history | `GET /dev/v1/subscriptions/{id}/billing-history` | Listen for `subscription:upcoming` webhooks to send renewal reminder emails before a charge happens. This is one of the most useful subscription webhooks and often overlooked. *** Get notified about subscription lifecycle events Understand the invoices generated by subscriptions # Telegram bots Source: https://docs.shoppex.io/developers/telegram-bots Connect your Telegram bot to Shoppex for notifications, broadcasts, and AI-powered customer support ## Quick start Connect your Telegram bot and send your first notification in under 5 minutes. Open [@BotFather](https://t.me/BotFather) in Telegram, run `/newbot`, and copy the bot token. ```bash theme={"system"} curl -X POST https://api.shoppex.io/dev/v1/telegram/bots \ -H "Authorization: Bearer YOUR_DEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{"bot_token": "123456:AAExampleToken"}' ``` The response includes a `deep_link_url`. Share it with customers so they can opt in to notifications through Telegram. Once customers connect, you can send broadcasts, set up journeys, or enable the AI agent. Your bot is now live. The sections below cover notifications, broadcasts, journeys, the inbox, and the AI agent. ## Bot response shape When you connect a bot, Shoppex returns a grouped resource: ```json theme={"system"} { "data": { "id": "9d40c284-4f39-4d2e-a9d5-56ea2e7d5bc7", "shop_id": "3c649ff2-0a87-42de-897a-c07fc2f9a79d", "identity": { "telegram_bot_id": "123456789", "username": "shoppex_demo_bot", "display_name": "Shoppex Demo Bot" }, "verification": { "status": "VERIFIED", "last_verified_at": "2026-04-06T11:20:00.000Z", "last_error": null }, "connection": { "webhook_url": "https://api.shoppex.io/telegram-webhook-url", "deep_link_url": "https://t.me/shoppex_demo_bot?start=shop:3c649ff2-0a87-42de-897a-c07fc2f9a79d", "merchant_deep_link_url": "https://t.me/shoppex_demo_bot?start=shop%3A3c649ff2-0a87-42de-897a-c07fc2f9a79d%2Cmanager%3Ausr_123%2Csig%3Aabc123" }, "agent": { "enabled": false, "mode": "CUSTOMER_SALES", "has_api_key": false, "api_key_generated_at": null, "last_routed_at": null } } } ``` ## Customer linking Customers connect to your shop through the bot deep link `https://t.me/?start=shop:`. Add context to the link for richer linking: | Link format | Use case | | --------------------------------------------------------- | ---------------------------------------------------- | | `?start=shop:` | Basic opt-in | | `?start=shop:,customer:` | Link to an existing customer for order confirmations | | `?start=shop:,email:` | Link guest checkouts by email | | `?start=shop:,manager:,sig:` | Merchant assistant mode (signed) | The bot response includes the merchant deep link pre-built under `connection.merchant_deep_link_url`. It binds the Telegram chat to a Shoppex manager, so merchant-only agent flows route safely. List linked users: ```bash theme={"system"} curl "https://api.shoppex.io/dev/v1/telegram/bots/BOT_ID/users?is_active=true&limit=25" \ -H "Authorization: Bearer YOUR_DEV_API_KEY" ``` ## Notifications Shoppex sends Telegram messages automatically for order confirmations, restock alerts, and promotional broadcasts. Each user's `notification_preferences` object controls what they receive, and the defaults apply until you change them. Update a single user: ```bash theme={"system"} curl -X PUT https://api.shoppex.io/dev/v1/telegram/bots/BOT_ID/users/USER_ID/notifications \ -H "Authorization: Bearer YOUR_DEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "notification_preferences": { "enabled_types": ["ORDER_CONFIRMATION", "PRODUCT_RESTOCK"] } }' ``` Update multiple users at once: ```bash theme={"system"} curl -X PUT https://api.shoppex.io/dev/v1/telegram/bots/BOT_ID/users/bulk/notifications \ -H "Authorization: Bearer YOUR_DEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "user_ids": [ "11111111-1111-4111-8111-111111111111", "22222222-2222-4222-8222-222222222222" ], "notification_preferences": { "enabled_types": ["PROMOTIONAL_OFFERS"] } }' ``` ## Broadcasts A broadcast sends a message to a filtered audience, right away or on a schedule. A broadcast request has three parts: | Group | What it controls | | ---------- | --------------------------------------------------------------------------------------------------------------------- | | `content` | The message body, parse mode, and inline keyboard buttons | | `audience` | Filter by notification type, activity (`only_active_users`, `last_activity_after`), or saved segments (`segment_ids`) | | `delivery` | When to send (`scheduled_for`) and the rate, up to 30 messages per second (`messages_per_second`) | ```bash theme={"system"} curl -X POST https://api.shoppex.io/dev/v1/telegram/bots/BOT_ID/broadcasts \ -H "Authorization: Bearer YOUR_DEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": { "body": "Restock alert\nYour item is back in stock.", "parse_mode": "HTML", "inline_keyboard": [ [{"text": "Open product", "url": "https://demo.myshoppex.io/product/prod_123"}] ] }, "audience": { "notification_types": ["PRODUCT_RESTOCK"], "only_active_users": true }, "delivery": { "messages_per_second": 15 } }' ``` URL buttons are automatically wrapped in Shoppex tracking links, so you get click-through rates and revenue attribution for free. Broadcasts move through these states: `DRAFT → SCHEDULED → SENDING → COMPLETED / CANCELLED / FAILED`. For product-specific restocks, use the dedicated endpoint. Shoppex builds the message from the product data: ```bash theme={"system"} curl -X POST https://api.shoppex.io/dev/v1/telegram/bots/BOT_ID/broadcasts/warehouse-restock \ -H "Authorization: Bearer YOUR_DEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "product_id": "33333333-3333-4333-8333-333333333333", "variant_id": "44444444-4444-4444-8444-444444444444", "content": {"body": "Back in stock now", "parse_mode": "HTML"}, "audience": {"only_active_users": true}, "delivery": {"messages_per_second": 10} }' ``` ### Saved segments Save a repeated audience filter as a segment: ```bash theme={"system"} curl -X POST https://api.shoppex.io/dev/v1/telegram/bots/BOT_ID/segments \ -H "Authorization: Bearer YOUR_DEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Active buyers", "description": "Customers active in the last 30 days", "filters": { "only_active_users": true, "linked_only": true, "notification_types": ["ORDER_CONFIRMATION"] } }' ``` Reference the saved segment in any broadcast with `audience.segment_ids: [""]`. Manage segments with `GET`, `PUT`, and `DELETE` on `/dev/v1/telegram/bots/:botId/segments/:segmentId`. ### Analytics and click tracking Shoppex tracks URL button clicks per broadcast and attributes completed orders back to the broadcast that drove them: 1. You send a broadcast with a product button. 2. Shoppex rewrites the URL to a tracking link. 3. The customer clicks, and Shoppex records the click before it redirects to the product page. 4. If the customer completes an order inside the attribution window, the revenue counts back to that broadcast. ```bash theme={"system"} curl https://api.shoppex.io/dev/v1/telegram/bots/BOT_ID/broadcasts/BROADCAST_ID/analytics \ -H "Authorization: Bearer YOUR_DEV_API_KEY" ``` The response `overview` object reports: | Field | Meaning | | ----------------------------------------- | ------------------------------------------------ | | `delivered_count`, `failed_count` | Messages delivered and messages that failed | | `unique_clicked_users`, `click_count` | Distinct clickers and total clicks | | `click_through_rate` | Click-through rate as a percentage | | `attributed_orders`, `attributed_revenue` | Orders and revenue linked back to this broadcast | Each entry in the `buttons` array reports `button_id`, `text`, `destination_url`, `unique_clicked_users`, and `click_count`. Track delivery and bot-level performance with: * `GET /dev/v1/telegram/bots/:botId/broadcasts` — list all broadcasts * `GET /dev/v1/telegram/bots/:botId/broadcasts/:id` — a single broadcast * `GET /dev/v1/telegram/bots/:botId/broadcasts/:id/stats` — delivery progress * `GET /dev/v1/telegram/bots/:botId/analytics` — bot-level analytics The broadcast response groups delivery progress under `delivery`: `sent_count`, `failed_count`, `progress_percentage`, `estimated_time_remaining_seconds`, and `failure_reasons`. ## Journeys Journeys are event-driven follow-up flows, such as a post-purchase drip or an abandoned-cart reminder. Shoppex sends each message on its own schedule, so you do not send them by hand. | Trigger | When it fires | | ---------------------- | -------------------------------------------------------- | | `ORDER_COMPLETED` | After a successful order | | `ABANDONED_CART` | When a customer starts checkout but does not complete it | | `PAYMENT_REMINDER` | Before a payment deadline | | `SUBSCRIPTION_RENEWAL` | Before a subscription renews | | `MANUAL` | Triggered by your API call | Example post-purchase follow-up: ```bash theme={"system"} curl -X POST https://api.shoppex.io/dev/v1/telegram/bots/BOT_ID/journeys \ -H "Authorization: Bearer YOUR_DEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Post-purchase follow-up", "trigger": "ORDER_COMPLETED", "filters": {"only_active_users": true}, "steps": [ { "delay_minutes": 0, "content": {"body": "Thanks for your order.", "parse_mode": "HTML"} }, { "delay_minutes": 1440, "content": {"body": "Need help with your order?", "parse_mode": "NONE"} } ] }' ``` This journey fires when a customer completes an order. It sends the first message immediately and queues the follow-up for 24 hours later. Test a journey before you turn it on with `POST /dev/v1/telegram/bots/:botId/journeys/:journeyId/test`. ## Support commands and inbox The bot doubles as a two-way support channel. Customer commands: | Command | What it does | | ----------- | ---------------------------------------- | | `/settings` | Opens the notification preference center | | `/track` | Tracks an order | | `/buyagain` | Reorders the last purchase | | `/help` | Opens the support flow | Merchant inbox endpoints: * `GET /dev/v1/telegram/bots/:botId/inbox` — list conversations * `GET /dev/v1/telegram/bots/:botId/inbox/:conversationId` — read a conversation * `POST /dev/v1/telegram/bots/:botId/inbox/:conversationId/reply` — send a reply * `PUT /dev/v1/telegram/bots/:botId/inbox/:conversationId/status` — close or reopen ```bash theme={"system"} curl -X POST https://api.shoppex.io/dev/v1/telegram/bots/BOT_ID/inbox/CONVERSATION_ID/reply \ -H "Authorization: Bearer YOUR_DEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{"message": "We are checking your order now.", "parse_mode": "HTML"}' ``` ## AI agent The AI agent routes inbound Telegram messages through Shoppex commerce tools: product search, order lookup, coupon creation, and payment links. | Mode | Who it serves | What it can do | | -------------------- | ------------------------------------- | ---------------------------------------------------------------------- | | `CUSTOMER_SALES` | Customers | Search products, look up orders, share payment links | | `MERCHANT_ASSISTANT` | Merchants, through a signed deep link | Create coupons, check revenue, manage products | | `BOTH` | Both, on the same bot | Routes based on whether the chat is linked to a customer or a merchant | Enable the agent: ```bash theme={"system"} curl -X PUT https://api.shoppex.io/dev/v1/telegram/bots/BOT_ID/agent \ -H "Authorization: Bearer YOUR_DEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{"agent_enabled": true, "agent_mode": "BOTH"}' ``` When you enable the agent, Shoppex creates a shop-scoped Dev API key for the bot. It stores inbound messages as inbox history, routes each message to the AI model, and sends the response back to Telegram. Check usage: ```bash theme={"system"} curl https://api.shoppex.io/dev/v1/telegram/bots/BOT_ID/agent/stats \ -H "Authorization: Bearer YOUR_DEV_API_KEY" ``` The response reports `calls`, `completed_calls`, `failed_calls`, `rate_limited_calls`, `conversations`, `total_tokens`, `estimated_cost_usd`, `average_response_time_ms`, and `last_run_at`. A customer asking "Do you have the hoodie in black?" gets a product search and a direct answer. A merchant saying "Create a 15% coupon for the hoodie" gets a coupon created through the Dev API. The `estimated_cost_usd` field tracks AI token costs, so there are no surprises on your bill. # Validate licenses from your own desktop app Source: https://docs.shoppex.io/developers/tutorials/desktop-license-validation Wire your installer's activation flow to Shoppex licenses with HWID and IP locking. You ship a desktop application — Windows, macOS, or Linux, using Electron, .NET, Qt, native, or another stack. You want it to activate against Shoppex license keys. Each customer's purchase issues a key. Your app calls Shoppex to validate the key and binds it to the user's hardware so the key cannot be shared. This tutorial walks through the activation flow end to end. ## How Shoppex licenses work When a buyer purchases a product fulfilled by **Licenses**, Shoppex issues a license object with: * A unique `license_key` string the buyer sees and enters into your app. * Status: `ACTIVE`, `SUSPENDED`, `REVOKED`, or `EXPIRED`. * Optional **HWID binding** — when the first activation comes in with a hardware ID, that HWID gets bound. Future activations from different hardware fail. * Optional **IP allowlist** — `allowed_ips` array. Non-matching IPs are rejected. * Optional **max\_uses** and **expires\_at** — limits on usage count and validity period. Your job is to call the validate endpoint, hand it the buyer's key plus their HWID, and act on the response. ## What you will need * A Shoppex API key with the `licenses.read` scope. Create one at **Settings → Developer → API Keys**. * A way to compute a stable hardware fingerprint in your app. Common approaches: motherboard serial plus CPU ID on Windows (`wmic`), `IOPlatformUUID` on macOS, or `/etc/machine-id` on Linux. Any cross-platform library that derives a stable hash from hardware identifiers also works. ## The endpoint ``` POST https://api.shoppex.io/dev/v1/licenses/validate Authorization: Bearer shx_your_key Content-Type: application/json ``` Request body: ```json theme={"system"} { "key": "ABCD-1234-EFGH-5678", "product_id": "prod_xxxxxxxxxxxx", "hardware_id": "stable-hwid-string", "ip": "203.0.113.45" } ``` * `key` and `product_id` are required. * `hardware_id` is optional but recommended if you want anti-sharing. * `ip` is optional. If you do not pass it, Shoppex reads `x-forwarded-for` / `x-real-ip` from the request headers and uses that. ## Response On success, you get the license object back wrapped in Shoppex's standard envelope: ```json theme={"system"} { "data": { "uniqid": "lic_xxxxxxxxxxxx", "license_key": "ABCD-1234-EFGH-5678", "product_id": "prod_xxxxxxxxxxxx", "hardware_id": "stable-hwid-string", "hwid_pending": false, "status": "ACTIVE", "uses": 5, "max_uses": null, "allowed_ips": [], "expires_at": null, "customer_email": "buyer@example.com" } } ``` On failure, you get an error response with one of these codes: * `LICENSE_NOT_FOUND` — key does not exist in this shop. * `LICENSE_SUSPENDED` — license is suspended (merchant-side action). * `LICENSE_REVOKED` — license was revoked. * `LICENSE_EXPIRED` — past `expires_at`. * `LICENSE_HWID_MISMATCH` — the HWID you passed does not match the bound one. * `LICENSE_IP_BLOCKED` — the resolved IP is not in `allowed_ips`. * `LICENSE_MAX_USES_REACHED` — `uses` >= `max_uses`. ## HWID binding behavior The HWID logic is automatic: * **First call with a HWID, license `hwid_pending: true`** — Shoppex binds the HWID to the license and returns success. The license is now locked to that machine. * **Later calls with the same HWID** — pass through. License returned. * **Call with a different HWID** — Shoppex returns `LICENSE_HWID_MISMATCH`. The buyer is trying to activate on a second machine. When the buyer legitimately needs a new machine (replacement laptop, reinstalled OS), you or the merchant can call: ``` POST /dev/v1/licenses/{license_id}/reset-hwid ``` This unbinds the HWID. The next validate call with a new HWID will bind that one instead. For workflows where buyers self-service the HWID reset, for example through your app's "Move to a new machine" button, there is also a key-scoped variant: ``` PATCH /dev/v1/licenses/keys/{key}/hwid Body: { "product_id": "prod_xxx", "hardware_id": null } ``` Setting `hardware_id` to `null` puts the license back in `hwid_pending: true` state. Setting it to a string binds that HWID directly. ## Rate limits The Dev API uses a token bucket — by default **30 tokens, refilling 10 tokens every 2 seconds**. Validate calls count against this. For most apps that is plenty: * An app that validates once on launch hits the limit only if a single customer is brute-forcing. * Apps that validate periodically, for example every hour for an "online required" check, must batch and back off on 429. Response headers tell you where you stand: `x-ratelimit-limit`, `x-ratelimit-remaining`, `x-ratelimit-reset`. On 429, respect the `retry-after` header. ## A minimal activation flow ```ts theme={"system"} async function activate(key: string) { const hwid = getStableHwid(); // your hardware-fingerprinting code const response = await fetch('https://api.shoppex.io/dev/v1/licenses/validate', { method: 'POST', headers: { 'Authorization': `Bearer ${process.env.SHOPPEX_API_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ key, product_id: 'prod_your_product_id', hardware_id: hwid, }), }); if (!response.ok) { const error = await response.json(); switch (error.error.code) { case 'LICENSE_HWID_MISMATCH': return { ok: false, reason: 'This license is already activated on another machine.' }; case 'LICENSE_EXPIRED': return { ok: false, reason: 'Your license has expired. Please renew.' }; case 'LICENSE_SUSPENDED': case 'LICENSE_REVOKED': return { ok: false, reason: 'This license is no longer valid.' }; case 'LICENSE_NOT_FOUND': return { ok: false, reason: 'Invalid license key.' }; default: return { ok: false, reason: 'Activation failed. Try again later.' }; } } const { data: license } = await response.json(); return { ok: true, license }; } ``` ## Caching: how to handle "the user is offline" Shoppex does not currently issue signed offline-validation tokens. Every validate call hits the API live. That means a totally-offline machine cannot validate. Most apps handle this by **caching successful validations for a grace period**. Store the last successful validation timestamp locally, signed by your app's own key to prevent tampering. Let the app run for N hours or N days before requiring a fresh online call. Pick the grace window based on what is acceptable in your domain. Game keys often run with 24-48 hour grace. Business software often runs with 30 days. ## SDK note The official `@shoppexio/sdk` for Node/TypeScript does not include a dedicated `licenses` service class yet. You can still call the endpoint through the SDK's typed raw client: ```ts theme={"system"} import { Shoppex } from '@shoppexio/sdk'; const client = new Shoppex({ apiKey: process.env.SHOPPEX_API_KEY }); const response = await client.raw.POST('/dev/v1/licenses/validate', { body: { key, product_id, hardware_id }, }); ``` Or use plain `fetch` — both work. There is no C#, .NET, Python, or other language-specific desktop SDK in the repo yet. ## Common pitfalls * **HWID that is not stable.** Some hardware-id schemes change on OS update or BIOS reset. Test by validating, rebooting, validating again — same HWID? If not, you will burn legitimate buyers. * **Validating on every API call inside the app.** Rate limits will hurt you. Validate on launch and every N hours. Cache between validations. * **Not handling 429.** Even a well-behaved app gets rate-limited if a user manually retries 10x. Honor `retry-after`. * **No fallback for transient errors.** A network blip is not the same as an invalid license. On `500`/`503`/connection errors, fall back to your cached grace window. Do not lock the user out. # Webhook handler in a Cloudflare Worker Source: https://docs.shoppex.io/developers/tutorials/webhook-cloudflare-worker Verify, idempotency-check, and act on Shoppex webhooks from a Worker. Cloudflare Workers are a good fit for webhook handlers — cheap, fast, globally distributed, no servers to babysit. This tutorial wires up a worker that receives Shoppex webhook deliveries, verifies the signature, deduplicates by delivery ID, and reacts to the event. ## What you will have at the end * A deployed Cloudflare Worker that listens for Shoppex events. * HMAC-SHA256 signature verification against the V2 header. * Idempotency using Cloudflare KV (so you can safely receive duplicates). * A registered Shoppex webhook pointing at the worker. ## Prerequisites * A Cloudflare account with Workers enabled. * `wrangler` CLI installed (`npm i -g wrangler` or `bun add -g wrangler`). * A Shoppex shop with an API key that has `webhooks.write`. ## Step 1 — Create the worker ```bash theme={"system"} npx wrangler init shoppex-webhook-worker cd shoppex-webhook-worker ``` Pick the "Hello World" Worker template, TypeScript. You will have a `src/index.ts` to edit. ## Step 2 — Add a KV namespace for idempotency Shoppex assigns a unique `delivery_id` to each delivery. Manually retrying a webhook can create duplicates, so record the IDs you have already processed. If you see the same ID twice, short-circuit and skip reprocessing. ```bash theme={"system"} npx wrangler kv:namespace create WEBHOOK_DELIVERIES ``` Wrangler prints a binding snippet — copy it into `wrangler.toml`: ```toml theme={"system"} [[kv_namespaces]] binding = "WEBHOOK_DELIVERIES" id = "your-namespace-id-from-wrangler" ``` ## Step 3 — Add the webhook secret The webhook secret is what you will use to verify HMAC signatures. Add it as a wrangler secret (not in code, not in `wrangler.toml`): ```bash theme={"system"} npx wrangler secret put SHOPPEX_WEBHOOK_SECRET # Paste the secret when prompted ``` You will get the secret from Shoppex in step 8 (when you create the webhook), so come back here once. ## Step 4 — Write the handler Replace `src/index.ts`: ```ts theme={"system"} export interface Env { WEBHOOK_DELIVERIES: KVNamespace; SHOPPEX_WEBHOOK_SECRET: string; } export default { async fetch(request: Request, env: Env): Promise { if (request.method !== 'POST') { return new Response('Method not allowed', { status: 405 }); } // 1. Read raw body — required for signature verification const rawBody = await request.text(); // 2. Extract Shoppex headers const signatureV2 = request.headers.get('X-Shoppex-Signature-V2'); const deliveryId = request.headers.get('X-Shoppex-Delivery'); const timestamp = request.headers.get('X-Shoppex-Timestamp'); const event = request.headers.get('X-Shoppex-Event'); if (!signatureV2 || !deliveryId || !timestamp || !event) { return new Response('Missing required Shoppex headers', { status: 400 }); } // 3. Reject deliveries older than 5 minutes (replay protection) const now = Math.floor(Date.now() / 1000); const ts = parseInt(timestamp, 10); if (Math.abs(now - ts) > 300) { return new Response('Timestamp out of window', { status: 400 }); } // 4. Verify HMAC-SHA256 signature const isValid = await verifySignature( signatureV2, deliveryId, timestamp, rawBody, env.SHOPPEX_WEBHOOK_SECRET, ); if (!isValid) { return new Response('Invalid signature', { status: 401 }); } // 5. Idempotency — check if this delivery has already been seen const seen = await env.WEBHOOK_DELIVERIES.get(deliveryId); if (seen) { return new Response('Already processed', { status: 200 }); } // 6. Parse and act on the event const payload = JSON.parse(rawBody); await handleEvent(event, payload, env); // 7. Record the delivery ID with a 7-day TTL await env.WEBHOOK_DELIVERIES.put(deliveryId, '1', { expirationTtl: 60 * 60 * 24 * 7 }); return new Response('ok', { status: 200 }); }, }; async function verifySignature( signatureHeader: string, deliveryId: string, timestamp: string, rawBody: string, secret: string, ): Promise { // Header format: "v1,t={timestamp},h={hex_signature}" const segments = signatureHeader.split(',').map((part) => part.trim()); const parts = segments.reduce>((acc, part) => { const [k, v] = part.split('='); if (k && v) acc[k] = v; return acc; }, {}); const signature = parts.h; if (!segments.includes('v1') || parts.t !== timestamp || !/^[0-9a-f]{64}$/i.test(signature ?? '')) return false; // Message format: "{deliveryId}.{timestamp}.{rawBody}" const message = `${deliveryId}.${timestamp}.${rawBody}`; // Compute HMAC-SHA256 with Web Crypto API const enc = new TextEncoder(); const key = await crypto.subtle.importKey( 'raw', enc.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'], ); const sigBuffer = await crypto.subtle.sign('HMAC', key, enc.encode(message)); const expected = Array.from(new Uint8Array(sigBuffer)) .map((b) => b.toString(16).padStart(2, '0')) .join(''); // Constant-time compare if (expected.length !== signature.length) return false; let mismatch = 0; for (let i = 0; i < expected.length; i++) { mismatch |= expected.charCodeAt(i) ^ signature.charCodeAt(i); } return mismatch === 0; } async function handleEvent(event: string, payload: any, env: Env): Promise { switch (event) { case 'order:paid': console.log('Order paid:', payload.data.uniqid, payload.data.total); // Your business logic here — send a Slack message, fulfill in your system, and more. break; case 'order:disputed': console.warn('Dispute opened on order:', payload.data.uniqid); break; case 'subscription:renewed': console.log('Subscription renewed:', payload.data.uniqid); break; default: console.log('Unhandled event:', event); } } ``` ## Step 5 — Test locally ```bash theme={"system"} npx wrangler dev ``` Wrangler runs the worker at `http://localhost:8787`. Send a POST request with fake headers to confirm it returns 400 for missing headers. Then send full headers with a wrong signature to confirm it returns 401. That is the right behavior. ## Step 6 — Deploy ```bash theme={"system"} npx wrangler deploy ``` Wrangler gives you a URL like `https://shoppex-webhook-worker.your-account.workers.dev`. Copy it. ## Step 7 — Register the webhook in Shoppex From your dashboard at **Settings → Developer → Webhooks** or through the API: ```bash theme={"system"} curl https://api.shoppex.io/dev/v1/webhooks \ -H "Authorization: Bearer shx_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "url": "https://shoppex-webhook-worker.your-account.workers.dev", "events": ["order:paid", "order:disputed", "subscription:renewed"] }' ``` The response includes a `secret`. **Save it immediately.** Shoppex shows it only once. ## Step 8 — Set the secret in the worker ```bash theme={"system"} npx wrangler secret put SHOPPEX_WEBHOOK_SECRET # Paste the secret you got in step 7 ``` ## Step 9 — Verify Trigger a real event. Make a purchase on your shop, or use the dashboard's "send test event" button on the webhook configuration page. Check your worker logs: ```bash theme={"system"} npx wrangler tail ``` You will see the event come in, get verified, and be processed. ## Event reference Common events you will likely subscribe to: * `order:created` — new order, not yet paid. * `order:paid` — payment confirmed. **This is the event 90% of integrations care about.** * `order:cancelled`, `order:disputed` — bad-path events worth knowing about. * `subscription:renewed`, `subscription:cancelled` — recurring billing lifecycle. * `subscription:trial:started`, `:trial:ended` — for nudge flows. * `product:stock` — direct catalog stock update. For stock consumed by checkout, use `order:paid` or `order:paid:product`. Up to **12 events per webhook**, and **15 webhooks per shop** in total. ## Retry behavior — important If your worker returns a non-2xx status or times out (30s limit), Shoppex retries automatically. It uses an exponential backoff of **2 min, 4 min, 8 min, 16 min** between tries. After **5 total attempts** (the first call plus 4 retries) the delivery is marked failed. You can manually re-queue any failed delivery from the dashboard or through `POST /dev/v1/webhooks/logs/:id/retry`. The same `delivery_id` is reused. That is why idempotency (the KV check above) matters. Practical implications: * **Your worker must be reliable.** Use a Worker (which is globally distributed) rather than a single-region origin server. Transient failures are forgiven automatically. Sustained outages still get the delivery through if you recover within the \~30-minute retry window. * **Make handlers idempotent.** Both automatic retries and your own manual retries will re-send the same `delivery_id`. The KV check above is what catches both cases. * **Watch for failures.** If a delivery uses all 5 attempts, it is done. Shoppex will not try again. Monitor your worker logs (Cloudflare Analytics or `wrangler tail`) and the Shoppex webhook-logs dashboard for failed entries. Once you have fixed the root cause, manually re-queue those entries. ## Common pitfalls * **Parsing the body before verifying.** You must use `request.text()` to get the *exact raw* bytes Shoppex signed. If you `request.json()` first and re-serialize, the message no longer matches the signature. * **Reading headers case-sensitively.** The Fetch API in Workers gives you case-insensitive access, but be consistent. * **Forgetting the timestamp window.** Without a 5-minute timestamp check, an attacker who captured one signed payload can replay it forever. * **Not enabling KV in production.** Without idempotency, a manual retry double-processes the order. Worth the 5 minutes of KV setup. Full event list, payload shapes, and the signature spec. # Plug Shoppex into Zapier or n8n Source: https://docs.shoppex.io/developers/tutorials/zapier-n8n Connect Shoppex to no-code automation tools using webhooks and the REST API. Zapier, n8n, and Make are no-code automation tools. They let you chain Shoppex events to hundreds of other apps. For example: "when a Shoppex order is paid, add the buyer to my Notion CRM and ping me on Slack." You do not need a published Shoppex app to do this. Webhooks and the REST API are enough. This tutorial covers both directions: Shoppex → automation tool (trigger), and automation tool → Shoppex (action). ## Why no official Zapier app yet? Shoppex does not (yet) ship a Zapier-marketplace app or n8n node. That means you will not see "Shoppex" in Zapier's app picker. Instead, you wire it up using these general-purpose building blocks: * **Webhooks** for outbound (Shoppex → automation tool). * **HTTP Request actions** for inbound (automation tool → Shoppex API). * **API keys** with `Authorization: Bearer` for authentication. Once you understand the pattern, you can apply it to Zapier, n8n, Make, Activepieces, or any other webhook-aware automation tool. ## Outbound — Shoppex triggers your automation ### Step 1 — Create a Catch Hook in your automation tool In Zapier: 1. Create a new Zap. 2. Trigger app: **Webhooks by Zapier**. 3. Event: **Catch Hook**. 4. Zapier gives you a URL like `https://hooks.zapier.com/hooks/catch/123456/abcdef/`. Copy it. n8n: similar — drop a **Webhook** node, set Method to POST, copy the production URL. Make: drop a **Webhook** module, copy the URL. ### Step 2 — Register the webhook in Shoppex Either through the dashboard at **Settings → Developer → Webhooks**, or through the API: ```bash theme={"system"} curl https://api.shoppex.io/dev/v1/webhooks \ -H "Authorization: Bearer shx_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "url": "https://hooks.zapier.com/hooks/catch/123456/abcdef/", "events": ["order:paid"] }' ``` Shoppex returns the webhook config plus a `secret`. **Copy the secret.** You will only see it once. You will not usually verify signatures in Zapier itself, because Zapier does not expose easy HMAC verification. Keep the secret anyway, for the day you migrate to a real backend. ### Step 3 — Trigger a test event Either make a real purchase, or use the dashboard's **Send test** button on the webhook configuration page. Zapier captures the payload. You will see the order data appear in the Zap editor. Now you can map fields into the next step. ### Step 4 — Add the action(s) This is where Zapier shines. Common downstream actions for `order:paid`: * **Slack** → "send message to #sales channel: New sale: bought for " * **Notion** → "create database row" in your customer DB. * **Google Sheets** → "append row" to your accounting tracker. * **Discord** → "send webhook message" to a private channel. * **Mailchimp / ConvertKit** → "add subscriber" with the buyer's email. Zapier and n8n have hundreds of pre-built actions. The Shoppex payload gives you the buyer, the product, the gateway, the amount, and timestamps — most automations need only that. ### What payload you get A typical `order:paid` delivery body looks like: ```json theme={"system"} { "event": "order:paid", "data": { "uniqid": "ord_xxxxxxxxxxxx", "type": "PRODUCT", "status": "completed", "gateway": "STRIPE", "total": 49.99, "total_display": "$49.99", "currency": "USD", "customer_email": "buyer@example.com", "product_id": "prod_xxxxxxxxxxxx", "product_title": "Pro License", "is_developer_invoice": false }, "created_at": 1716595200 } ``` Headers Shoppex sends with each delivery: * `X-Shoppex-Event` — the event name. * `X-Shoppex-Delivery` — unique delivery UUID (use for idempotency). * `X-Shoppex-Timestamp` — unix seconds. * `X-Shoppex-Signature-V2` — HMAC signature (see [Webhook handler in a Cloudflare Worker](/developers/tutorials/webhook-cloudflare-worker) for verification). ## Inbound — automation tool writes to Shoppex The reverse direction: something happens in another app, your Zap calls Shoppex to act on it. ### Setup Add an **HTTP Request** action to your Zap (or "HTTP Request" node in n8n): * **Method:** POST or PATCH, depending on the endpoint. * **URL:** `https://api.shoppex.io/dev/v1/{resource}`. * **Headers:** * `Authorization`: `Bearer shx_your_api_key` * `Content-Type`: `application/json` * **Body:** JSON matching the endpoint's schema. ### Common inbound patterns **Sync a new Stripe customer into Shoppex:** When Stripe creates a new customer (Zapier trigger), call: ``` POST /dev/v1/customers { "email": "{{stripe.customer.email}}", "name": "{{stripe.customer.name}}" } ``` **Create an invoice from a Typeform submission:** When Typeform receives a submission (Zapier trigger), call: ``` POST /dev/v1/payments { "title": "Custom invoice from form submission", "email": "{{typeform.email}}", "value": 99.00, "currency": "USD" } ``` The response includes a hosted checkout URL. Zapier can then send that URL through email, Slack, and more. **Add a coupon code on demand:** When you tag a customer in your CRM as "VIP", create a personal coupon: ``` POST /dev/v1/coupons { "code": "VIP-{{customer.id}}", "discount_type": "PERCENTAGE", "discount_value": 25, "max_uses": 1 } ``` ## OAuth — for building a real Zapier app If you eventually publish a marketplace Zapier or n8n integration for other Shoppex merchants, you will switch from per-merchant API keys to OAuth 2.0: * **Authorize URL:** `https://api.shoppex.io/dev/v1/oauth/authorize` (auth-code flow). * **Token URL:** `https://api.shoppex.io/dev/v1/oauth/token` (exchange code for token). * **Token prefix:** `shpat_` for access tokens (1-hour TTL), `shprt_` for refresh tokens (30-day TTL). * **Client credentials:** prefixed `shoc_` (client ID) and `shcs_` (client secret). This is a single-merchant Zap: you run your own automations on your own shop. In that case, the personal API key from your dashboard is the right path. No OAuth needed. ## Common pitfalls * **Treating Zapier's filter as a security boundary.** Without HMAC verification in the automation tool, an attacker who knew your Zapier hook URL can fire fake events. Most webhook attacks are unsophisticated and Zapier hooks are obscure enough to not be a real target, but it is worth knowing. For sensitive flows (money, irreversible actions), use a real backend that verifies signatures. See the [Cloudflare Worker tutorial](/developers/tutorials/webhook-cloudflare-worker). * **Zapier rate limits.** Zapier paid plans have task limits per month. A high-volume `order:paid` event can chew through them fast. Be selective about which events you forward. * **Order is paid event being processed before fulfillment finishes.** Shoppex fires `order:paid` once payment clears, before the fulfillment outbox (Discord roles, file delivery) has run. If your Zap depends on the buyer "having the product", wait for `order:completed` or watch for the fulfillment-specific events. * **Retry window is finite.** Shoppex auto-retries failed deliveries with an exponential backoff (2/4/8/16 min) for a total of 5 attempts. If Zapier is paused for longer than \~30 minutes, the delivery uses up its retries and is marked failed. Once Zapier is back, re-queue the delivery manually from the Shoppex webhooks dashboard. You can also periodically scan recent orders through the API to catch anything that slipped through. Full event list and payload structures. # Webhook events Source: https://docs.shoppex.io/developers/webhook-events Supported webhook event names and example payloads ## Event types Shoppex supports order, subscription, product, query, feedback, affiliate, and reseller webhooks. Event names use colons as separators, like `order:paid`. When creating webhooks through the Dev API, send the exact event name, for example `order:paid`. Wildcard patterns like `invoice.*` do not work. Fetch the full allowlist from `GET /dev/v1/webhooks/events`. These event payloads are not the same as the `dynamic_webhook` callback used by `DYNAMIC` products. For that contract, see [Dynamic product delivery](/developers/dynamic-delivery). *** ## Supported event names | Event | Description | | ------------------------------ | ------------------------------------- | | `order:created` | Order created | | `order:updated` | Order updated | | `order:partial` | Partial payment received | | `order:paid` | Order fully paid | | `order:cancelled` | Order cancelled or expired | | `order:disputed` | Order dispute opened | | `order:created:product` | Order created with product payload | | `order:updated:product` | Order updated with product payload | | `order:partial:product` | Partial payment with product payload | | `order:paid:product` | Paid order with product payload | | `order:cancelled:product` | Cancelled order with product payload | | `order:disputed:product` | Disputed order with product payload | | `order:manual_payment_pending` | Manual payment review required | | `order:item.delivered` | Line item fulfilled with late content | | `order:item.delivery_failed` | Dynamic line item delivery failed | For paid product purchases, subscribe to `order:paid` or `order:paid:product`. If that purchase reduces available stock, Shoppex does not send a separate `product:stock` event for the same checkout. `order:item.delivered` and `order:item.delivery_failed` report per-line-item fulfillment after payment. See [Asynchronous and Manual Fulfillment](/developers/fulfillment#fulfillment-webhook-events) for their payloads and recovery flow. ### Payload families #### Invoice payload These events use the same base invoice payload shape as `order:paid`: * `order:created` * `order:updated` * `order:partial` * `order:paid` * `order:cancelled` * `order:disputed` * `order:manual_payment_pending` #### Invoice payload with products These events use the same invoice payload plus the `products` array, like `order:paid:product`: * `order:created:product` * `order:updated:product` * `order:partial:product` * `order:paid:product` * `order:cancelled:product` * `order:disputed:product` ### Example payloads #### order:paid Triggered when an order/invoice is successfully paid. ```json theme={"system"} { "event": "order:paid", "data": { "uniqid": "abc123def456", "type": "PRODUCT", "status": "COMPLETED", "gateway": "STRIPE", "total": 49.99, "total_display": 49.99, "currency": "USD", "exchange_rate": 1, "crypto_exchange_rate": 0, "crypto_gateway": null, "apm_method": "CARD", "customer_email": "customer@example.com", "country": "US", "quantity": 1, "product_id": "prod_xyz", "product_title": "Pro License", "is_developer_invoice": false, "created_at": "2026-01-15T09:10:00.000Z", "updated_at": "2026-01-15T10:10:00.000Z" }, "created_at": 1705318200 } ``` Dashboard simulation for `order:paid` uses this same envelope shape and the same core fields, just with synthetic values. #### order:cancelled Triggered when an order is cancelled or expires. ```json theme={"system"} { "event": "order:cancelled", "data": { "uniqid": "abc123def456", "type": "PRODUCT", "status": "VOIDED", "gateway": null, "total": 49.99, "total_display": 49.99, "currency": "USD", "exchange_rate": 1, "crypto_exchange_rate": 0, "crypto_gateway": null, "apm_method": null, "customer_email": "customer@example.com", "product_id": "prod_xyz", "product_title": "Pro License", "created_at": "2026-01-15T09:10:00.000Z", "updated_at": "2026-01-16T09:03:20.000Z" }, "created_at": 1705400600 } ``` #### order:paid:product Same as `order:paid` but includes full product details in the payload. ```json theme={"system"} { "event": "order:paid:product", "data": { "uniqid": "abc123def456", "type": "PRODUCT", "status": "COMPLETED", "gateway": "STRIPE", "total": 49.99, "total_display": 49.99, "currency": "USD", "exchange_rate": 1, "crypto_exchange_rate": 0, "crypto_gateway": null, "apm_method": "CARD", "customer_email": "customer@example.com", "product_id": "prod_xyz", "product_title": "Pro License", "products": [ { "uniqid": "prod_xyz", "title": "Pro License", "description": "Full access to all features", "price": 49.99, "price_display": 49.99, "currency": "USD", "type": "SERVICE" } ], "created_at": "2026-01-15T09:10:00.000Z", "updated_at": "2026-01-15T10:10:00.000Z" }, "created_at": 1705318200 } ``` #### order:cancelled:product Same as `order:cancelled` but includes full product details. #### order:manual\_payment\_pending Triggered when Shoppex is waiting for an offline or manual payment review. ```json theme={"system"} { "event": "order:manual_payment_pending", "data": { "uniqid": "abc123def456", "type": "PRODUCT", "status": "PENDING", "gateway": "MANUAL", "total": 49.99, "total_display": 49.99, "currency": "USD", "exchange_rate": 1, "crypto_exchange_rate": 0, "crypto_gateway": null, "apm_method": null, "customer_email": "customer@example.com", "country": "US", "quantity": 1, "product_id": "prod_xyz", "product_title": "Pro License", "is_developer_invoice": false, "created_at": "2026-01-15T09:10:00.000Z", "updated_at": "2026-01-15T09:20:00.000Z" }, "created_at": 1705315200 } ``` | Event | Description | | ------------------------------------ | ------------------------------------------- | | `subscription:trial:started` | Trial started | | `subscription:trial:ended` | Trial ended | | `subscription:created` | Subscription created | | `subscription:updated` | Subscription updated | | `subscription:renewed` | Subscription renewed | | `subscription:cancelled` | Subscription cancelled | | `subscription:upcoming` | Upcoming renewal reminder | | `subscription:trial:started:product` | Trial started with product payload | | `subscription:trial:ended:product` | Trial ended with product payload | | `subscription:created:product` | Subscription created with product payload | | `subscription:updated:product` | Subscription updated with product payload | | `subscription:renewed:product` | Subscription renewed with product payload | | `subscription:cancelled:product` | Subscription cancelled with product payload | | `subscription:upcoming:product` | Upcoming renewal with product payload | ### Payload family These events use the same subscription payload shape as `subscription:created`: * `subscription:trial:started` * `subscription:trial:ended` * `subscription:created` * `subscription:updated` * `subscription:renewed` * `subscription:cancelled` * `subscription:upcoming` * `subscription:trial:started:product` * `subscription:trial:ended:product` * `subscription:created:product` * `subscription:updated:product` * `subscription:renewed:product` * `subscription:cancelled:product` * `subscription:upcoming:product` ### Example payloads #### subscription:created Triggered when a new subscription starts. ```json theme={"system"} { "event": "subscription:created", "data": { "id": "sub_abc123", "status": "ACTIVE", "gateway": "STRIPE", "customer_id": "cust_xyz789", "current_period_start": "2026-01-15T00:00:00.000Z", "current_period_end": "2026-02-15T00:00:00.000Z", "created_at": "2026-01-15T00:00:00.000Z" }, "created_at": 1705314600 } ``` #### subscription:cancelled Triggered when a subscription is cancelled. ```json theme={"system"} { "event": "subscription:cancelled", "data": { "id": "sub_abc123", "status": "CANCELLED", "gateway": "STRIPE", "customer_id": "cust_xyz789", "current_period_start": "2026-01-15T00:00:00.000Z", "current_period_end": "2026-02-15T00:00:00.000Z", "created_at": "2026-01-15T00:00:00.000Z" }, "created_at": 1705918200 } ``` #### subscription:renewed Triggered when a subscription renews for a new billing period. ```json theme={"system"} { "event": "subscription:renewed", "data": { "id": "sub_abc123", "status": "ACTIVE", "gateway": "STRIPE", "customer_id": "cust_xyz789", "current_period_start": "2026-02-15T00:00:00.000Z", "current_period_end": "2026-03-15T00:00:00.000Z", "created_at": "2026-01-15T00:00:00.000Z" }, "created_at": 1707991800 } ``` | Event | Description | | ----------------------------------- | ------------------------------------------------------------------- | | `product:created` | Product created | | `product:edited` | Product updated | | `product:stock` | Product stock changed through a direct catalog/product update | | `product:dynamic` | Dynamic product webhook dispatched | | `query:created` | Support query created | | `query:replied` | Support query replied | | `feedback:received` | Feedback received | | `affiliate:payout_requested` | Affiliate payout requested | | `reseller:enrolled` | Customer became an active reseller | | `reseller:application.created` | Customer applied to the reseller program | | `reseller:application.rejected` | Reseller application rejected | | `reseller:suspended` | Reseller suspended | | `reseller:order.completed` | Wholesale reseller order paid and delivered | | `reseller:tier.advanced` | Reseller reached a higher discount tier automatically | | `reseller:supplier_order.delivered` | A sold item was bought automatically at your supplier and delivered | | `reseller:supplier_order.failed` | An automatic supplier purchase failed and the customer was refunded | ### Payload notes #### Invoice-context product payload `product:dynamic` currently uses the same invoice-style payload family as the order events. It is not the same as the direct `dynamic_webhook` delivery callback. #### Runtime-specific events `product:stock` is for direct catalog stock changes, such as dashboard or API product edits. For stock consumed by checkout, use the matching order event, usually `order:paid:product`. #### Reseller events Four enrollment-lifecycle events share one payload family: a single `reseller` object. They are `reseller:enrolled`, `reseller:application.created`, `reseller:application.rejected`, and `reseller:suspended`. `reseller:order.completed`, `reseller:tier.advanced`, and the two `reseller:supplier_order.*` events each have their own shape, documented below. `reseller:enrolled` fires on all three enrollment paths (invite accepted, self-enrollment in open mode, and merchant approval of an application). Reinstating a suspended reseller does not send an event. There is no `reseller:reinstated`. The two `reseller:supplier_order.*` events belong to the **reselling** shop, not the supplier's: they tell you what happened when your own store auto-purchased a sold item from your supplier. The remaining supported event names on this page are listed as valid event values. Dedicated public example payloads for `product:created`, `product:edited`, `product:stock`, `query:created`, `query:replied`, `feedback:received`, and `affiliate:payout_requested` are not expanded here yet. ### Example payload #### product:dynamic Triggered after Shoppex starts dynamic product delivery for an invoice-backed purchase. This webhook uses invoice context and is separate from the direct `dynamic_webhook` delivery request. ```json theme={"system"} { "event": "product:dynamic", "data": { "uniqid": "abc123def456", "type": "PRODUCT", "status": "COMPLETED", "gateway": "STRIPE", "total": 49.99, "total_display": 49.99, "currency": "USD", "customer_email": "customer@example.com", "country": "US", "quantity": 1, "product_id": "prod_xyz", "product_title": "Dynamic Role", "is_developer_invoice": false, "created_at": "2026-01-15T09:10:00.000Z", "updated_at": "2026-01-15T10:10:00.000Z" }, "created_at": 1705318200 } ``` #### reseller:enrolled Triggered when a customer becomes an active reseller. `reseller:application.created`, `reseller:application.rejected` and `reseller:suspended` carry the identical `reseller` object — only `status` and the timestamp fields differ. ```json theme={"system"} { "event": "reseller:enrolled", "data": { "reseller": { "id": "019cb402-1111-7000-8000-000000000001", "customer_id": "019cb402-2222-7000-8000-000000000002", "email": "wholesale@example.com", "status": "ACTIVE", "tier": { "id": "019cb402-3333-7000-8000-000000000003", "name": "Gold", "discount_percent": 15 }, "application_note": null, "lifetime_revenue": "0.00", "lifetime_orders": 0 } }, "created_at": 1753430400 } ``` `tier` is `null` when no tier is assigned yet. `application_note` is only populated for application-mode enrollments. `lifetime_revenue` is a decimal string, `lifetime_orders` an integer. #### reseller:order.completed Triggered when a wholesale reseller order is paid from the reseller's balance and delivered. The `items` array is the price snapshot frozen at order time. It is not re-derived from the current tier or product prices. ```json theme={"system"} { "event": "reseller:order.completed", "data": { "reseller": { "id": "019cb402-1111-7000-8000-000000000001", "email": "wholesale@example.com", "tier": { "id": "019cb402-3333-7000-8000-000000000003", "name": "Gold", "discount_percent": 15 } }, "order": { "invoice_uniqid": "019cb402-4444-7000-8000-000000000004", "total": "425.00", "currency": "USD", "items": [ { "product_id": "019cb402-5555-7000-8000-000000000005", "variant_id": null, "quantity": 5, "unit_price": "85.00", "line_total": "425.00" } ] } }, "created_at": 1753430460 } ``` #### reseller:tier.advanced Triggered when an order completion pushes a reseller's lifetime totals past a tier's auto-advance threshold. This event only ever fires alongside `reseller:order.completed` — there is no other code path that advances a tier automatically. Note the flat shape: the reseller is identified by `reseller_id` and `email` at the top level, not by a nested `reseller` object. ```json theme={"system"} { "event": "reseller:tier.advanced", "data": { "reseller_id": "019cb402-1111-7000-8000-000000000001", "email": "wholesale@example.com", "previous_tier": { "id": "019cb402-6666-7000-8000-000000000006", "name": "Silver", "discount_percent": 10 }, "new_tier": { "id": "019cb402-3333-7000-8000-000000000003", "name": "Gold", "discount_percent": 15 }, "lifetime_revenue": "12500.00", "lifetime_orders": 42 }, "created_at": 1753430461 } ``` `previous_tier` is `null` when the reseller had no tier before. `discount_percent` is a number on this event, unlike the decimal strings used for money fields. #### reseller:supplier\_order.delivered Triggered when one of your products is fulfilled by an automatic wholesale purchase at your supplier and the codes reached your buyer. Fires on your own shop. ```json theme={"system"} { "event": "reseller:supplier_order.delivered", "data": { "supplier_order_id": "019cb402-7777-7000-8000-000000000007", "status": "DELIVERED", "quantity": 2, "delivered_quantity": 2, "shortfall_quantity": 0, "amount": "18.00", "currency": "USD", "failure_reason": null, "retail_invoice": { "uniqid": "019cb402-8888-7000-8000-000000000008", "line_item_id": "019cb402-9999-7000-8000-000000000009" }, "supplier_shop": { "id": "019cb402-aaaa-7000-8000-00000000000a", "invoice_uniqid": "019cb402-bbbb-7000-8000-00000000000b" } }, "created_at": 1753430470 } ``` `amount` is what was charged to your reseller balance at the supplier. A partial supplier delivery still sends this event, with `shortfall_quantity` above zero. #### reseller:supplier\_order.failed Triggered when the automatic supplier purchase fails to deliver. `status` is `REFUNDED` when the buyer was refunded and `FAILED` when no refund was possible. `amount` carries the refunded sum or `null`. `supplier_shop.invoice_uniqid` is always `null` here, because no usable supplier invoice exists. ```json theme={"system"} { "event": "reseller:supplier_order.failed", "data": { "supplier_order_id": "019cb402-cccc-7000-8000-00000000000c", "status": "REFUNDED", "quantity": 1, "delivered_quantity": 0, "shortfall_quantity": 1, "amount": "9.00", "currency": "USD", "failure_reason": "SUPPLIER_OUT_OF_STOCK: The supplier could not deliver this item.", "retail_invoice": { "uniqid": "019cb402-dddd-7000-8000-00000000000d", "line_item_id": "019cb402-eeee-7000-8000-00000000000e" }, "supplier_shop": { "id": "019cb402-aaaa-7000-8000-00000000000a", "invoice_uniqid": null } }, "created_at": 1753430480 } ``` `failure_reason` is a `CODE: message` string. Common codes are `SUPPLIER_OUT_OF_STOCK`, `ATTEMPTS_EXHAUSTED`, and the guard failures for a disabled source, an inactive reseller relationship, or a supply chain deeper than three shops. *** ## Common fields All webhook payloads include these top-level fields: | Field | Type | Description | | ------------ | ------ | ----------------------------------------- | | `event` | string | Event type, for example `order:paid` | | `data` | object | Event-specific payload | | `created_at` | number | Unix timestamp when the event was created | Top-level webhook `created_at` is a Unix timestamp. Nested timestamps inside `data`, like invoice `created_at` / `updated_at` or subscription period fields, are ISO 8601 strings when present. *** Setup, verification, and response handling Deliver products in real time through the webhook response # Webhooks Source: https://docs.shoppex.io/developers/webhooks Register endpoints, verify signatures, and handle retries Shoppex sends an HTTP `POST` request when a subscribed event occurs. This page covers normal event webhooks such as `order:paid`. For product delivery callbacks, read [Dynamic product delivery](/developers/dynamic-delivery). ## Create a webhook You can create a webhook in **Settings → Webhooks**. You can also use the Developer API. ```bash theme={"system"} curl https://api.shoppex.io/dev/v1/webhooks \ -X POST \ -H "Authorization: Bearer shx_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "url": "https://example.com/webhooks/shoppex", "events": ["order:paid", "order:cancelled"] }' ``` The create response contains the webhook ID and its secret. Store the secret when you create the webhook. Each webhook has its own secret. A secret for one webhook cannot verify a delivery for another webhook. ## Event catalog The event catalog contains these families: * `order` * `dispute` * `product` * `query` * `feedback` * `affiliate` * `reseller` * `subscription` * `replacement` Use `GET /dev/v1/webhooks/events` for the current allowlist. The endpoint is the source for valid event names. See [Webhook events](/developers/webhook-events) for payload examples. Wildcard subscriptions are not supported. ## Request headers Shoppex sends these headers with a normal event webhook: | Header | Value | | ---------------------------------- | --------------------------------- | | `Content-Type` | `application/json` | | `User-Agent` | `Shoppex-Webhook/1.0` | | `X-Shoppex-Event` | The event name | | `X-Shoppex-Delivery` | The delivery ID | | `X-Shoppex-Timestamp` | A Unix timestamp in seconds | | `X-Shoppex-Signature-V2` | `v1,t=,h=` | | `X-Shoppex-Signature-V2-Algorithm` | `HMAC-SHA256` | | `X-Shoppex-Signature` | The legacy HMAC-SHA512 signature | | `X-Shoppex-Unescaped-Signature` | A legacy compatibility signature | The `v1` value inside `X-Shoppex-Signature-V2` is the header format version. The header name remains `X-Shoppex-Signature-V2`. ## Verify the V2 signature Read the request body before you parse its JSON. Shoppex signs the exact body that it sends. Build this message: ```text theme={"system"} .. ``` Compute its HMAC-SHA256 value with the webhook secret. Compare that value with the `h` value from `X-Shoppex-Signature-V2`. Also compare the header's `t` value with `X-Shoppex-Timestamp`. Use a constant-time function for the signature comparison. ```javascript Node.js theme={"system"} import crypto from 'node:crypto'; function parseSignatureHeader(value) { const segments = value.split(',').map((part) => part.trim()); if (!segments.includes('v1')) return null; const fields = Object.fromEntries( segments .filter((part) => part.includes('=')) .map((part) => part.split('=', 2)), ); if (!/^\d+$/.test(fields.t ?? '')) return null; if (!/^[0-9a-f]{64}$/i.test(fields.h ?? '')) return null; return { timestamp: fields.t, signature: fields.h.toLowerCase() }; } export function verifyShoppexWebhook({ rawBody, signatureHeader, deliveryId, timestampHeader, secret, }) { const parsed = parseSignatureHeader(signatureHeader); if (!parsed || !deliveryId || parsed.timestamp !== timestampHeader) return false; const expected = crypto .createHmac('sha256', secret) .update(`${deliveryId}.${parsed.timestamp}.`, 'utf8') .update(rawBody) .digest(); const received = Buffer.from(parsed.signature, 'hex'); return received.length === expected.length && crypto.timingSafeEqual(received, expected); } ``` ```php PHP theme={"system"} bool: segments = [part.strip() for part in signature_header.split(",")] if "v1" not in segments: return False fields = dict(part.split("=", 1) for part in segments if "=" in part) timestamp = fields.get("t", "") signature = fields.get("h", "").lower() if not delivery_id or timestamp != timestamp_header: return False if re.fullmatch(r"[0-9a-f]{64}", signature) is None: return False prefix = f"{delivery_id}.{timestamp}.".encode("utf-8") expected = hmac.new( secret.encode("utf-8"), prefix + raw_body, hashlib.sha256, ).hexdigest() return hmac.compare_digest(expected, signature) ``` Do not verify a new JSON serialization of the parsed body. Whitespace or escaping changes will produce a different signature. Store processed `X-Shoppex-Delivery` values. If Shoppex sends the same delivery again, return success without repeating the side effect. ## Legacy signatures `X-Shoppex-Signature` contains an HMAC-SHA512 value for the raw body. It does not include the delivery ID or timestamp. `X-Shoppex-Unescaped-Signature` is another legacy header. It signs the body after escaped slashes change from `\/` to `/`. New integrations must use `X-Shoppex-Signature-V2`. ## Response and retry behavior Shoppex treats any `2xx` response as success. A non-`2xx` response or request error starts the retry flow. Each request has a 30-second timeout. Shoppex makes up to five delivery attempts. | Attempt | Delay before the attempt | | ------- | ------------------------ | | 1 | Immediate | | 2 | 2 minutes | | 3 | 4 minutes | | 4 | 8 minutes | | 5 | 16 minutes | The delay after a failed attempt is `2^attempts` minutes. After the fifth failure, Shoppex marks the delivery as failed. Your endpoint can receive the same event more than once. Make event processing idempotent. ## Test a webhook The test event must be part of the webhook subscription. ```bash theme={"system"} curl https://api.shoppex.io/dev/v1/webhooks/WEBHOOK_ID/test \ -X POST \ -H "Authorization: Bearer shx_your_api_key" \ -H "Content-Type: application/json" \ -d '{"event":"order:paid"}' ``` The endpoint queues a test delivery. The response message is `Test webhook queued`. ## Rotate a secret Rotate the secret for one webhook with this endpoint: ```bash theme={"system"} curl https://api.shoppex.io/dev/v1/webhooks/WEBHOOK_ID/rotate-secret \ -X POST \ -H "Authorization: Bearer shx_your_api_key" ``` The response contains the new secret. Store it immediately. The new secret replaces the previous secret for that webhook. ## Inspect and retry deliveries Use these endpoints to inspect delivery results: * `GET /dev/v1/webhooks/logs` * `GET /dev/v1/webhooks/logs/{id}` Retry a failed delivery with `POST /dev/v1/webhooks/logs/{id}/retry`. Read event names and payload examples. Read the callback contract for dynamic products. # Chargeback Shield Source: https://docs.shoppex.io/payments/chargeback-shield Win your disputes automatically and keep your Stripe and PayPal accounts alive. When a buyer charges back, the bank only ever hears one side of the story — theirs. Most sellers never respond, so the money is gone. **Chargeback Shield** answers every single dispute for you, builds the proof from your own order data, and submits it before the deadline. Its companion, **Ban Radar**, watches your dispute rate around the clock and steps in before your payment provider shuts your account down. **You only pay when you win.** When Chargeback Shield wins a dispute for you, it keeps 18% of the recovered amount (10% on Business). That fee is added to your regular monthly statement, and it is never pulled from your wallet or your payout. If you lose a case, you pay nothing. There is no monthly subscription, and letting a small dispute go is always free. You will find it in the dashboard under **Security → Chargeback Shield** (`/security/disputes`). The first time you open it, before any dispute has come in, it introduces itself: what it does, what it costs, and where to configure it. Chargeback Shield first-run screen: a headline explaining that chargebacks now have to get past Shoppex first, an example of a won case, the three-step flow, and the Ban Radar teaser ## How it works A chargeback (also called a dispute) is filed against the gateway that processed the payment. Stripe disputes live in Stripe, PayPal disputes live in PayPal. Shoppex receives the notification, surfaces it here, and goes to work automatically. The dispute shows up here the moment it happens, straight from your Stripe or PayPal account. There is nothing to install and nowhere else to watch. Shoppex already has the connection. Chargeback Shield assembles an evidence packet from data only *you* have. This includes the download log proving the buyer received the file, license activations from the buyer's device, and the buyer's prior order history. Generic dispute tools cannot see any of this. Your storefront produced it. The packet is sent to the bank inside the gateway's evidence window. You can approve every case yourself, or let it run fully automatically. Win, and the money comes back to you. Chargeback Shield never invents evidence. Every item in a packet is real telemetry your store recorded — a delivery event, a license ping, a past paid order. If the data needed to defend a case is not there, the case is flagged for your review. It is never submitted with a weak packet. ## The Disputes view The Disputes tab is where you work. A health strip across the top answers the question merchants actually care about, *is my account safe?*, followed by every dispute and its current state. Chargeback Shield Disputes tab: a health strip showing dispute rate, open and submitted counts, and win rate, above a list of disputes with amounts, reasons in plain English, and status badges The health strip carries three numbers: * **Dispute rate (90d)** — your rate over the last 90 days, colored **Healthy**, **At risk**, or **High risk** against the zone where processors start watching. This is the headline, because account survival, not recovery, is what costs sellers their business. * **Open · submitted** — how many cases are still in flight versus already sent to the bank. * **Win rate** — how many you have won and lost, and how much money has been recovered. Each row in the list leads with the **amount** and a **plain-English reason**, for example "Buyer claims fraud" or "Buyer says they never got it." It also shows the order it belongs to, and a status badge. Disputes raised on charges that did not originate from a Shoppex order are labeled *Not a Shoppex order*. Shoppex still tracks these for your account health, but it has no first-party evidence to defend them with. ### A single case Opening a dispute shows everything Chargeback Shield knows about it. This includes the amount and reason, the evidence packet it assembled, the submission history, and the customer details captured at checkout. A single dispute detail page showing the masked dispute and invoice identifiers, the amount, the reason 'Buyer claims fraud', the assembled evidence section, submission history marked succeeded, and the customer's masked email, IP and country For a won case, the submission history shows the evidence was submitted automatically and the outcome, a **Won** badge in the corner. You can add an internal note to any case for your team. Notes are private and never sent to the bank. ## Settings — what to fight and what to let go Open **Settings** from the Chargeback Shield header to decide how much Chargeback Shield does on its own. Chargeback Shield settings page with two groups: Fighting disputes (Fight disputes for me, an optional ask-me-first amount, and Alerts) and Small disputes (Let small disputes go with a limit, and Refund fraud warnings instantly) * **Fight disputes for me** — Fully automatic submission. Turn it off to approve every case yourself. If you forget to review a case, Chargeback Shield still submits it 24 hours before the deadline, so nothing is ever lost to silence. * **Ask me first above (\$)** — When automatic fighting is on, set a value here to hold the biggest disputes for your approval. Smaller disputes are handled for you, and anything above the amount waits for your approval. If you never want to be asked, leave the field empty. * **Let small disputes go** — Fighting a \$5 dispute costs more than it is worth, and accepting it keeps your account healthier. Below the limit you set, Chargeback Shield accepts the dispute instead of fighting it, and accepting a dispute is never charged a fee. * **Refund fraud warnings instantly** — When a buyer's bank flags a payment as fraud before a dispute is filed, refunding it quickly stops the chargeback from happening. The same small-dispute limit applies here. * **Alerts** — Email and Telegram alerts for new disputes and their results, so you are never surprised by an outcome. ## What it costs Chargeback Shield is **success-based**. There is no monthly subscription and nothing to pay up front. * **A fee applies only on disputes it wins for you.** When the autopilot submits your evidence and the case is won, Chargeback Shield keeps **18%** of the recovered amount (**10%** on Business). * **The fee goes on your monthly statement.** It is billed alongside your other platform fees at the end of the month, never deducted from your wallet or held back from a payout. * **You are never charged for a loss, an accept, or a case you handled yourself.** Lost disputes, small disputes you let go, and disputes you won directly in Stripe or PayPal carry no fee. Because the fee only comes out of money Chargeback Shield recovers, turning it on cannot leave you worse off than ignoring a dispute. ## Ban Radar — keeping your account alive Winning disputes is only half the job. The other half is keeping your **dispute rate** out of the zone where Stripe and PayPal freeze your money and close your account. That is Ban Radar, the second tab inside Chargeback Shield (`/security/ban-radar`). Ban Radar tab showing account status High risk, the dispute rate against the danger zone, the current protection level Maximum, a rate trend chart, the active protection rules, a per-payment-provider breakdown for Stripe and PayPal, settings toggles, and a history timeline Ban Radar tracks your rate every day and shows it three ways: * **Account status** — **Healthy**, **At risk**, or **High risk**, with the number of disputed payments behind it. * **Dispute rate** — your current rate, next to the point where Stripe starts watching accounts. * **Protection** — whether extra checkout protection is currently **Normal**, **Protected**, or at **Maximum**. ### Automatic protection When your rate climbs, Ban Radar can tighten your checkout to bring it back down, then relax it again once you recover. The protection it applies is a runtime layer on top of checkout. It never changes your saved settings: * Card buyers verify their card before paying (3-D Secure) * VPN and proxy buyers are blocked * High-risk connections are blocked * Repeated purchases from the same buyer are limited **You are always warned first.** The very first time Ban Radar tightens your checkout, it announces the change ahead of time. You can apply it immediately, dismiss it, or hold your protection level exactly where it is. Each step costs a little conversion while it protects the account. The choice stays yours. The **Rate trend** chart plots your dispute rate against the danger line. If the rate is rising, the chart estimates how long until you reach it. The **per-payment-provider** breakdown splits the rate across Stripe and PayPal so you can see which account is under pressure. Every escalation and relaxation is recorded in the **History** timeline. A processor freezing your account usually freezes your money first. Ban Radar acts before that happens, but it can only protect what it can see. Keep Ban Radar enabled, and respond to its early warnings instead of waiting for the High-risk state. ## What Chargeback Shield is good for Most disputes on digital goods are "I never got it" or "I do not recognize this charge." Your download logs and license activations answer both, automatically. Turn on automatic fighting and Chargeback Shield handles the whole lifecycle: assemble, submit, follow up, and let small ones go. Ban Radar keeps your dispute rate out of the account-closing zone, so one bad week does not cost you your payment provider. Evidence built from your own data wins cases generic tools cannot, and you only pay when it works. For the blocking side of fraud, banning specific buyers, IPs, or regions after the fact, see [Disputes & chargebacks](/payments/refunds) and the **Security → Blocked** list. # Checkout Source: https://docs.shoppex.io/payments/checkout How a Shoppex checkout session is created and completed. A Shoppex checkout is the hosted page where a buyer pays for an order, on whichever gateway you have connected. ## Three ways to a checkout The buyer browses your shop, adds a product to the cart, and pays on the hosted checkout page. Copy a link from a product's detail page and share it directly. The buyer lands on checkout for that one product. Create the checkout session from your own app through the Developer API. The storefront route fits digital products, subscriptions, and simple shops that do not need a custom checkout. A payment link fits a single product shared on social media, in an email, or in a chat. Your own checkout fits an app or website that needs full control over the buyer flow. ## What the buyer sees The buyer sees the payment methods you turned on for the shop, and pays on the hosted checkout page for the gateway they pick. After payment clears, delivery follows the fulfillment mode of the product. See [Products](/selling/products) for what each mode delivers. ## Test mode Turn on **Test mode** in **Settings** before you go live, so a test payment does not move real money. | Card | Number | Result | | ---------- | --------------------- | ----------------------- | | Visa | `4242 4242 4242 4242` | Success | | Mastercard | `5555 5555 5555 4444` | Success | | Declined | `4000 0000 0000 0002` | Declined | | 3D Secure | `4000 0025 0000 3155` | Requires authentication | Use any future expiry date and any three-digit CVC. These numbers work for Stripe. PayPal and crypto gateways each have their own separate test mode. ## Building your own checkout For invoice and payment link endpoints, webhook signatures, and gateway-specific request fields, see [Payments](/developers/payments) in the developer section. # Shoppex Wallet Source: https://docs.shoppex.io/payments/crypto-wallet Accept Bitcoin and Litecoin into a self-custodial wallet only you control. The Shoppex Wallet is a native, self-custodial crypto wallet for accepting Bitcoin and Litecoin. Buyers pay straight into a wallet that belongs to you. Shoppex never holds the funds and cannot move them. It is the wallet-you-control option in **Settings → Payments**, alongside provider-based crypto (Cryptomus, Oxapay). **Your money stays yours.** Payments go straight into your wallet. Shoppex never holds your funds and cannot access them. Only you, with your wallet password and recovery phrase, can move the money. ## How it works When you create a wallet, the keys and recovery phrase are generated **on your device** and encrypted with a password you choose. Shoppex only ever receives an encrypted backup that it **cannot open**. There is no Shoppex copy of your keys, your password, or your recovery phrase. * **Keys never leave your device unencrypted.** Wallet creation and transaction signing both happen locally in your browser. * **Payments arrive on-chain, directly.** A buyer who pays with crypto sends to an address derived from *your* wallet. The coins land in your wallet the moment the transaction confirms. * **Only you can spend.** Moving funds requires your wallet password to decrypt the keys, plus an account step-up (your dashboard password or two-factor code). Both run on your device. This is the trade-off of self-custody: full control, and full responsibility. If you lose both your wallet password and your recovery phrase, no one, including Shoppex, can recover your funds. See [Recovery and loss](#recovery-and-loss). ### Fees Because funds settle straight to your wallet on-chain, there is no payment processor in the middle taking a cut. The Shoppex Wallet is the lowest-fee way to accept crypto: * **No extra Shoppex fee for crypto processing.** Shoppex does not add a processing fee on top of native crypto payments. * **You only pay the network fee.** The blockchain's mining/network fee is the only deduction on a transfer, and it goes to the network, not to Shoppex. There is no provider margin layered on top. * **Providers charge their own fee.** Accepting crypto through a provider instead (Cryptomus, Oxapay) routes the money through them, and they take their own processing fee before it reaches you. The native wallet skips that entirely. The standard **Shoppex platform fee still applies**. It is the same across every payment method and is not tied to crypto. The points above are only about *crypto processing* costs. See [How payments work](/payments/gateways) for how the platform fee works. ### Supported chains Native segwit (`bc1…`) receiving addresses. Pays directly to your wallet on-chain. Native segwit (`ltc1…`) receiving addresses. Same self-custodial model as Bitcoin. Both chains share one recovery phrase. Activating the wallet sets up Bitcoin and Litecoin together. ## Set up your wallet Open **Settings → Payments** and find the **Shoppex Wallet** section. If you have not set one up yet, you will see the onboarding wizard. The Shoppex Wallet section in Settings → Payments, with the onboarding entry point The whole flow happens on your device in three steps. Shoppex generates a fresh wallet on this device the moment the wizard opens. It creates the keys and a 12-word recovery phrase locally, and never sends them anywhere in the clear. Onboarding step 1: the wallet is created on your device, with the non-custodial guarantee Wallet creation needs a secure (`https`) connection. If your browser blocks it, you will see the error "Could not create a wallet." Load the dashboard over HTTPS and try again. Choose a **wallet password**. It encrypts your wallet on this device and is required every time you send funds. Shoppex never receives it. Your **wallet password** is **not** your Shoppex account login. It is a separate password that encrypts your keys locally. Shoppex can reset your account login by email. It can **never** reset or recover your wallet password. If you lose it, only your recovery phrase can restore access. A strength meter guides you toward a strong password, and you confirm it once to avoid typos. Onboarding step 2: setting the wallet password with a strength meter Your **12-word recovery phrase** is shown **once**. Write it down, in order, and store it offline, never in a screenshot, cloud note, or email. Anyone who has this phrase can take your funds. Before you can activate the wallet, you confirm two statements. The first says you have stored the phrase safely. The second says you understand that no one can recover your funds without it. You then pass a short **verify challenge** by re-typing a few of the words, to prove you saved them correctly. Onboarding step 3: the 12-word recovery phrase and the verify challenge Once verified, **Activate** sets up Bitcoin and Litecoin. From here on, the only copies of your keys are your wallet password and your recovery phrase, both held by you alone. ### Wallet password vs. account login This is the most common point of confusion: | | Account login | Wallet password | | -------------------- | ---------------------- | --------------------------------------------- | | **What it unlocks** | Your Shoppex dashboard | Your encrypted crypto keys, on this device | | **Who can reset it** | You, through email | **No one** — not even Shoppex | | **If lost** | Reset through email | Recoverable **only** with your 12-word phrase | You will be asked for **both** when you send crypto: your account credential (or two-factor code) to confirm it is you, and your wallet password to sign the transaction. ## Manage your funds Day-to-day banking lives in the **Wallet** area, separate from the setup screen in Settings. This covers balances, receiving, sending, and history. The header marks it **Self-custodial · Bitcoin & Litecoin**. The Wallet overview with the total-balance hero and per-coin balance cards The overview shows a combined balance, in your fiat currency when live rates are available. It also shows a card per coin, with its balance, an estimated fiat value, and **Receive** / **Send** actions. A **Pending** badge means a transaction is on-chain but not yet fully confirmed. ### Receive Click **Receive** on a coin to get a payment address. * A **fresh address** is generated for every receipt — standard practice that keeps your incoming payments harder to link together. Reusing an old address still works, but a new one is better. * The dialog shows the address as text and as a **QR code**. Copy it, or let the payer scan it. * **Only send the matching coin to this address.** Sending Bitcoin to a Litecoin address (or any other asset) results in permanent loss. The networks are separate. The Receive dialog showing a fresh address and its QR code Buyer-paid orders use this same mechanism automatically: Shoppex derives a receive address from your wallet for each crypto invoice. You do not hand out addresses manually for store sales. The **Receive** dialog is for ad-hoc deposits. ### Send Sending crypto takes deliberate extra steps, because **transfers cannot be reversed**. The flow walks through Details → Review → Sign. Enter the recipient address and an amount. You can type the amount in the coin or, when a live rate is available, switch to your fiat currency and Shoppex converts it. The address is checked for the right format before you can continue. Confirm exactly what you are sending: the amount, the **full recipient address**, the network fee (calculated from current network conditions), and the **total debit**. Double-check the recipient here. This is your last chance before signing. The Send dialog review step showing amount, recipient, network fee, and total debit Authorize and sign on this device. You enter two things: * Your **account password** (or **two-factor code**) — confirms it is you. * Your **wallet password** — decrypts your keys so the transaction can be signed locally. Shoppex never sees your wallet password and cannot move your funds. Only the signed transaction is broadcast to the network. Your keys never leave the device. The Send dialog sign step asking for the account credential and wallet password Crypto transfers are **final**. There is no chargeback and no undo. If you send to the wrong address or the wrong amount, Shoppex cannot reverse it. Always confirm the recipient on the Review step. The network confirmation can fail to return cleanly, for example after a brief connection drop. When that happens, the dialog keeps the signed transaction and offers **Retry Send**, which re-sends the same transaction. This prevents accidentally signing a second one and spending twice. Do not close the dialog while that retry is pending. ### Transactions and history Every incoming and outgoing transfer appears in **Recent activity** on the overview, and in full in the **History** tab. Click any row to open its detail sheet. The Wallet transaction history list Each transaction has a status: * **Awaiting confirmation / N confirmations** — the transaction is on-chain but has not reached the number of confirmations you require. Shown as a pending/amber state. * **Confirmed** — enough confirmations have cleared. The funds are settled, and for sales, the order is marked paid. The detail sheet shows the amount, status, confirmation count, block height, and timestamp. It also shows the linked order (for store sales) and the full transaction ID with a **View on explorer** link. ## Per-chain settings Each active chain has its own status card in the **Shoppex Wallet** section of **Settings → Payments**. A toggle turns the chain on or off, and a badge tells you its checkout state at a glance. Active Bitcoin and Litecoin chain status cards with Live badges ### Checkout visibility The badge on each card reflects two things: whether the chain is enabled, and whether it is showing to buyers. | Badge | Meaning | | -------------------- | ----------------------------------------------------------------------------- | | **Live** | Enabled and shown to buyers at checkout. | | **Enabled · hidden** | Turned on, but not yet appearing at checkout (for example, still finalizing). | | **Off** | Not accepting payments. | Each card also shows the wallet **fingerprint**, a short identifier so you can confirm the card maps to the wallet you expect. ### Advanced settings Open **Advanced settings** on a chain card for the policies you rarely touch: * **Required confirmations** — how many on-chain confirmations an invoice needs before it clears, from **1 to 12**. More confirmations are safer against chain reorganizations but make buyers wait longer. The card shows a rough estimated wait, based on the chain's block time. * **Auto-accept underpayments** — crypto buyers often land a few cents short after network fees. This setting completes the order automatically when the shortfall is small. Per chain, you can choose one of three settings. **Use store default** inherits your shop-wide underpayment setting. **On for this coin** forces auto-accept, optionally capped by a max amount and a max percent shortfall. **Off for this coin** never auto-accepts underpayments on this chain. The shop-wide default is configured in **Checkout & fees**, and this per-chain control overrides it for one coin. * **Recovery backup** — download an encrypted backup of your keys, or reveal your recovery phrase. See [Recovery and loss](#recovery-and-loss) below. A single **Save changes** commits everything you have adjusted in the Advanced panel at once. ## Recovery and loss Your wallet exists in two forms only: the encrypted copy on your device (unlocked by your **wallet password**) and your **12-word recovery phrase**. Keep both safe and independent. ### Download an encrypted backup From a chain's **Advanced settings → Recovery backup → Download**, you can save an encrypted backup file. To reach it, you first confirm it is you with an account step-up (your dashboard password, or a two-factor code if you have 2FA enabled). * The downloaded file is **useless without your wallet password**. Shoppex can never read it. * It is a safety net for moving your wallet to another device, not a substitute for your recovery phrase. The Recovery backup dialog with download and reveal-phrase options ### Reveal your recovery phrase In the same dialog, after the account step-up, you can **reveal your 12-word recovery phrase**. Enter your **wallet password**, the one you set when creating the wallet. The phrase is decrypted on your device. It never travels to Shoppex. If you need to re-record your backup, use this option. Treat the screen as sensitive. **If you lose both your wallet password and your recovery phrase, your funds are gone permanently.** Shoppex holds only an encrypted backup it cannot open. There is no recovery path on our side, no support override, and no way to reset access. This is the cost of true self-custody. Store your recovery phrase offline, in order, somewhere you will not lose it. ## Where this fits * For payouts, there is nothing to withdraw from Shoppex. Coins are already in your wallet on-chain once they confirm. See [Payouts](/payments/gateways). * To compare crypto options (self-custodial wallet vs. Cryptomus / Oxapay providers), and to set up the other gateways, see [Payment gateways](/payments/gateways). # Payment gateways Source: https://docs.shoppex.io/payments/gateways Connect Stripe, PayPal, PayPal Friends & Family, Cash App, and crypto. All gateways are configured from **Settings → Payment gateways** (`/settings/payments/gateways`). The page has four tabs: **Card**, **Crypto**, **Aggregators**, **Manual**. Connect any combination — buyers see only what is enabled. This page walks you through the setup steps for each provider, including the parts that most often cause problems. For a high-level overview of how payments flow, see [Checkout](/payments/checkout). ## Stripe Cards and wallets (Apple Pay, Google Pay) through Stripe. ### What you will need A Stripe account with **API access** is required. If you do not have one, sign up at [stripe.com](https://stripe.com). You will need to copy two keys from your Stripe dashboard: * **Publishable Key** — starts with `pk_live_` (or `pk_test_` for testing). * **[Restricted API Key](https://docs.stripe.com/keys/restricted-api-keys) (recommended)** — starts with `rk_live_` (or `rk_test_`). Shoppex also accepts a secret key (`sk_live_` or `sk_test_`), but an unrestricted key is not required. Both are in the Stripe Dashboard at **Developers → API keys**. ### Connecting 1. Open **Settings → Payment gateways → Card** and click **Connect** on Stripe. 2. Paste the **Restricted API Key** and **Publishable Key** into the dialog. 3. Click **Connect**. Shoppex validates the keys, then automatically: * Creates a webhook endpoint on your Stripe account (events: `payment_intent.succeeded`, `.payment_failed`, `.canceled`, `charge.refunded`, and the three `charge.dispute.*` events). * Registers your shop subdomain (`yourshop.shoppex.io`), the shared `checkout.shoppex.io` and `embed.shoppex.io` hosts, and any verified custom domain you have connected, for Apple Pay. ### Common pitfalls * **Restricted Key permissions.** For one-time card checkout, grant `Basic Business Contact Information Read` (`accounts_kyc_basic_read`) and `Write` for `Webhook Endpoints`, `Payment Intents`, `Checkout Sessions`, `Customers`, and `Refunds`. Apple Pay domain registration needs `Apple Pay Domains: Write`; VAT tax-rate creation needs `Tax Rates: Write`. Subscription products additionally need `Products`, `Prices`, and `Subscriptions: Write` plus `Setup Intents: Read`. Dispute tools additionally need `Charges: Read` and `Disputes: Write`. Stripe `Write` permissions include `Read` access for that resource. * **"Stripe API key must allow Shoppex to create webhook endpoints."** The Restricted Key is missing `Webhook Endpoints: Write`. Add that permission in Stripe and reconnect. * **Apple Pay domain registration fails.** Stripe can still connect, but Apple Pay is unavailable on the rejected domain. Grant `Apple Pay Domains: Write`, then reconnect Stripe to retry registration. * **Test keys vs. live keys.** Shoppex accepts restricted and secret keys in both modes. Test keys (`rk_test_` or `sk_test_`) only accept test cards at checkout. Use them for sandbox runs, since no real money moves. Switch to live keys for production. * **Disconnecting does not check pending payments against Stripe.** If you disconnect Stripe while PaymentIntents are still in flight, Shoppex stops tracking those payments after the disconnect, though they continue on Stripe's side. Wait for pending invoices to finish before you disconnect. * **Stripe-side key revocation.** If Stripe revokes the API key, for example because the account is suspended, checkouts after that fail. If Stripe-paid orders stop succeeding, check the **Stripe Dashboard**. ## PayPal (SDK) The standard PayPal integration. Recommended over Friends & Family unless you have a specific reason to avoid it. ### What you will need A **PayPal Business account** plus REST API credentials. To get those: 1. Go to [developer.paypal.com](https://developer.paypal.com) and log in with your PayPal Business account. 2. Open **Apps & Credentials → Live**. 3. Either create a new app or open an existing one. Copy the **Client ID** and **Secret**. ### Connecting 1. Open **Settings → Payment gateways → Card** → **Connect** on PayPal. 2. Paste the **Client ID** and **Client Secret** into the form. 3. Click **Connect**. Shoppex validates the credentials against PayPal's API. ### Register webhooks in PayPal (required) Unlike Stripe, Shoppex does **not** auto-register PayPal webhooks. You must add them yourself in the PayPal Developer Dashboard so that Shoppex receives payment events: 1. In **Apps & Credentials**, click into your app → **Webhooks** → **Add Webhook**. 2. Webhook URL for normal payments: `https://api.shoppex.io/v1/paypal/webhook` 3. Webhook URL for subscriptions: `https://api.shoppex.io/v1/paypal/webhook/subscriptions` Subscribe to these events: **For payments** * `CHECKOUT.ORDER.APPROVED`, `CHECKOUT.ORDER.COMPLETED` * `PAYMENT.AUTHORIZATION.CREATED`, `PAYMENT.AUTHORIZATION.VOIDED` * `PAYMENT.CAPTURE.COMPLETED`, `PAYMENT.CAPTURE.DENIED`, `PAYMENT.CAPTURE.PENDING` * `PAYMENT.CAPTURE.REFUNDED`, `PAYMENT.CAPTURE.REVERSED` * `CUSTOMER.DISPUTE.CREATED`, `CUSTOMER.DISPUTE.UPDATED`, `CUSTOMER.DISPUTE.RESOLVED` **For subscriptions** (only if you sell subscription products) * `BILLING.SUBSCRIPTION.ACTIVATED`, `BILLING.SUBSCRIPTION.CANCELLED` * `BILLING.SUBSCRIPTION.PAYMENT.FAILED` * `PAYMENT.SALE.COMPLETED`, `PAYMENT.SALE.REFUNDED`, `PAYMENT.SALE.REVERSED` Save. Webhook deliveries start flowing immediately. ### Common pitfalls * **"Invalid PayPal API credentials."** You are using Sandbox credentials, not Live. Sandbox apps and Live apps have separate Client IDs and Secrets in the PayPal Developer Console. * **Orders stuck in pending.** Almost always missing or misconfigured webhooks. Re-check the webhook URL and the subscribed events. * **Currency mismatch.** PayPal requires the buyer's currency to match a currency your PayPal account supports. Most accounts handle USD, EUR, and GBP without issue. Less common currencies can bounce. ## PayPal Friends & Family A simpler PayPal route that avoids transaction fees, at the cost of buyer-side protection (F\&F transactions are not eligible for PayPal Buyer Protection or chargebacks). Setup is IPN-based, not API-based. ### What you will need * A **PayPal Business** account (Personal works too, with the IPN feature enabled). * Your PayPal account email. ### Connecting 1. Open **Settings → Payment gateways → Manual** → **Connect** on PayPal F\&F. 2. Enter the **PayPal email** where buyers will send Friends & Family payments. 3. Click **Connect PayPal F\&F**. Shoppex shows you the IPN URL to use: `https://paypal-ff.myshoppex.io`. This URL is the same for all Shoppex shops. Incoming IPNs are matched to your shop by the **invoice ID** in the buyer's payment note. Copy it. ### Configure IPN in PayPal Without IPN, F\&F orders sit in pending forever and need manual verification. To set it up: 1. Log into PayPal (the Business account, not the Developer Console). 2. **Account Settings → Website Payments → Instant Payment Notifications**. 3. Click **Update**, then **Choose IPN Settings**. 4. Paste `https://paypal-ff.myshoppex.io` as the IPN URL. 5. Turn on **Receive IPN messages**. 6. Save. PayPal starts forwarding F\&F payments to Shoppex. Shoppex matches each IPN to a pending invoice by the **invoice ID in the buyer's payment note**. ### Buyer flow When a buyer pays through F\&F, the Shoppex checkout shows them: * Your PayPal email address (where to send the money). * The exact amount. * A note string (`SX-{invoice-id}`) they need to include in the PayPal note field. The buyer pays out-of-band on PayPal. PayPal sends the IPN to Shoppex. Shoppex matches by the note and marks the order paid. ### Common pitfalls * **Buyer forgot to include the note.** Without the note, Shoppex cannot match the IPN to an invoice. The order stays pending until you manually verify it from the order page, or the buyer disputes or requests a refund through PayPal directly. * **F\&F does not work for cross-border payments.** PayPal restricts F\&F to domestic transactions in many countries. If your buyer is in a different region than your PayPal account, the F\&F option can appear greyed out for them on PayPal's side. * **No buyer protection means no chargebacks.** It also means you have no recourse if the buyer's PayPal account is compromised, or if the buyer reverses the transfer manually. Treat F\&F as cash-equivalent. ## Cash App US-only. The flow is **email-forwarding-based**: Cash App does not have a public API for merchants, so Shoppex reads Cash App's receipt emails to confirm payments. ### What you will need * An active **Cash App** account with a `$cashtag`. * A **Gmail address** you control and can set up forwarding from. ### Connecting 1. Open **Settings → Payment gateways → Manual** → **Connect** on Cash App. 2. Enter your **`$cashtag`** (must start with `$`, for example `$yourbrand`). 3. Enter the **Gmail address** that receives Cash App receipt emails. 4. Click through to step 2 of the wizard. Shoppex shows you a **forwarding email address** — something like `{shop-id}-cashapp@inbound.shoppex.io`. Copy it. ### Set up Gmail forwarding Cash App setup has **two phases**: 1. **Gmail forwarding confirmation** — Google sends one email to Shoppex to prove the forwarding address is reachable. 2. **Receipt pipeline test** — a real Cash App payment receipt must reach Shoppex before the gateway shows as verified. In the Cash App wizard: 1. Open Gmail → **Settings → Forwarding and POP/IMAP** → **Add a forwarding address**. 2. Paste the Shoppex forwarding address from step 2 of the wizard. 3. Gmail sends a confirmation email to Shoppex. Go back to the wizard and click **Check Verification Status**. If the wizard shows a **Google Confirmation Link**, open it. 4. Approve forwarding in Gmail. 5. Create a **filter** with criteria `from:cash@square.com OR from:cash@cash.app` and forward matching mail to the same Shoppex address. 6. Send yourself a **small test payment** through your shop. Once Shoppex receives the receipt email, the wizard shows a green check and the gateway badge clears. Until step 6 completes, the gateway card can show **Receipt test pending** even after the Gmail confirmation email arrives. That is expected. ### Buyer flow At checkout, the buyer sees your `$cashtag` and a unique note code (`SX-{invoice-id}`). They: 1. Open Cash App, send the amount to your `$cashtag`, and include the note. 2. Click **I've sent the payment** on the Shoppex checkout (manual confirmation step). Cash App emails you the receipt. Gmail forwards it to Shoppex. Shoppex matches the email to the pending invoice and marks it paid. ### Common pitfalls * **USD only.** Cash App rejects non-USD shops. If your shop's primary currency is not USD, Cash App does not appear in the gateway list. * **Connect before enable.** You must connect Cash App with your cashtag and Gmail address before the checkout toggle can be turned on. Receipt verification is separate and proves the forwarding pipeline works. * **Gmail filter must catch every receipt format.** Cash App occasionally varies its email template. If a payment does not auto-confirm, check Gmail for the receipt and forward it manually as a test. ## Crypto: Aggregators For multi-coin coverage without managing addresses per chain. ### Cryptomus API-key-based. Get a key from your [Cryptomus dashboard](https://cryptomus.com) under **Settings → Payments → API**. You will also need your **Merchant ID** from the same page. In Shoppex: **Settings → Payment gateways → Aggregators → Cryptomus → Connect**. Paste both, save. Cryptomus handles which coins are visible at checkout based on your Cryptomus settings. ### Oxapay API-key-based, simpler than Cryptomus. Get a Merchant API key from [Oxapay](https://oxapay.com) → **Merchant API**. In Shoppex: **Settings → Payment gateways → Aggregators → Oxapay → Connect**. Paste the key. Oxapay's currently supported coins and networks are fetched live whenever a buyer opens checkout. Whatever you have enabled in your Oxapay dashboard shows up. ## Crypto: Shoppex Wallet For accepting Bitcoin and Litecoin directly into a wallet only you control, with no intermediary. The **Shoppex Wallet** is a native, **self-custodial** wallet for **BTC** and **LTC**. Buyers pay straight into a wallet that belongs to you. The keys and recovery phrase are generated on your device, and Shoppex never holds the funds. You create it from the onboarding wizard in **Settings → Payments → Shoppex Wallet**. There are no per-coin addresses to paste. See the full guide: [Shoppex Wallet](/payments/crypto-wallet). ### Pitfalls * **Self-custody means full responsibility.** If you lose both your wallet password and your 12-word recovery phrase, Shoppex has no way to recover the funds. * **No automatic refunds.** Because Shoppex does not custody the wallet, crypto refunds are manual. You send the funds back yourself from the Wallet area, then mark the order refunded in Shoppex. See [Refunds](/payments/refunds#crypto-refund-amounts). For multi-coin coverage beyond BTC/LTC, connect a provider (Cryptomus / Oxapay) instead. See [Crypto: Aggregators](#crypto-aggregators) above. ## Payouts Because Shoppex does not custody funds, payouts happen on each gateway's own schedule, not Shoppex's schedule. There is no shared Shoppex balance to withdraw from. ### Per-gateway timing * **Stripe** — Funds settle to the bank account on your connected Stripe profile on Stripe's rolling payout schedule (set in your Stripe dashboard). New accounts typically start on a delayed schedule and graduate to faster cycles as volume builds. That is Stripe's policy, not Shoppex's. * **PayPal (SDK)** — Funds land in your PayPal Business balance right after capture. Withdraw from PayPal as you normally do. * **PayPal Friends & Family** — Funds arrive in your PayPal personal balance as F\&F transfers, per PayPal's own timing. * **Cryptomus / Oxapay** — Funds accumulate in your provider balance. Withdraw from those providers' dashboards to your wallets or to fiat (where supported). * **Direct crypto (BTC / LTC / ETH / Lightning)** — Funds arrive at your wallet on-chain once the required confirmations clear, with no intermediary holding them. ### Matching orders to gateway statements Shoppex tracks every order in your dashboard regardless of which gateway processed it. The **Orders** page lists each order's gateway, amount, and final status. Match those against your gateway statements to check that everything lines up. ### Holds and reserves Holds, rolling reserves, and reverse-flow refunds are gateway-specific and live in each provider's own dashboard, not in Shoppex. If Stripe puts your account on a reserve, you see it in the Stripe dashboard, and your Shoppex orders continue normally. Stripe controls when the funds disburse. # Refunds and disputes Source: https://docs.shoppex.io/payments/refunds Issue full or partial refunds from the dashboard, and see what happens when a buyer disputes a charge. Refund any completed order, in full or in part, from the order detail page in **Orders**, and set the exact amount yourself. The refund starts on the original gateway: * **Stripe refunds** go back to the buyer's card through Stripe. * **PayPal refunds** go back to the buyer's PayPal balance or original funding source. * **Crypto refunds through direct wallets** are not automatic. You must send the funds back manually from your own wallet. Mark the order refunded in Shoppex and send the transaction separately. * **Cryptomus / Oxapay** refunds work through their own dashboards. ## Crypto refund amounts For crypto, the refund is calculated at the **current exchange rate**, not the rate at purchase. If a buyer paid $100 in BTC and BTC has doubled, refunding that $100 worth means sending roughly half the original satoshis. ## Refunds and store credit Instead of refunding to the original payment method, you can credit the buyer's Shoppex wallet on your store. The buyer then uses that balance on a future order. Configure shop-wide behavior under **Settings → Payments → Store Credit**: * Allow refunds to be issued as wallet credit. * Set a default expiry on credits (days, or never). * Cap the per-customer balance. ## Refunds and fulfillment Refunding does not automatically revoke what was delivered: * **File** downloads stay accessible from the buyer's order page. You can revoke manually if you need to. * **Serials** stay assigned to the buyer. Mark them as revoked from the order to release them back into your pool (or burn them). * **Discord roles** — refunding a subscription invoice does not auto-revoke. Revoke the role from your Discord server or wait for the subscription to be canceled. ## Subscriptions Refunding a subscription's most recent invoice does not cancel the subscription. To stop the recurring billing, cancel the subscription separately from **Subscriptions** in your dashboard. ## Disputes Disputes are filed against the gateway that processed the payment, not against Shoppex. Stripe disputes land in Stripe, and PayPal disputes land in PayPal. Shoppex surfaces both in your dashboard, so you do not have to monitor three separate places. For the automated side, including winning disputes and protecting your account from a processor shutting it down, see [Chargeback Shield](/payments/chargeback-shield). This section covers the underlying model: where disputes live, how evidence works, and how to block abusive buyers. ### How you find out When a gateway notifies us of a new dispute, the order moves to a disputed state. You get an event on the `order:disputed` webhook, plus a dashboard notification. The Orders page shows the dispute alongside the original order. ### Responding Each gateway has its own evidence flow. Stripe gives you a deadline (typically 7-14 days depending on the card network). PayPal disputes go through their resolution center with a similar window. You respond inside that gateway's interface — Stripe Dashboard or PayPal Resolution Center. Shoppex does not proxy evidence submission, because the gateway is the one judging the case. What Shoppex contributes: the order detail page shows everything captured at checkout — buyer IP, country, device fingerprint, order timestamp, fulfillment proof (download log, serial delivered, role granted). Copy what is relevant into your gateway's evidence form. ### Prevention Most disputes for digital goods come from buyers who do not recognize the charge on their statement. Two things help: * Set a clear shop name in **Settings → Shop**. This is what flows into the gateway's descriptor field. * Send fulfillment quickly. Buyers who have not received what they paid for after a few hours are more likely to dispute than wait for support. ### Blacklisting bad buyers If a specific buyer disputes after fulfillment (also called "friendly fraud"), add them to **Security → Blocked** (`/security/blocked`) to block future purchases. Shoppex supports blocking by: * Email * IP address or IP range * Country * Domain * ASN or ISP * City * Crypto wallet address Use the most specific entry that matches the abuse. Country-level blocks are broad and can affect many legitimate buyers. ASN or IP-range blocks target a repeat abuser more precisely, and they are usually the better choice. # Playbooks Source: https://docs.shoppex.io/playbooks/overview Step-by-step playbooks for common shop launches, sales, and migrations. These five playbooks walk through complete flows for a Discord supporter tier, a Telegram-only shop, a subscription switch, a Black Friday sale, and a platform migration. ## Discord supporter tier This walkthrough takes you from a Discord server with no monetization to a working paid supporter tier in about 20 minutes. ### What you will have at the end * A Discord role that gets granted automatically when someone subscribes on your shop. * The role is revoked when the subscription is canceled or fails, as long as "Remove role on cancel" stays on. This setting is on by default. * A customer dashboard your buyers use to manage their subscription themselves. ### Prerequisites * A Discord server you own (you need `Manage Server` permission). * A Shoppex shop with at least one payment gateway connected. If you do not have one, do [Quickstart](/start/quickstart) first. ### Step 1 — Connect Discord and invite the bot In your Shoppex dashboard, open **Settings → Integrations → Discord**. 1. Click **Connect Discord**. This is the OAuth step that links your Discord identity to your Shoppex account. 2. Pick the target server from the dropdown that appears. 3. Click **Invite Bot**. Discord opens its OAuth approval flow asking you to authorize the Shoppex bot on the server you picked. 4. The bot requests **Manage Roles**, **View Channels**, **Send Messages**, and **Embed Links**. Accept. 5. Discord redirects you back to Shoppex with the bot connected. In your Discord server settings, you will now see the Shoppex bot listed as a member. **Important:** its role in the server hierarchy must sit *above* any role you intend to sell. This is a Discord rule: bots can only manage roles below their own. If needed, drag the Shoppex role up in **Server Settings → Roles**. ### Step 2 — Create the supporter role In Discord → **Server Settings → Roles → Create Role**. Give it a name like `Supporter`, pick a color, set any permissions for private channels you want this role to see. You can now connect this role to a Shoppex product. ### Step 3 — Create the subscription product Back in Shoppex → **Products → New product**. * **Type:** Subscription (this is the product's **Type** field, not the Discord role. The Discord role comes next.) * **Title:** "Supporter — Monthly" (or whatever fits). * **Price:** Set monthly amount and billing interval. Then scroll to the product's **Discord** section and: * Toggle **Assign Role** on. * Pick the server. * Pick the `Supporter` role (loaded live from your server). * Leave **Remove role on cancel** on (the default) so the role gets revoked when the subscription ends. Save the product. It is now live on `yourshop.shoppex.io`. ### Step 4 — Test the flow yourself Open your shop in an incognito window. Buy the product with one of your own gateways (or use a Stripe test key if you connected one). You will see: 1. Checkout asks you to link your Discord account. 2. You authorize through Discord OAuth. 3. You pay. 4. Within a few seconds, the Supporter role appears on your Discord account. If anything stalls, the order page in your dashboard tells you exactly which step is pending. ### Step 5 — Announce it Share the product link in your server (or anywhere). Buyers click → checkout → done. ### What happens when someone subscribes Each month: * The subscription auto-renews against the gateway the buyer used at signup. * If renewal succeeds, the role stays. Neither you nor the buyer needs to do anything. * If renewal fails, Shoppex enters a 7-day grace period (configurable in shop settings). The role stays. The buyer is asked to update payment. * Once the subscription has two voided invoices total (cumulative across its lifetime, not only consecutive), Shoppex auto-cancels it. The role is revoked at cancel time. Buyers can cancel anytime from their customer dashboard. Cancellation behavior depends on the gateway: see [Subscriptions](/selling/subscriptions#cancellation) for the specifics. Stripe and crypto cancel at the end of the billing period. PayPal cancels immediately. ### Common questions **"Can I have multiple supporter tiers?"** Yes. Create a separate product per tier (Bronze / Silver / Gold), bound to different roles. A buyer can hold several at once. **"Can I grant roles across multiple servers?"** Not from one product. **Assign Role** binds to one role on one server. To cover multiple servers, create multiple products and either bundle them or use addons. **"What if a buyer leaves my Discord server?"** Shoppex cannot grant a role to someone who is not in the server. The order pauses in pending-fulfillment until they rejoin. After they rejoin, you can re-trigger fulfillment from the order page. **"What if I delete the role?"** Shoppex cannot grant a role that no longer exists. Existing holders lose it implicitly, because the role is gone, and new orders fail to fulfill. Do not delete a Discord role that is tied to an active subscription. Existing holders lose the role, and new orders fail to fulfill. See [Subscriptions](/selling/subscriptions) for trials, plan changes, retries, and pauses on any subscription product. ## Telegram-only shop This playbook connects a Telegram bot for sales and, if you want, hides the web storefront so Telegram is the only sales channel. ### What you will have at the end * A Telegram bot connected to your Shoppex shop. * The bot answers buyer questions and creates payment links right in the chat. * Broadcasts and journeys to bring buyers back. * (Optional) The web storefront at `yourshop.shoppex.io` disabled, so the bot is the only sales channel. ### Prerequisites * A Telegram bot you have created with **@BotFather** on Telegram. You will need its API token, the long string that BotFather sends you after `/newbot`. * A Shoppex shop with at least one payment gateway connected. ### Step 1 — Connect the bot Open your Shoppex dashboard at **Store → Telegram** (`/store/telegram`). 1. Click **Connect bot** or **Add bot**. 2. Paste the bot token from BotFather. 3. Save. Shoppex verifies the token, fetches the bot's profile, and registers it against your shop. The bot is now connected and listening. ### Step 2 — Turn on the AI sales agent In the same Telegram page, configure the bot's **Agent**. There are three modes: * **`CUSTOMER_SALES`** — the bot talks to buyers, answers product questions, creates payment links when they want to buy. * **`MERCHANT_ASSISTANT`** — the bot works for you only: DM it to check orders or get daily revenue. It does not talk to buyers. * **`BOTH`** — the same bot serves buyers and you, distinguished by who is chatting. For a sales-channel use case, pick **`CUSTOMER_SALES`** (or `BOTH` if you also want to use the bot yourself). ### Step 3 — Test a purchase DM your bot from a different Telegram account. Ask about a product, or say "I want to buy X". The agent will: 1. Find the product in your catalog. 2. Reply with the product info and a **Pay now** button. 3. Tapping the button opens the Shoppex hosted checkout in Telegram's in-app browser. 4. The buyer pays through your normal gateways. 5. Order confirmation is delivered both through email and (if the buyer linked Discord/Telegram to their customer profile) directly in the chat. The payment itself happens on `checkout.shoppex.io`, not inside Telegram natively. The bot generates a Shoppex payment link and hands it over through an inline button. ### Step 4 — Build out user management Once buyers start interacting, the **Users** section of the Telegram dashboard fills up. You can: * See who has chatted with the bot (with last-seen, total interactions). * Toggle per-user notification settings. * Build **Segments**, saved filters like "buyers in the last 30 days" or "VIPs over \$500". ### Step 5 — Set up broadcasts and journeys For ongoing engagement: **Broadcasts** are one-off announcements to a list. Draft a message, pick a segment or all users, schedule it for later, or send immediately. Statuses: `DRAFT`, `SCHEDULED`, `SENDING`, `COMPLETED`, `CANCELLED`, `FAILED`. **Journeys** are automated triggers. They fire when an event happens for a user. Available trigger types: * `ORDER_COMPLETED` — thank-you flow after a purchase. * `ABANDONED_CART` — nudge buyers who opened checkout but did not pay. * `PAYMENT_REMINDER` — invoice past due reminder. * `SUBSCRIPTION_RENEWAL` — renewal heads-up. * `MANUAL` — fired by you or the API on demand. Journeys can branch and wait, for example "if a buyer has not paid after 4 hours, send the discount code". ### Step 6 — (Optional) Disable the web storefront If you do not want anyone visiting `yourshop.shoppex.io`, turn off the managed storefront from **Settings → Shop**. The field is `managedStorefrontEnabled`. Turn it off, and optionally add a redirect URL, for example to your Telegram bot's t.me link. After that, the public storefront URL no longer serves your shop. The Telegram bot is the only entry point. Payment links and direct checkout URLs still work. They do not depend on the storefront. ### Common questions **"Can the bot accept payment directly in Telegram (no redirect)?"** Not currently. Shoppex generates a Shoppex-hosted checkout URL and the bot sends it as an inline button. The buyer taps, pays in Telegram's in-app browser. There is no native Telegram Stars or native Telegram payments integration. **"Can buyers manage their subscription from the bot?"** Yes for queries through the agent ("show me my subscriptions"), but cancel and upgrade actions still go through their customer dashboard on Shoppex. **"Multiple bots for the same shop?"** Each shop can connect multiple bots. This is useful for testing in a separate bot before you swap the primary bot, or for running different language versions. ## Switch to recurring This playbook moves a product line from single-use codes, like Steam keys or activation tokens, to an auto-renewing subscription. It keeps the old product running, launches the new one, and moves customers over during the renewal cycle. This is the safest migration path. ### Why not edit the existing product? Technically Shoppex allows changing a product's `type` (the type field is mutable on update). But changing `SERIALS` to `SUBSCRIPTION` on a live product does not carry over what you actually need. It creates no automatic Stripe plan, migrates no past buyers to the new billing terms, and handles no pending serial deliveries. The clean path is to **create a new subscription product** alongside the old one, then deactivate the old one once customers have moved. ### Step 1 — Create the subscription product Open **Products → New product**, the same page used in Discord supporter tier above: * **Type:** Subscription * **Title:** the new name (for example "Pro Access — Monthly") * **Price:** monthly amount * **Billing interval:** Day / Week / Month / Year (and a count, for example interval=Month, count=1 for monthly) * **Trial period:** optional, up to 60 days In the **Discord** section (if applicable), bind a supporter role with **Assign Role** so subscribers get access automatically. See [Discord supporter tier](#discord-supporter-tier) above for the full Discord setup. If your old product delivered files or serials on each purchase, set the subscription product's **delivery type** to match. Subscriptions can deliver `SERIALS`, `FILE`, `SERVICE`, or `DYNAMIC` content on each renewal. Save the product. It is now live on your storefront. ### Step 2 — Announce the change to existing buyers Email or message your existing customer list. Be explicit: * The old product (one-time codes) is staying available through a specific date. * The new subscription product is now live. Mention its benefits: for example a lower monthly price, better access, or automatic renewal. * After the cutover date, only the subscription product will be sold. Tools available to you: the built-in email marketing (**Marketing → Email**), Telegram broadcasts if you have a bot, and Discord announcements. ### Step 3 — Move the old buyers There is no automated way to convert a buyer's past one-time purchase into a subscription. The payment method changes from one-off to recurring, and the gateway agreement is different. The buyer must subscribe again on their own. The most reliable way to nudge them: offer a discount coupon on the first month of subscription, restricted to existing customers. Create the coupon at **Discounts** (`/coupons`): * Type: `PERCENTAGE` (or `FIXED`) * Value: whatever feels right (20-50% on first month is typical for migration) * `validUntil`: a hard cutoff * `maxUses`: total count to cap exposure Share the code in the same announcement. See Black Friday sale below for the full set of coupon fields and limits. ### Step 4 — Wind down the old product When the cutover date arrives, deactivate the old product instead of deleting it. From the old product's settings, toggle it inactive. It disappears from your storefront, but the past orders, delivered codes, and customer records all stay intact. Do not delete the old product. Deleting it breaks historical order references. Toggle it inactive instead. ### What about Discord roles already granted? If the old product granted a Discord role, those grants stay. The buyer keeps the role until you remove it manually. Most merchants either: * Let the old grants stand as a goodwill gesture (existing customers keep access forever). * Set a date and bulk-revoke from the Discord side, with a clear announcement. Subscription-bound roles are auto-revoked on cancel (when **Remove role on cancel** is on for the subscription product), so the role lifecycle is automatic going forward. ### Common questions **"Will the new subscription auto-charge buyers who had recurring billing somewhere else?"** No. Recurring billing is per-product per-buyer per-gateway. The new subscription is a fresh authorization. The buyer goes through checkout once to set it up. **"Can I keep both running indefinitely?"** Yes. Some merchants keep a one-time purchase option for buyers who prefer not to subscribe, alongside the subscription as the default. Make sure the pricing makes the subscription the clearly better deal. ## Black Friday sale This two-week walkthrough runs a major sale on Shoppex, built around Black Friday but equally usable for Cyber Monday, end-of-quarter sales, or a launch. ### What you will do * Prep your catalog (which products are on sale, by how much). * Create coupon codes, with limits and product-targeting. * Build the buyer list to email and Telegram-blast. * Schedule the email campaign for the moment the sale opens. * Track conversions live and react if something underperforms. * Run the post-mortem. ### Two weeks before — prep **Pick the discount strategy.** Coupon types Shoppex supports: * **`PERCENTAGE`** — N% off the order. * **`FIXED`** — fixed-amount discount. (There is no built-in BOGO. For buy-one-get-one, use bundle products instead.) For each coupon, configurable limits: * `maxUses` — total redemptions across all buyers (cap the exposure). * `minOrderAmount` — minimum order subtotal to be eligible. * `maxDiscount` — cap the absolute discount (useful for percent codes on big orders). * `validFrom` / `validUntil` — time window. Coupon code returns invalid outside these times. * Product / variant binding — restrict to specific products. Without bindings, coupon applies to anything. Decide your codes: * A general code (`BF25` for 25% off everything, capped at \$X). * Product-line specific codes (`PRO-BF` for the Pro tier only). * VIP code with bigger discount, low max-uses, shared only with your top customers. Create them at **Discounts** (`/coupons`). **Build your email lists and segments.** In **Marketing → Email** (`/marketing/email`), your list of buyers is automatically maintained. Build segments based on what you want to target: * All past buyers. * Buyers in the last 90 days (recent and warm). * High-value buyers (LTV > \$X). * Trial-only buyers (never converted to paid). Segments are saved filters. Once you build one, it stays current as new customers come in. **Draft the email.** Email Marketing's template system is **MJML-based**. You can either: * Use the **builder** to drag-and-drop the email. * Write MJML directly if you want pixel-precise control. Personalize the email with variables: first name, the buyer's most-bought category, or the discount code itself. These variables come from the campaign-recipient context. Send a test to yourself before you schedule the campaign. A broken template can render differently in each mail client. **Build a parallel Telegram blast (if you use Telegram).** The same idea works on the Telegram side. **Store → Telegram → Broadcasts → New broadcast.** * Pick the target segment. * Compose the message (include the coupon code or a deeplink). * Schedule for the same moment as the email. ### Sale day — go live **Schedule everything in advance.** Do not manually click "Send" at 9am. Set the schedule the night before instead. Both campaigns (`emailMarketingCampaigns.scheduleAt`) and Telegram broadcasts support scheduled send. You wake up to the campaigns already going out. Make sure your coupons' `validFrom` matches the announcement time. If you email at 9am with a code that says "valid from 10am", buyers will hit "invalid code" on the first 100 tries, and you will lose them. **Watch the dashboard.** During the sale, watch these live indicators: * **Analytics → Revenue** (`/analytics`) — running revenue total. * **Orders** page — orders coming in, filterable. * **Email campaign status** — sent / delivered / opened / clicked counts, available per campaign. * **Telegram broadcasts** — same kind of stats, per broadcast. Shoppex does not have a built-in coupon analytics dashboard. To see how many sales each code drove, filter Orders by coupon code. Every order with a redeemed coupon has the code attached, so you can read the count and revenue from there. **React if something underperforms.** If the email got delivered but conversions are flat, consider: * A follow-up email 12-24 hours later with a different angle (urgency, social proof). * Lower the coupon's `minOrderAmount` if you are getting "cart abandoned because code did not apply" complaints. * Create a deeper discount code for an off-hours segment. If conversions are *too* strong on a wholesale-margin product, you can mark the coupon inactive during the sale. Buyers who already redeemed it are unaffected, but the code stops working for everyone else. ### After the sale — post-mortem Within a few days of close: 1. Filter Orders by each coupon code. Count: total redemptions, average order value, total revenue. 2. Compare the results against the same week last month, or last year if you have the data. This shows the real lift from the sale, not only orders from your existing buyers. 3. Check Email Marketing stats: open rate, click rate, conversion rate per campaign. 4. Note which segment converted best. That is where you focus for the next campaign. Save the coupons, but mark them inactive. Keeping the codes intact preserves your historical record, since orders that redeemed them stay linked. ### Common pitfalls * **Forgetting `maxUses`** on a percent code shared publicly. If someone leaks it, you end up refunding a thousand orders. Always cap `maxUses`. * **Email templates that break on iOS Mail.** Test before scheduling. MJML compiles to cross-client HTML but you still want to eyeball it. * **Coupon stacking.** Shoppex applies one coupon per cart. If buyers complain that they cannot use both codes, that is expected. Pick which coupon wins, usually the larger discount. * **Shipping a sale on a day where one gateway is down.** Test Stripe, PayPal, crypto *before* the announcement goes out. ## Migrate to Shoppex This guide migrates an existing shop from **Sell.app**, **SellAuth**, or **Komerza** to Shoppex, using the same flow for every platform except the export step. The export step is the only part that differs per platform. The whole migration usually takes one to two hours of active work, plus a few hours of DNS propagation in the background. ### What you will have at the end * All your products live on Shoppex with the same SKUs. * Payment gateways connected to your own accounts. * Custom domain pointing at Shoppex (optional). * Old shop links redirecting (manual on your side). * No data lost. ### Step 1 — Export your data from your current platform You are looking for: **products with prices and descriptions, license keys / serial pools, and customer order history if available**. Most platforms expose this under "Data Export", "Backup", or "Tools" in their dashboard. Exact menu paths shift over time, so check the current vendor docs if anything has moved. The data shape you need is the same regardless of platform. The dump needs one row per product, and one row per remaining serial or license key. If you have that, in CSV or JSON, you can re-import everything. You do not need to re-upload customers. Shoppex creates new customer records as buyers come in. Save the export anyway: it is your archive. ### Step 2 — Sign up for Shoppex and pick your shop URL At [dashboard.shoppex.io](https://dashboard.shoppex.io), create an account. Pick a slug for your default URL (for example `yourshop.shoppex.io`). If you plan to use a custom domain, the slug does not matter long-term. Buyers will reach the custom domain instead. ### Step 3 — Connect payment gateways This is the biggest difference from your old platform: **you connect your own gateway accounts**, not Shoppex's. Open **Settings → Payment gateways**. Set up each one you want: * **Stripe** — paste publishable and secret key from your Stripe dashboard. * **PayPal SDK** — paste client ID and secret from PayPal Developer Console. * **PayPal F\&F** — your PayPal Business email. * **Cash App** — your `$cashtag` and Gmail forwarding. * **Crypto** — your wallet addresses, or Cryptomus / Oxapay API keys. See [Payment gateways](/payments/gateways) for the full per-provider walkthrough. **Important:** if you were using merchant-of-record on the old platform (some platforms operate as MoR by default), you will need to switch to your own gateways now. Shoppex does not operate as MoR. Funds settle directly to your own Stripe, PayPal, or wallet accounts. ### Step 4 — Recreate your products For each product on your old shop: 1. **Products → New product** in Shoppex. 2. Set the same title, description, price. 3. Pick the fulfillment mode that matches: * Files → **File**. * License keys / activation codes / Steam keys → **Serials** or **Licenses** (Licenses if you need HWID-locking). * Discord access → **Subscription**, and assign a Discord role from the product's Discord section, the same role-binding steps as [Discord supporter tier](#discord-supporter-tier) above. * Monthly billing → **Subscription**. * Custom service / boost / design → **Service**. 4. If it is a serials product, paste your old key pool into the **Serials** tab. If you have many products, doing this by hand is tedious. The [Developer API](/developers/quickstart) lets you script it. Write a small migration tool that reads your old export and sends one product at a time to `/dev/v1/products`. There is no bulk-create endpoint. Do not run too many requests at once, and respect the rate limit. The contracts are documented in [API Reference → Products](/developers/api-overview). ### Step 5 — Move your custom domain You can choose between two strategies, depending on how much downtime you can tolerate. **Cleanest: dual-domain transition.** Keep your old shop running on its current domain. Set up Shoppex on a new domain (for example `new.yourbrand.com` or `yourbrand.shop`). Test thoroughly. When ready, swap DNS so the main domain points at Shoppex. This means you have two shops live at the same time for a few days while you confirm Shoppex works for your buyers. You have no revenue gap during the switch. **Fastest: direct swap.** Update DNS on the day you are ready. Old shop goes dark when DNS propagates. For the actual DNS setup, see [Custom domain](/storefront/custom-domain). ### Step 6 — Redirect your old product links Shoppex URLs look different from your old platform's. Old buyers, old social posts, and search-engine results will hit dead URLs unless you redirect. If you control the old platform's domain, set up a redirect rule. For Cloudflare: 1. Add the old domain to Cloudflare. 2. Rules → Redirect Rules → "If URL contains `/product/`, then 301 to `https://yourbrand.shoppex.io/product/{path}`". For domains you cannot redirect, for example third-party platforms hosting old links, there is nothing to do. You lose those click-throughs. ### Step 7 — Notify your customers Send an email or Discord announcement explaining the move. Include: * The new shop URL. * Tell buyers that existing subscriptions on the old platform do not carry over. Stripe and PayPal do not transfer subscriptions between merchants, so buyers must re-authorize the recurring charge on Shoppex. * A link to your support channel for questions. The subscription point is the most painful part of any migration. Communicate this clearly. ### Step 8 — Shut down the old shop Monitor the new shop for a week or two. Check that there are no support tickets about missing items, that search results are updated, and that social posts point at the new shop. Then close the old account. Keep the export file permanently. It is your historical record. ### Common pitfalls * **Subscriptions do not migrate.** This is the biggest surprise for most merchants. Buyers must re-subscribe on Shoppex with their own payment method. Plan a transition window of a month where you message them, ideally with a discount code to make it easier. * **License keys you sold but never delivered**, for example ones still in the old shop's pool, go into the Shoppex Serials pool. Once sold and delivered, they stay with the buyer forever. * **Customer wallet balances on the old shop** cannot transfer. If buyers had store credit on your old platform, you can manually re-credit their Shoppex wallet from the Customers page as a goodwill gesture. * **Disputes filed on old gateway accounts** stay with those gateways. Watch your old Stripe / PayPal dashboards for a few months after the cut-over. # Discord roles Source: https://docs.shoppex.io/selling/discord-roles Grant a server role automatically when a buyer's payment clears. Sell access to private channels, supporter tiers, or paid communities by binding a product to a Discord role. When the buyer pays, Shoppex grants them the role on your server through the Discord API. ## Setup The invite link is on **Settings → Integrations → Discord** (`/settings/integrations/discord`). The bot requests **Manage Roles**, **View Channels**, **Send Messages**, and **Embed Links**. Its own role in the server hierarchy must sit *above* any role you intend to sell. This is a Discord rule: bots can only manage roles below their own. On a product, choose the product **Type** (usually `Subscription` for recurring access). In the **Discord** section, toggle **Assign Role**. Then pick the server and the role, loaded live from your server. Buyers are prompted to authorize Discord through OAuth, so Shoppex can grant the role to their account. If the buyer skips that authorization, the order cannot fulfill. ## When the role is granted The grant runs asynchronously after payment, through an outbox queue, so transient Discord API failures retry automatically. Most grants land within a few seconds. If Discord is down or rate-limiting, Shoppex retries on a fixed schedule: **1 minute, 5 minutes, 30 minutes, 2 hours, 12 hours** — five attempts over about 14 hours. If the role has not landed after the last attempt, the grant is marked failed, and the order's fulfillment status reflects that. Once the issue is resolved, re-trigger the grant from the order page. ## Revoking For one-time purchases, the role stays granted indefinitely. The buyer keeps it until you remove it manually or they leave the server. For **subscription products**, revoke behavior is per-product. By default, new products have **Remove role on cancel** enabled. The role is revoked when: * The subscription is canceled. * The subscription moves to a canceled state because the buyer stopped paying (two voided invoices trigger auto-cancel — see [Subscriptions](/selling/subscriptions)). If you turn off **Remove role on cancel**, the role stays granted regardless of subscription status. If the same buyer holds another active subscription tied to the same role, the role is kept either way. ## Multiple servers Bind a product to a role on one server. If you want to grant roles across multiple servers in the same purchase, set up more products and either bundle them or use addons. ## Troubleshooting If a buyer reports they did not get their role: 1. Check the order's fulfillment status. If the status is pending, the Discord OAuth authorization is still missing. The buyer needs to complete the link from their order page. 2. Check that the bot has Manage Roles permission and sits above the target role in the server hierarchy. 3. Check that the role still exists. If you deleted it, Shoppex cannot grant it. 4. If fulfillment is marked failed after all five retries, wait until Discord is back, then re-trigger the grant from the order page. # Orders Source: https://docs.shoppex.io/selling/orders Find an order, read its status, and act on it when something needs your attention. Every paid checkout creates an order. **Orders** in the sidebar lists all of them, newest first, across every payment method you accept. The page lives at `/invoices` in the dashboard. Orders and invoices are the same object in Shoppex: the order is what the buyer bought, the invoice is what they paid. ## The list Each row shows six columns: **Order**, **Amount**, **Payment method**, **Status**, **Date**, and an actions menu. Three filters narrow the list: * **Status** — see below for what each value means. * **Payment method** — the gateway that processed the payment. * **Order type** — separates normal shop orders from reseller and other order sources. Search accepts an order ID or a buyer email address. ## Order status | Status | What it means | What you do | | ----------- | --------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | | `PENDING` | The buyer started checkout but the payment has not cleared. Crypto payments sit here until they reach the required confirmations. | Wait. The status changes on its own when the gateway confirms. | | `COMPLETED` | Payment cleared and fulfillment ran. | Nothing, unless the buyer reports a problem. | | `EXPIRED` | The buyer never paid and the checkout window closed. | Nothing. The stock goes back to the pool. | | `REFUNDED` | You sent the money back. | Check whether the delivered content also needs revoking. See [Refunds and disputes](/payments/refunds). | | `VOIDED` | The order was cancelled before payment cleared. | Nothing. | ## Acting on a single order Open an order to see the buyer, the line items, the payment details, and what was delivered. The actions you can run from there: | Action | Use it when | | -------------------------- | -------------------------------------------------------------------------------------------------------------------------- | | **Process** | The payment cleared but fulfillment did not run. This runs it again. | | **Retry delivery** | A [dynamic product](/selling/products#dynamic) failed to deliver because your endpoint was down. Fix the endpoint first. | | **Fulfill line item** | A [Service](/selling/products#service) product is waiting on you, or an item needs manual delivery. | | **Replacement** | A buyer reports a dead serial. This issues a new code from the pool and burns the bad one. | | **Refund** | You are sending the money back, in full or in part. | | **Void** | You want to cancel the order before payment clears. | | **Accept partial payment** | A crypto buyer sent less than the invoice total and you decide to accept it. | | **Verify payment** | You want Shoppex to check the payment state with the gateway again. | | **Recover payment** | A payment failed and you want to give the buyer another attempt. | | **Resend email** | The buyer never received the delivery email. | | **Reset downloads** | A buyer hit the download limit for a file product and needs another try. | | **History** | You want the full audit trail of everything that happened to this order. | | **Blacklist** | The buyer is abusive or fraudulent. This blocks future purchases. See [Customers](/customers/overview#blocking-customers). | ## When an order looks stuck Work through these in order: 1. **Check the status.** If it is `PENDING`, the gateway has not confirmed yet. Crypto needs its confirmations before anything happens. 2. **Check the fulfillment state on the order.** A paid order with nothing delivered means fulfillment failed, not payment. 3. **For dynamic products**, look at whether your endpoint answered. Shoppex makes one attempt with a 15-second timeout and does not retry on its own. Fix your endpoint, then use **Retry delivery**. 4. **For Discord roles**, confirm the buyer completed the Discord authorization. Without it the grant cannot run. See [Discord roles](/selling/discord-roles). 5. **Still stuck?** Open **History** on the order. It records every state change with a timestamp. ## Talking to the buyer Every order has a conversation thread. Use it instead of moving to email or Discord, because the messages stay attached to the order. If the buyer later disputes the charge, Stripe and PayPal accept the whole order page as evidence, including the chat history, the custom field values, and your delivery proof. See [Support inbox](/customers/inbox) for how tickets work. # Products Source: https://docs.shoppex.io/selling/products What you can sell on Shoppex, and how fulfillment works for each type. Every product has a fulfillment mode that decides what the buyer receives the moment their payment clears. Pick the mode that matches what you are selling. Shoppex handles delivery automatically for every mode except Service. Shoppex has six fulfillment modes: * **File** — upload once. Shoppex delivers the same download link to every buyer. * **Serials** — upload a pool of codes. Each buyer gets a unique one. * **Subscription** — charge on a recurring schedule, with auto-renewal. * **Service** — manual fulfillment. You get notified, then you deliver the order yourself. * **Dynamic** — your own webhook returns the delivery payload for each order. * **Info card** — information-only. Use it for listings, ads, or "request a quote" placeholders that need no automated delivery. [Discord roles](/selling/discord-roles) are not a separate mode. You add them to a product that already uses one of the six modes, most often a Subscription product. Licenses work the same way. See [Serials and licenses](/selling/serials-and-licenses). ## File The File fulfillment mode delivers a download link to every buyer immediately after payment. The buyer can access the download from their customer dashboard for as long as the order stays valid. ### Uploading Open your product, go to the **Files** tab, and upload. If you want buyers to receive a bundle (for example, a ZIP, a PDF, and a license file), attach multiple files to the same product. ### File size Product file uploads are capped at **50 MB per file** in the dashboard UI. The effective hard ceiling is **16 MB** at the API gateway, so anything larger fails the upload regardless of the UI cap. For larger assets, host the file on your own storage (S3, Cloudflare R2, your CDN) and point the product at an external URL. Shoppex delivers the URL through the same protected delivery flow, so the buyer experience stays identical. ### Updating the file Replace files at any time from the product's **Files** tab. ### Download protection Downloads are gated by the buyer's logged-in customer dashboard session. Anyone who is not signed into the buyer's customer account cannot pull the file, and there is no public sharable URL that can leak. If you suspect abuse on a specific order, for example one buyer's link shared widely, revoke the order from your dashboard. The buyer loses access immediately. ### Stock behavior File products have unlimited stock. Shoppex delivers the same file to every buyer. ## Serials Shoppex delivers a unique code from a pool you upload, one per buyer. See [Serials and licenses](/selling/serials-and-licenses) for setup, hardware-locked licenses, and the event audit trail. ### Stock behavior Serials are inventoried. Shoppex tracks how many codes remain in your pool, and the listing automatically becomes "out of stock" when the pool hits zero. ## Subscription Subscription mode charges the buyer on a recurring schedule, with auto-renewal. See [Subscriptions](/selling/subscriptions) for billing cycles, retries, and cancellation. ### Stock behavior Subscription products are not inventoried. They do not run out. ## Service Use Service mode when you deliver the product yourself, for example design work, game-boosting, account services, or custom orders. Shoppex collects payment and notifies you. The actual delivery happens off-platform. ### How an order moves 1. Buyer pays. The order is marked **Paid** but **pending fulfillment**. 2. You receive an email and a dashboard notification. 3. You do the work, outside Shoppex, on your own schedule. 4. Mark the order **fulfilled** from its detail page, optionally attaching a note or file for the buyer. 5. The buyer is emailed your completion message. ### Custom fields Service products almost always need information from the buyer, for example a username, target server, game tag, or Discord ID. Add **custom fields** to the product, and Shoppex prompts the buyer at checkout. The submitted values appear on the order page when you open it. Field types are **text**, **textarea**, **number**, **checkbox**, or **hidden**. Each field can be required or optional. You can also attach an optional **regex** pattern for client-side validation, for example to check emails, URL formats, or any custom shape you need. ### Communicating with the buyer The order page has a built-in conversation thread between you and the buyer. Use it instead of moving the conversation to Discord or email. Every message stays attached to the order, and that message history becomes useful evidence if the buyer later disputes the charge. ### Dispute risk Service products are the most dispute-prone category on Shoppex, because there is no automatic proof of delivery. To stay safe: * Keep the conversation in the Shoppex order thread. * Attach screenshots, videos, or logs of completed work to the order. * Mark the order fulfilled only after the buyer has confirmed receipt. When a dispute comes, Stripe and PayPal accept the full Shoppex order page as evidence. This includes buyer chat history, custom field values, and your fulfillment attachments. ### Stock behavior Service products are not inventoried. They do not run out. ## Dynamic Dynamic fulfillment lets you generate each buyer's delivery payload at order time, instead of pre-uploading a pool of files or codes. Use it when: * The product is generated per-buyer (rendered PDFs, signed binaries, account credentials). * You are sourcing from an external system you control (your own license server, a third-party API, a database of generated codes). * Stock is effectively infinite, but you do not want to pre-upload anything. ### How it works 1. You configure a **dynamic delivery URL** on the product, an endpoint on your own backend. 2. When a buyer pays, Shoppex sends a request to that endpoint with the order details. 3. Your endpoint returns the delivery payload, for example text, a code, or a URL. 4. Shoppex stores that payload against the order and shows it to the buyer. ### Setting up the endpoint You provide an HTTPS URL on your own infrastructure. Shoppex sends a signed `POST` request with the order data. You respond with the payload. The full request and response contract is documented in the [Dynamic delivery reference](/developers/dynamic-delivery). ### Failures If your endpoint is down or returns an error, the order moves to a failed-fulfillment state. The buyer sees a "delivery pending" message, and you get a dashboard notification. Once you have fixed your endpoint, you can retry the delivery from the order detail page. Dynamic delivery does not retry automatically. Shoppex cannot tell whether a 500 response from your endpoint is a temporary error or a sign that you are rate-limiting requests. You decide how to handle retries. ### When not to use Dynamic If the same file or code works for every buyer, use **File** or **Serials** instead. Use Dynamic fulfillment only when the payload is different for each buyer. ### Stock behavior Dynamic products are not inventoried. They do not run out. ## Info card Info card mode is information-only. Use it for listings, ads, or "request a quote" placeholders that need no automated delivery. There is no download, code, or manual step attached to the order. ### Stock behavior Info card products are not inventoried. They do not run out. ## Variants and pricing A product can have variants, such as sizes, tiers, or region-specific versions. Each variant has its own price, stock, and fulfillment payload. Set one price for the whole product, or set a separate price for each variant. ## Bundles, addons, and quantity deals On top of base products, you can configure: * **Bundles** — sell multiple products as a single SKU at a bundle price. * **Addons** — optional upsells shown on the checkout page. * **Quantity deals** — automatic discount tiers. For example, set 10% off at 5 units and 20% off at 10 units. Configure these from each product's detail page. ## Licenses (advanced) If you sell software, the [Licenses system](/selling/serials-and-licenses#hardware-locked-licenses) goes further than serials. License objects have a status (active, suspended, revoked, or expired), HWID binding to prevent key sharing, IP locks, and a full event audit trail. # Serials & license keys Source: https://docs.shoppex.io/selling/serials-and-licenses Sell single-use codes like license keys, game codes, or gift cards. Shoppex has two related but distinct systems for selling codes: Serials, for simple single-use codes, and Licenses, for software that needs status tracking and activation control. ## Serials (simple) Use Serials fulfillment mode for Steam keys, gift codes, beta access codes, and other one-time codes buyers paste elsewhere. ### Uploading Add codes from the product's **Serials** tab, either paste one per line or upload a CSV. Shoppex stores them as an unlocked pool. When a buyer pays, one code locks to their order and is removed from the pool. The product lists as "out of stock" once the pool is empty. Top it up at any time. ### Replacements If a buyer reports an invalid or already-used code, issue a replacement from the order detail page under **Replace**. The replacement comes from the remaining pool, and the bad code is marked burned so it will not be issued again. ## Licenses (advanced) The Licenses system goes far beyond serials. Each license is its own object with: * **Status**: `ACTIVE`, `SUSPENDED`, `REVOKED`, or `EXPIRED`. Change it at any time. * **HWID binding**: bind a license to the first machine that activates it. Activations from other machines fail unless you reset the HWID. * **IP locks**: restrict a license to a single IP or range. * **Expiration**: licenses can be perpetual or expire on a date. * **Event audit trail**: every license validation (successful or failed), HWID change, IP lock change, status change, and expiration extension is logged. ### Where to find it The full licensing UI lives at `/licenses` in the dashboard, separate from regular products. ### Hardware-locked licenses When a buyer activates a license through your software, your client sends the HWID to `POST /dev/v1/licenses/validate`. Shoppex either: * accepts and binds the HWID if the license is unbound, or * accepts if the HWID matches the bound one, or * rejects if the HWID does not match. When a buyer needs a new machine, they (or you) call **reset HWID** to unbind. Configure this flow from the license detail page or through the [API](/developers/quickstart). ### Status changes Suspending a license blocks future validations without deleting it. Revoking a license is permanent and triggers a license event. Expired licenses stop validating but stay in the system for reference. # Subscriptions Source: https://docs.shoppex.io/selling/subscriptions Recurring billing with trials, retries, and cancellation handling. Set a product to subscription mode to charge buyers on a recurring schedule, with renewals that run automatically against the gateway the buyer used at signup. ## Billing intervals You can bill weekly, monthly, yearly, or on any custom interval. Each renewal creates a new invoice. The buyer sees a complete history from their customer dashboard. ## Trials Offer a free trial or a discounted intro period. The full price applies automatically when the trial ends. The buyer does not need to take any action. Shoppex fires `subscription:trial:started` and `subscription:trial:ended` webhook events so you can build flows around them. ## Plan changes Buyers can upgrade or downgrade from their customer dashboard. Upgrades take effect immediately, with proration against the current period. Downgrades take effect at the next renewal. You can also change plans on the buyer's behalf from the subscription detail page in your dashboard. ## Cancellation Buyers can cancel at any time from their customer dashboard. Cancellation behavior depends on the gateway the buyer used: * **Stripe / crypto** — cancellation is at period end. The buyer keeps access until the period they paid for runs out, then the subscription moves to canceled. There is no refund for the current period. * **PayPal** — cancellation is immediate. PayPal cancels the subscription on their side right away, and the Shoppex subscription moves to canceled at the same time. The buyer loses access immediately. There is no period-end grace. You can also cancel on the buyer's behalf from **Subscriptions** in your dashboard. ## Failed renewals — grace period and auto-cancel When a renewal payment fails, Shoppex enters a grace period before it takes any other action. The default grace period is **7 days** (configurable per shop). During grace: * The subscription stays active. The buyer keeps access. Anything like a Discord role stays granted. * The buyer is asked to update payment. After the grace period, if the renewal has not been paid: * The unpaid invoice is voided. * The subscription moves to a past-due state. Once the subscription has **two voided invoices in total** (cumulative across its lifetime, not only consecutive), Shoppex automatically cancels it. Discord roles and other recurring fulfillment are revoked at cancel time. The buyer can resubscribe at any time. ## Pause and resume You can pause a subscription from the dashboard, which suspends renewals without cancellation. Resume it at any time. The buyer keeps the same plan and pricing. # Quickstart Source: https://docs.shoppex.io/start/quickstart Get your Shoppex shop live, take your first sale, and see what happens next. This walkthrough takes you from signup to your first sale, and each step links to a page with more detail. ## Create your shop Sign up at [dashboard.shoppex.io](https://dashboard.shoppex.io). After you confirm your email, you land on a fresh shop with a URL like `yourshop.shoppex.io`. This URL is live, but it has no products yet. Shoppex routes every sale directly to the payment account that you connect to your shop: Stripe, PayPal, or crypto. There is no Shoppex wallet, no payout delay, and no merchant-of-record layer. When a buyer pays, the funds settle to your own account on the gateway's schedule. ## Connect a payment gateway Open **Settings → Payment gateways** (`/settings/payments/gateways`). Pick at least one provider: * **Stripe** for cards. Paste your **Publishable Key** and **Secret Key** from your Stripe Dashboard. Shoppex validates them, creates a webhook on your Stripe account, and registers Apple Pay automatically. * **PayPal** for buyers who prefer it. Paste a Client ID and Secret from the [PayPal Developer Console](https://developer.paypal.com). * **PayPal Friends & Family** for the no-fee route. It needs only your PayPal email, plus IPN configured on PayPal's side. * **Cash App** (US only). Connect your `$cashtag` with Gmail forwarding. * **Crypto** through Cryptomus or Oxapay (multi-coin aggregators), or through a direct wallet address for BTC, LTC, ETH, or Bitcoin Lightning. Customers see the methods you have enabled at checkout. You can mix as many as you like. See [Payment gateways](/payments/gateways) for the full breakdown of each. ## Add your first product Go to **Products → New product** (`/products/create`). Pick the fulfillment mode that fits what you sell: * **File** — Upload a downloadable file. The buyer gets a download link after payment. * **Serials** — Upload a list of single-use codes (license keys, gift codes). Each buyer gets a unique one. * **Service** — Manual fulfillment. You get notified after payment and deliver yourself. * **Subscription** — Recurring billing on the schedule you set. * **Dynamic** — Generated per order through your own webhook endpoint. * **Info card** — Information-only product, no automatic delivery. For now, the simplest path is **File** or **Serials**. ## Share your shop Your storefront at `yourshop.shoppex.io` is live with whatever products you have added. You can also copy a **payment link** for any single product from its detail page. Share this link in Discord, Telegram, or a social media bio. ## What happens on your first sale Once a buyer completes checkout, Shoppex creates the order and starts delivery based on the product's fulfillment mode. | Mode | What the buyer gets | | ------------ | --------------------------------------------------------------------------------------- | | File | A download link. The same file goes to every buyer, and stock never runs out. | | Serials | A unique code from your pool. The listing goes out of stock when the pool reaches zero. | | Service | Nothing automatic. You get notified after payment and deliver by hand. | | Subscription | Recurring billing on the schedule you set. | | Dynamic | A payload generated by your own webhook endpoint for that order. | | Info card | No automatic delivery. The product is information-only. | You can see every order, whatever its mode, on the **Orders** page in your dashboard. A **Service** product never delivers on its own. Check your notifications after each sale, and complete the delivery yourself. A **Serials** product needs a fresh batch of codes once the pool empties, or the listing stays out of stock. The most common cause of a stuck sale is a Service or Dynamic product waiting on your own action, not a failed payment. See [Products](/selling/products) for the full detail on each mode, including variants, bundles, and quantity deals. **Reaching the buyer.** If the buyer has a question, they open a ticket from their order, and it lands in your **Inbox**. You get an email and a dashboard notification. Reply from the ticket, and the buyer sees your reply from their own dashboard. A ticket moves through **Pending**, **Open**, **Awaiting reply**, **Resolved**, and **Closed**. If a buyer sounds unhappy, resolve it through a ticket first. This is faster than a dispute, and it skips the chargeback fee a dispute always brings. See [Support inbox](/customers/inbox) for ticket states and replying in detail. ## Multiple shops per login You can create more than one shop from a single Shoppex login. Each shop is fully independent: its own URL, its own payment gateways, its own products and orders, and its own team. Shoppex does not copy settings between shops, so a new shop always starts empty. See [Team and stores](/account/team) for how to create more shops and switch between them. ## Related docs Deeper look at Stripe, PayPal, and crypto setup. All six product types in detail. Run the shop on your own domain instead of `yourshop.shoppex.io`. Get notified in your own systems when sales happen. # Block reference Source: https://docs.shoppex.io/storefront/block-reference Every block type a ThemeDocument can use, and the settings each one accepts. An Easy theme is a set of pages built from ordered blocks. This page lists every block type the renderer accepts, and the settings each one reads. This page is generated from the renderer's own settings catalog, so it always matches what the renderer accepts. Regenerate it with `bun apps/docs/scripts/generate-block-reference.ts`. Shoppex validates block settings before it renders a page. An unknown setting key fails the save with the block ID and the offending key. It is never dropped silently, so a typo in an automated edit shows up straight away instead of quietly doing nothing. ## Shared surface settings Most content blocks accept these three settings on top of their own: | Setting | Type | Required | | -------------------- | ---- | -------- | | `style.background` | text | No | | `style.borderRadius` | text | No | | `style.padding` | text | No | ## How to read a block table `Required` means the renderer needs the value to draw the block. Everything else falls back to the theme default. A type of "text, localizable" accepts either a plain string or a map of locale codes to strings, for example `{ "en": "Buy now", "de": "Jetzt kaufen" }`. Some blocks list the same setting twice, once short and once with a prefix, for example `ctaLabel` and `hero.ctaLabel`. Both write the same value. The prefixed form is the canonical one, and it is the form the Builder and the AI tools produce. Prefer it in anything you automate. The short form stays accepted so older documents keep working. ## Layout Every page carries these. They frame the content blocks between them. ### `header` | Setting | Type | Required | | -------------------------------- | ------------------------------ | -------- | | `brand.logo` | text, localizable | No | | `showProductsLink` | boolean | No | | `productsLabel` | text, localizable | No | | `showAboutLink` | boolean | No | | `aboutLabel` | text, localizable | No | | `header.aboutLabel` | text, localizable | No | | `showFaqLink` | boolean | No | | `faqLabel` | text, localizable | No | | `showLoginLink` | boolean | No | | `header.showLoginLink` | boolean | No | | `loginLabel` | text, localizable | No | | `header.loginLabel` | text, localizable | No | | `loginHref` | text, localizable | No | | `header.loginHref` | text, localizable | No | | `showLiveUsers` | boolean | No | | `showSearchButton` | boolean | No | | `navigation.search.placeholder` | text, localizable | No | | `navigation.search.background` | string | No | | `navigation.search.borderColor` | string | No | | `navigation.search.borderRadius` | number or string | No | | `showCartButton` | boolean | No | | `logoSize` | `small` or `medium` or `large` | No | | `header.logoSize` | `small` or `medium` or `large` | No | | `navigationLinks` | array | No | Variants: `classic`, `classic-centered`, `classic-deck`, `classic-minimal`, `clean-minimal`, `clean-minimal-centered`, `clean-minimal-compact`, `clean-minimal-underline`, `default-centered`, `default-minimal`, `default-pill`, `pulse`, `pulse-deck`, `pulse-minimal`, `pulse-pill`, `starlight`, `starlight-centered`, `starlight-minimal`, `starlight-pill` ### `footer` | Setting | Type | Required | | ------------------ | ----------------- | -------- | | `footer.copyright` | text, localizable | No | | `copyright` | text, localizable | No | | `footerLinks` | array | No | Variants: `classic`, `clean-minimal`, `pulse`, `starlight` ### `cart-modal` | Setting | Type | Required | | ------------------------------ | ----------------- | -------- | | `cartModal.heading` | text, localizable | No | | `cartModal.checkoutButtonText` | text, localizable | No | | `cartModal.emptyMessage` | text, localizable | No | | `cartModal.openOnAddToCart` | boolean | No | | `cartModal.showProductImages` | boolean | No | | `cartModal.showCouponField` | boolean | No | | `cartModal.showUpsell` | boolean | No | | `cartModal.upsellHeading` | text, localizable | No | | `cartModal.upsellProductId` | text, localizable | No | Variants: `starlight` ## Content Marketing and information sections. Order them freely on any page. ### `hero` | Setting | Type | Required | | ------------------------- | ---------------------------------------------------------------------------- | -------- | | `hero.title` | text, localizable | No | | `title` | text, localizable | No | | `hero.subtitle` | text, localizable | No | | `subtitle` | text, localizable | No | | `hero.eyebrow` | text, localizable | No | | `eyebrow` | text, localizable | No | | `hero.image` | text, localizable | No | | `hero.visual` | text, localizable | No | | `hero.url` | text, localizable | No | | `hero.primaryCtaLabel` | text, localizable | No | | `hero.primaryCtaHref` | text, localizable | No | | `hero.secondaryCtaLabel` | text, localizable | No | | `hero.secondaryCtaHref` | text, localizable | No | | `hero.showCtas` | boolean | No | | `hero.layout` | `centered` or `centered-avatar` or `split-right` or `split-left` or `banner` | No | | `storeStats.enabled` | boolean | No | | `storeStats.showSales` | boolean | No | | `storeStats.showProducts` | boolean | No | | `storeStats.showRating` | boolean | No | Also accepts the [shared surface settings](#shared-surface-settings). Variants: `classic`, `classic-banner`, `classic-minimal`, `clean-minimal`, `clean-minimal-editorial`, `clean-minimal-framed`, `default-minimal`, `default-spotlight`, `pulse`, `pulse-minimal`, `pulse-split`, `starlight`, `starlight-minimal`, `starlight-split` ### `about` | Setting | Type | Required | | ---------------- | ----------------- | -------- | | `title` | text, localizable | No | | `about.title` | text, localizable | No | | `subtitle` | text, localizable | No | | `about.subtitle` | text, localizable | No | | `image` | text, localizable | No | | `about.image` | text, localizable | No | Also accepts the [shared surface settings](#shared-surface-settings). Variants: `pulse` ### `faq` | Setting | Type | Required | | ----------- | ----------------- | -------- | | `faq.title` | text, localizable | No | | `title` | text, localizable | No | | `faq.items` | array | No | | `items` | array | No | Also accepts the [shared surface settings](#shared-surface-settings). Variants: `classic`, `pulse`, `starlight` ### `text-block` | Setting | Type | Required | | --------------------- | ----------------------------- | -------- | | `textBlock.eyebrow` | text, localizable | No | | `eyebrow` | text, localizable | No | | `textBlock.title` | text, localizable | No | | `title` | text, localizable | No | | `textBlock.body` | text, localizable | No | | `body` | text, localizable | No | | `textBlock.ctaLabel` | text, localizable | No | | `ctaLabel` | text, localizable | No | | `textBlock.ctaHref` | text, localizable | No | | `ctaHref` | text, localizable | No | | `textBlock.alignment` | text, localizable | No | | `alignment` | text, localizable | No | | `textBlock.width` | `compact` or `wide` or `full` | No | | `style.accentColor` | string | No | Also accepts the [shared surface settings](#shared-surface-settings). Variants: `pulse`, `starlight` ### `why-choose-us` | Setting | Type | Required | | ---------------------------- | ----------------- | -------- | | `title` | text, localizable | No | | `subtitle` | text, localizable | No | | `whyChooseUs.title` | text, localizable | No | | `whyChooseUs.subtitle` | text, localizable | No | | `whyChooseUs.cards` | array | No | | `whyChooseUs.features` | array | No | | `features` | array | No | | `whyChooseUs.delivery.title` | text, localizable | No | | `whyChooseUs.delivery.body` | text, localizable | No | | `whyChooseUs.delivery.stats` | array | No | | `style.accentColor` | string | No | Also accepts the [shared surface settings](#shared-surface-settings). Variants: `classic`, `classic-list`, `classic-split`, `clean-minimal`, `clean-minimal-columns`, `clean-minimal-rows`, `default-grid`, `default-list`, `pulse`, `pulse-band`, `pulse-rows`, `starlight`, `starlight-compact`, `starlight-rows` ### `trust-strip` | Setting | Type | Required | | -------------------- | ----------------- | -------- | | `trustStrip.eyebrow` | text, localizable | No | | `trustStrip.items` | array | No | Variants: `clean-minimal` ### `store-stats` | Setting | Type | Required | | -------------------------- | ------- | -------- | | `storeStats.enabled` | boolean | No | | `storeStats.showSales` | boolean | No | | `storeStats.showCustomers` | boolean | No | | `storeStats.showReviews` | boolean | No | | `storeStats.showProducts` | boolean | No | | `storeStats.showRating` | boolean | No | Variants: `clean-minimal` ### `vouches` | Setting | Type | Required | | ------------------ | ----------------- | -------- | | `vouches.title` | text, localizable | No | | `vouches.subtitle` | text, localizable | No | | `amount` | number | No | ### `image` | Setting | Type | Required | | ----------- | ----------------------------- | -------- | | `image` | text, localizable | No | | `alt` | text, localizable | No | | `caption` | text, localizable | No | | `link` | text, localizable | No | | `size` | `full` or `wide` or `medium` | No | | `alignment` | `left` or `center` or `right` | No | Variants: `classic`, `clean-minimal`, `pulse`, `starlight` ### `image-gallery` | Setting | Type | Required | | ------------------------ | ------------------------------------- | -------- | | `imageGallery.title` | text, localizable | No | | `imageGallery.subtitle` | text, localizable | No | | `imageGallery.columns` | number or string | No | | `imageGallery.items` | array | No | | `imageFit` | `cover` or `contain` | No | | `imageGallery.imageFit` | `cover` or `contain` | No | | `tileRatio` | `square` or `landscape` or `portrait` | No | | `imageGallery.tileRatio` | `square` or `landscape` or `portrait` | No | Also accepts the [shared surface settings](#shared-surface-settings). Variants: `pulse`, `starlight` ### `gallery` | Setting | Type | Required | | ---------------------------- | -------------------- | -------- | | `product.gallery.enableZoom` | boolean | No | | `imageFit` | `cover` or `contain` | No | | `product.gallery.imageFit` | `cover` or `contain` | No | Variants: `classic`, `clean-minimal`, `pulse`, `starlight` ### `media-embed` | Setting | Type | Required | | ----------------- | ----------------- | -------- | | `videoUrl` | text, localizable | No | | `height` | number | No | | `privacyEnhanced` | boolean | No | | `videoEmbedSrc` | text, localizable | No | | `resolvedHeight` | number or string | No | | `title` | text, localizable | No | Also accepts the [shared surface settings](#shared-surface-settings). Variants: `classic`, `clean-minimal`, `pulse`, `starlight` ### `blog-posts` | Setting | Type | Required | | -------------------- | ----------------- | -------- | | `blogPosts.title` | text, localizable | No | | `title` | text, localizable | No | | `blogPosts.subtitle` | text, localizable | No | | `subtitle` | text, localizable | No | | `blogPosts.ctaLabel` | text, localizable | No | | `ctaLabel` | text, localizable | No | | `blogPosts.ctaHref` | text, localizable | No | | `ctaHref` | text, localizable | No | | `blogPosts.items` | array | No | | `items` | array | No | Also accepts the [shared surface settings](#shared-surface-settings). Variants: `pulse`, `starlight` ### `content-media` | Setting | Type | Required | | ---------- | ----------------- | -------- | | `title` | text, localizable | No | | `subtitle` | text, localizable | No | | `text` | text, localizable | No | | `image` | text, localizable | No | | `videoUrl` | text, localizable | No | | `ctaLabel` | text, localizable | No | | `ctaHref` | text, localizable | No | Also accepts the [shared surface settings](#shared-surface-settings). ### `content-features` | Setting | Type | Required | | -------------------------- | ----------------- | -------- | | `title` | text, localizable | No | | `subtitle` | text, localizable | No | | `columns` | `2` or `3` or `4` | No | | `features.backgroundImage` | text, localizable | No | | `features.watermarkImage` | text, localizable | No | | `features` | array | No | Also accepts the [shared surface settings](#shared-surface-settings). ### `content-socials` | Setting | Type | Required | | -------------------- | ----------------- | -------- | | `title` | text, localizable | No | | `subtitle` | text, localizable | No | | `socials.eyebrow` | text, localizable | No | | `socials.extraLinks` | array | No | Also accepts the [shared surface settings](#shared-surface-settings). ### `contact-form` | Setting | Type | Required | | -------------------------------------- | ----------------- | -------- | | `title` | text, localizable | No | | `subtitle` | text, localizable | No | | `submitLabel` | text, localizable | No | | `submitLoadingLabel` | text, localizable | No | | `successTitle` | text, localizable | No | | `successMessage` | text, localizable | No | | `sendAnotherLabel` | text, localizable | No | | `invoiceIdLabel` | text, localizable | No | | `invoiceIdHint` | text, localizable | No | | `preSalesTitle` | text, localizable | No | | `preSalesDescription` | text, localizable | No | | `orderSupportTitle` | text, localizable | No | | `orderSupportDescription` | text, localizable | No | | `formDisabledTitle` | text, localizable | No | | `formDisabledDescription` | text, localizable | No | | `showSupportChannels` | boolean | No | | `contact-form.title` | text, localizable | No | | `contact-form.subtitle` | text, localizable | No | | `contact-form.submitLabel` | text, localizable | No | | `contact-form.submitLoadingLabel` | text, localizable | No | | `contact-form.successTitle` | text, localizable | No | | `contact-form.successMessage` | text, localizable | No | | `contact-form.sendAnotherLabel` | text, localizable | No | | `contact-form.invoiceIdLabel` | text, localizable | No | | `contact-form.invoiceIdHint` | text, localizable | No | | `contact-form.preSalesTitle` | text, localizable | No | | `contact-form.preSalesDescription` | text, localizable | No | | `contact-form.orderSupportTitle` | text, localizable | No | | `contact-form.orderSupportDescription` | text, localizable | No | | `contact-form.formDisabledTitle` | text, localizable | No | | `contact-form.formDisabledDescription` | text, localizable | No | | `contact-form.showSupportChannels` | boolean | No | | `communityLinks` | array | No | Also accepts the [shared surface settings](#shared-surface-settings). Variants: `classic`, `pulse`, `starlight` ## Commerce Blocks that read the catalog or move the buyer towards checkout. ### `products` | Setting | Type | Required | | ------------------------------ | ------------------------------------- | -------- | | `title` | text, localizable | No | | `subtitle` | text, localizable | No | | `emptyState` | text, localizable | No | | `products.emptyState` | text, localizable | No | | `products.showCategories` | boolean | No | | `showCategories` | boolean | No | | `products.categoryImageSize` | `small` or `medium` or `large` | No | | `categoryImageSize` | `small` or `medium` or `large` | No | | `productImageFit` | `cover` or `contain` | No | | `products.productImageFit` | `cover` or `contain` | No | | `titleLines` | `1` or `2` or `3` | No | | `products.titleLines` | `1` or `2` or `3` | No | | `mobileColumns` | `1` or `2` | No | | `products.mobileColumns` | `1` or `2` | No | | `productImageRatio` | `square` or `landscape` or `portrait` | No | | `products.productImageRatio` | `square` or `landscape` or `portrait` | No | | `products.showSort` | boolean | No | | `showSort` | boolean | No | | `columns` | `2` or `3` or `4` or `5` | No | | `products.columns` | `2` or `3` or `4` or `5` | No | | `products.productCount` | number | No | | `products.showPrices` | boolean | No | | `products.showReviewStars` | boolean | No | | `products.showAddToCart` | boolean | No | | `products.addToCartButtonText` | text, localizable | No | Also accepts the [shared surface settings](#shared-surface-settings). Variants: `classic`, `classic-list`, `classic-overlay`, `classic-shine`, `clean-minimal`, `clean-minimal-gallery`, `clean-minimal-list`, `default-minimal`, `default-overlay`, `default-shine`, `pulse`, `pulse-spotlight`, `pulse-strip`, `starlight`, `starlight-grid`, `starlight-spotlight` ### `buy-box` | Setting | Type | Required | | ----------------------------------- | ----------------- | -------- | | `product.buyBox.variantLabel` | text, localizable | No | | `variantLabel` | text, localizable | No | | `product.buyBox.quantityLabel` | text, localizable | No | | `product.buyBox.addonsLabel` | text, localizable | No | | `product.buyBox.primaryActionLabel` | text, localizable | No | | `product.buyBox.buyNowLabel` | text, localizable | No | | `product.buyBox.trustBadge1Label` | text, localizable | No | | `product.buyBox.trustBadge2Label` | text, localizable | No | Variants: `classic`, `clean-minimal`, `pulse`, `starlight` ### `product-buy-box` | Setting | Type | Required | | ----------------------------------- | ----------------- | -------- | | `product.buyBox.variantLabel` | text, localizable | No | | `variantLabel` | text, localizable | No | | `product.buyBox.quantityLabel` | text, localizable | No | | `product.buyBox.addonsLabel` | text, localizable | No | | `product.buyBox.primaryActionLabel` | text, localizable | No | | `product.buyBox.buyNowLabel` | text, localizable | No | | `product.buyBox.trustBadge1Label` | text, localizable | No | | `product.buyBox.trustBadge2Label` | text, localizable | No | ### `product-form` | Setting | Type | Required | | ----------------------------------- | ----------------- | -------- | | `product.buyBox.variantLabel` | text, localizable | No | | `variantLabel` | text, localizable | No | | `product.buyBox.quantityLabel` | text, localizable | No | | `product.buyBox.addonsLabel` | text, localizable | No | | `product.buyBox.primaryActionLabel` | text, localizable | No | | `product.buyBox.buyNowLabel` | text, localizable | No | | `product.buyBox.trustBadge1Label` | text, localizable | No | | `product.buyBox.trustBadge2Label` | text, localizable | No | Variants: `pulse`, `starlight` ### `details` | Setting | Type | Required | | ----------------------------------------- | ----------------- | -------- | | `product.details.descriptionTabLabel` | text, localizable | No | | `product.details.reviewsTabLabel` | text, localizable | No | | `product.details.faqTabLabel` | text, localizable | No | | `product.details.emptyDescriptionLabel` | text, localizable | No | | `product.details.emptyReviewsTitle` | text, localizable | No | | `product.details.emptyReviewsDescription` | text, localizable | No | | `product.details.emptyFaqLabel` | text, localizable | No | Variants: `classic`, `clean-minimal`, `pulse`, `starlight` ### `related-products` | Setting | Type | Required | | ------------------------------- | ----------------- | -------- | | `product.relatedProducts.title` | text, localizable | No | Variants: `classic`, `clean-minimal`, `pulse`, `starlight` ### `stock-countdown` | Setting | Type | Required | | --------------------------- | ----------------- | -------- | | `stockCountdown.title` | text, localizable | No | | `title` | text, localizable | No | | `stockCountdown.stockLabel` | text, localizable | No | | `stockLabel` | text, localizable | No | | `stockCountdown.dropLabel` | text, localizable | No | | `dropLabel` | text, localizable | No | | `stockCountdown.dropAt` | text, localizable | No | | `dropAt` | text, localizable | No | | `stockCount` | number | No | ### `discord-role-claim` | Setting | Type | Required | | ------------------------ | ----------------- | -------- | | `discordRole.title` | text, localizable | No | | `discordRole.subtitle` | text, localizable | No | | `discordRole.ctaLabel` | text, localizable | No | | `discordRole.serverName` | text, localizable | No | | `discordRole.roleName` | text, localizable | No | | `style.buttonColor` | string | No | | `style.borderColor` | string | No | Also accepts the [shared surface settings](#shared-surface-settings). ### `license-delivery` | Setting | Type | Required | | -------------------------- | ----------------- | -------- | | `title` | text, localizable | No | | `subtitle` | text, localizable | No | | `licenseDelivery.title` | text, localizable | No | | `licenseDelivery.subtitle` | text, localizable | No | | `licenseDelivery.steps` | array | No | ## System pages One block per built-in page. These render a whole page rather than a section, so a page holds exactly one of them. ### `page-cart` This block takes no settings of its own. Variants: `classic`, `clean-minimal`, `pulse`, `starlight` ### `page-checkout` This block takes no settings of its own. Variants: `classic`, `clean-minimal`, `pulse`, `starlight` ### `page-all-products` | Setting | Type | Required | | -------------------------------------- | ------------------------------ | -------- | | `pages.all-products.title` | text, localizable | No | | `pages.all-products.subtitle` | text, localizable | No | | `pages.all-products.emptyState` | text, localizable | No | | `pages.all-products.showCategories` | boolean | No | | `pages.all-products.showSort` | boolean | No | | `pages.all-products.categoryImageSize` | `small` or `medium` or `large` | No | | `pages.all-products.productImageFit` | `cover` or `contain` | No | | `pages.all-products.columns` | `2` or `3` or `4` or `5` | No | | `pages.all-products.mobileColumns` | `1` or `2` | No | Variants: `classic`, `clean-minimal`, `pulse`, `starlight` ### `page-not-found` This block takes no settings of its own. Variants: `classic`, `clean-minimal`, `pulse`, `starlight` ### `page-faq-page` | Setting | Type | Required | | --------------------------- | ----------------- | -------- | | `apexSkin` | `"vault"` | No | | `pages.faq-page.title` | text, localizable | No | | `pages.faq-page.subtitle` | text, localizable | No | | `pages.faq-page.ctaText` | text, localizable | No | | `pages.faq-page.ctaLabel` | text, localizable | No | | `pages.faq-page.emptyLabel` | text, localizable | No | | `pages.faq-page.items` | array | No | | `faqPageItems` | array | No | Variants: `classic`, `clean-minimal`, `pulse`, `starlight` ### `page-reviews-page` | Setting | Type | Required | | ----------------------------------- | ----------------- | -------- | | `apexSkin` | `"vault"` | No | | `pages.reviews-page.title` | text, localizable | No | | `pages.reviews-page.subtitle` | text, localizable | No | | `pages.reviews-page.noReviewsLabel` | text, localizable | No | Variants: `classic`, `clean-minimal`, `pulse`, `starlight` ### `page-feedback-page` | Setting | Type | Required | | ---------------------------------------- | ----------------- | -------- | | `pages.feedback-page.title` | text, localizable | No | | `pages.feedback-page.subtitle` | text, localizable | No | | `pages.feedback-page.noReviewsLabel` | text, localizable | No | | `pages.feedback-page.leaveFeedbackLabel` | text, localizable | No | | `pages.feedback-page.seeReviewsLabel` | text, localizable | No | | `pages.feedback-page.writeTitle` | text, localizable | No | | `pages.feedback-page.writeSubtitle` | text, localizable | No | | `pages.feedback-page.submitLabel` | text, localizable | No | | `pages.feedback-page.submitLoadingLabel` | text, localizable | No | | `pages.feedback-page.successMessage` | text, localizable | No | | `pages.feedback-page.submitAnotherLabel` | text, localizable | No | | `pages.feedback-page.reviewsTitle` | text, localizable | No | | `pages.feedback-page.basedOnLabel` | text, localizable | No | | `apexSkin` | `vault` | No | Variants: `classic`, `pulse`, `starlight` ### `page-status-page` | Setting | Type | Required | | ------------------------------ | ----------------- | -------- | | `apexSkin` | `"vault"` | No | | `pages.status-page.title` | text, localizable | No | | `pages.status-page.subtitle` | text, localizable | No | | `pages.status-page.emptyState` | text, localizable | No | Variants: `classic`, `pulse`, `starlight` ### `page-tool-page` | Setting | Type | Required | | ------------------------------ | ----------------- | -------- | | `apexSkin` | `"vault"` | No | | `page.title` | text, localizable | No | | `page.faqItems` | array | No | | `page.contentHtml` | text, localizable | No | | `pages.tool-page.eyebrow` | text, localizable | No | | `pages.tool-page.title` | text, localizable | No | | `pages.tool-page.subtitle` | text, localizable | No | | `pages.tool-page.ctaLabel` | text, localizable | No | | `pages.tool-page.ctaHref` | text, localizable | No | | `pages.tool-page.hint` | text, localizable | No | | `pages.tool-page.warningTitle` | text, localizable | No | | `pages.tool-page.warningBody` | text, localizable | No | | `style.accentColor` | string | No | Also accepts the [shared surface settings](#shared-surface-settings). Variants: `classic`, `clean-minimal`, `pulse`, `starlight` ### `page-trust-page` | Setting | Type | Required | | --------------------------- | ----------------- | -------- | | `pages.trust-page.title` | text, localizable | No | | `pages.trust-page.subtitle` | text, localizable | No | Variants: `starlight` ### `page-guide-page` | Setting | Type | Required | | --------------------------- | ----------------- | -------- | | `apexSkin` | `"vault"` | No | | `pages.guide-page.title` | text, localizable | No | | `pages.guide-page.subtitle` | text, localizable | No | | `pages.guide-page.body` | text, localizable | No | Variants: `classic`, `pulse`, `starlight` ### `page-contact-page` | Setting | Type | Required | | -------------------------------------------- | ----------------- | -------- | | `communityLinks` | array | No | | `pages.contact-page.title` | text, localizable | No | | `pages.contact-page.subtitle` | text, localizable | No | | `pages.contact-page.submitLabel` | text, localizable | No | | `pages.contact-page.submitLoadingLabel` | text, localizable | No | | `pages.contact-page.successTitle` | text, localizable | No | | `pages.contact-page.successMessage` | text, localizable | No | | `pages.contact-page.sendAnotherLabel` | text, localizable | No | | `pages.contact-page.invoiceIdLabel` | text, localizable | No | | `pages.contact-page.invoiceIdHint` | text, localizable | No | | `pages.contact-page.preSalesTitle` | text, localizable | No | | `pages.contact-page.preSalesDescription` | text, localizable | No | | `pages.contact-page.orderSupportTitle` | text, localizable | No | | `pages.contact-page.orderSupportDescription` | text, localizable | No | | `pages.contact-page.formDisabledTitle` | text, localizable | No | | `pages.contact-page.formDisabledDescription` | text, localizable | No | Variants: `classic`, `clean-minimal`, `pulse`, `starlight` ### `terms-page` This block takes no settings of its own. Variants: `classic`, `clean-minimal`, `pulse` ## Advanced Escape hatches. Use them when no other block fits. ### `custom-html` | Setting | Type | Required | | ------------ | ----------------- | -------- | | `html` | string | No | | `heightMode` | `fixed` or `auto` | No | | `height` | number | No | Variants: `classic`, `clean-minimal`, `pulse`, `starlight` ### `ai-section` | Setting | Type | Required | | -------------- | ----------------- | -------- | | `__aiTitle` | text, localizable | No | | `__aiFields` | object | No | | `__aiTemplate` | object | No | Also accepts the [shared surface settings](#shared-surface-settings). Variants: `classic`, `clean-minimal`, `pulse`, `starlight` # Customer accounts Source: https://docs.shoppex.io/storefront/code-storefront-accounts Build a signed-in customer account into a code storefront, with the session rules that make it safe. A code storefront can serve the customer account itself: sign-in, order history, downloads, support. The account is then part of your theme, with your layout, your type and your colors, instead of a separate page the buyer is sent to. This page is about the Advanced lane. If your shop runs an Easy (visual) theme, the account is a block you place in the Builder and none of the code below applies. The reference implementation ships with the Nova template, in `src/components/account/` and `src/commerce/account-route.ts`. Start from those files rather than from a blank page. ## Where the account lives There is exactly one account route. | URL | Shows | | ---------------------------------- | ---------------------------------------------------------------------------------- | | `/dashboard` | The account overview | | `/dashboard?tab=orders` | One account section, named by `tab` | | `/dashboard?tab=orders&order=` | One order, inside the orders section | | `/customer-portal?...` | The older spelling of the same route. Fold it into `/dashboard` and keep the query | The ten section names are `overview`, `orders`, `downloads`, `subscriptions`, `rewards`, `referrals`, `favorites`, `support`, `settings` and `reseller`. Do not invent per-section paths such as `/dashboard/orders`. Order emails, password-free sign-in redirects and links customers already have are written against the query form, and it is identical on both Shoppex storefront lanes. A theme that routes its account differently breaks links it does not own. Your storefront is a single-page app, so serve `/dashboard` from your own router and read the two parameters: ```tsx theme={"system"} const params = new URLSearchParams(window.location.search); const tab = params.get('tab') ?? 'overview'; const orderId = params.get('order'); ``` A section your theme does not implement should say so. Answering it with the overview makes a broken link look like it worked, and the customer keeps hunting for something that is not there. ## How the session works Sign-in is a single-use code sent by email. There is no password, and there is no redirect: the customer types the code into your page and the same page becomes their account. `requestOtp(email)` sends the address and nothing else. The code is short lived. Its lifetime and retry limits are enforced on the server. `verifyOtp(email, otp)` completes the sign-in. The Shoppex storefront worker sets an HttpOnly cookie on your own domain, scoped to that host. Every later call carries it automatically. Three consequences shape everything you write: Nothing hands your code a session token, and nothing should store one. The cookie is HttpOnly, so your scripts cannot read it even by accident. The shop is derived from the host the buyer is on. Never send a shop, a shop id or a slug with an account call. Calls go to your own domain under `/api/customer/*`. Anything that changes data must come from your own pages; the edge refuses cross-site writes before they reach the API. Use the SDK for every account call. A hand-rolled `fetch` client against `/api/customer/*` is how a storefront ends up sending a shop or a token it was never meant to choose, and it drops the response validation the SDK does for you. ## The account calls Load the Storefront SDK as you already do for the catalog and the cart. In a code storefront that is the CDN global (`window.shoppex`), wrapped by `src/commerce/shoppex.ts` in the official templates. In a bundled project, the same functions are named exports of `@shoppexio/storefront`. Every call answers with the same envelope: `{ success, data?, message?, code? }`. `message` is a sentence to show the customer. `code` is the machine-readable reason, when the server sent one, and is the only value you should branch on. | Call | Method and endpoint | Returns | | -------------------------------------------------------------------------------------------------- | --------------------------------------------- | ------------------------------------------------------------ | | `requestOtp(email)` | `POST /api/customer/auth/otp/request` | Nothing to render. Move to the code step | | `verifyOtp(email, otp)` | `POST /api/customer/auth/otp/verify` | Nothing secret. The session cookie arrives with the response | | `logout()` | `POST /api/customer/auth/logout` | Ends the session and clears the cookie | | `me()` | `GET /api/customer/me` | The signed-in customer, or a failure when there is none | | `dashboard()` | `GET /api/customer/dashboard` | Shop, customer, counters, store credit, recent orders | | `orders({ page, limit })` | `GET /api/customer/invoices` | One page of order history | | `order(id)` | `GET /api/customer/invoice/` | One order with its line items, delivery and payment | | `createTicket({ title, message, invoice_id })` | `POST /api/customer/tickets` | Opens a support ticket | | `ticket(uniqid)` / `replyToTicket(uniqid, message)` | `GET` and `POST /api/customer/tickets/…` | One conversation, and an answer to it | | `resetLicenseHwid(uniqid)` | `POST /api/customer/licenses//reset-hwid` | Unbinds a licence from its device | | `cancelSubscription(uniqid, options)` / `pauseSubscription(uniqid)` / `resumeSubscription(uniqid)` | `POST /api/customer/subscriptions//…` | Changes a subscription's standing | | `updateProfile({ name })` | `POST /api/customer/profile` | Changes the display name | | `sessions()` / `revokeSession(id)` / `revokeAllSessions()` | `GET` and `DELETE /api/customer/sessions` | The customer's signed-in devices | **Licences, subscriptions and tickets have no list call.** All three arrive inside `dashboard()`, and the calls above act on one item whose id you already have. `GET /api/customer/licenses` is not an endpoint and answers `404` at the edge — the same is true for `/subscriptions` and `/tickets`. `dashboard()`, `orders()` and `order()` are validated against the published response contract before you see them. A payload that does not match comes back as a failure rather than as half-read data, so a change on the platform side surfaces as an error instead of as a wrong number on a customer's screen. ## Sign a customer in ```javascript theme={"system"} async function sendCode(email) { const result = await shoppex.requestOtp(email); if (!result.success) { // The server's own sentence. Show it as it came: it knows whether the // address is rate limited, blocked, or simply malformed. return showError(result.message); } showCodeStep(email); } async function signIn(email, code) { // The field is the code the customer typed. Nothing here returns a token, // and nothing needs to be stored: the cookie is set by the edge. const result = await shoppex.verifyOtp(email, code); if (!result.success) return showError(result.message); renderAccount(); } ``` Decide what a signed-in page looks like by asking, not by remembering: ```javascript theme={"system"} const session = await shoppex.me(); if (session.success) renderAccount(); else if (session.status === 401) renderSignInForm(); else renderError(session.message); ``` **Only a `401` means "not signed in".** Every other failure — a `403`, a `5xx`, a contract mismatch, or a network error that carries no `status` at all — means the answer is unknown. Rendering the sign-in form there tells a buyer whose session is still live that they were signed out, and a reload contradicts you. Show the error and let them retry. Never do the opposite either and treat a failed check as a signed-in customer: that renders an account shell around data nobody fetched. ## The overview One call carries the whole overview. Read what you need out of it and count nothing yourself. ```javascript theme={"system"} const { success, data, message } = await shoppex.dashboard(); if (!success) return showError(message); console.log(data.customer.name, data.customer.email); console.log(data.stats.invoices_count, data.stats.licenses_count); // Store credit is a section, not a page. `wallet` is null for shops that run // no wallet, and that null is the switch: render nothing rather than a zero // balance for a feature the merchant never turned on. if (data.wallet && data.wallet.enabled) { showStoreCredit(data.wallet.available, data.wallet.currency); } ``` `data.invoices` holds the most recent orders, which is enough for a "recent orders" block without a second request. ## Order history Pagination is page based. Ask for a page and a size, and read `has_more` to decide whether a next page exists. ```javascript theme={"system"} const { success, data, message } = await shoppex.orders({ page: 1, limit: 10 }); if (!success) return showError(message); renderRows(data.invoices); setNextEnabled(data.pagination.has_more); ``` There is no cursor. A cursor parameter is ignored by the API, which then keeps answering with page one, so a list built on one silently stops advancing. An order row carries `uniqid`, and that is the id the detail call takes: ```javascript theme={"system"} const { success, data, message } = await shoppex.order(uniqid); if (!success) return showError(message); for (const item of data.lineItems) { // Fulfillment is per line and independent of payment: a paid order can still // hold items the shop delivers later. console.log(item.productTitle, item.deliveryStatus); if (item.deliverySummary) { showDelivery(item.deliverySummary.codes, item.deliverySummary.serials); } } ``` ## Two details that bite **Field casing is not uniform.** The dashboard payload is snake\_case (`total_display`, `created_at`, `line_items`), while the order list and the order detail are camelCase (`totalDisplay`, `createdAt`, `lineItems`). Keep the names as they arrive. Renaming them in a shared helper is how one of the two surfaces quietly starts reading `undefined`. **`total` and `currency` are not a pair.** `currency` is the currency the buyer sees. The `total` field is a normalized figure in USD, and `total_display` / `totalDisplay` is the same total in the buyer's currency. Render the display amount first: ```javascript theme={"system"} function orderAmount(order, currency) { return formatMoney(order.totalDisplay ?? order.total, currency); } ``` The same rule applies to `discount` and `discountDisplay`. Line prices, `subtotal` and `tax` are already in the buyer's currency. ## What a storefront cannot do The edge keeps a fixed list of account endpoints, bound to their method. Anything outside it answers `404` before it reaches the API, whatever the page calls it from. Your theme runs on the same origin as the customer's session, so that list is the answer to what shop-authored code may do on a buyer's behalf, and it is not something a theme can widen. Deliberately closed today: | Not available | Why | | -------------------------------------- | ----------------------------------------------------------------------------------------------------- | | License renewal | It creates a payable invoice. Money paths get their own review before any storefront can trigger them | | Affiliate payouts | Same reason | | The subscription payment-method portal | It hands out a signed redirect into a payment provider | | Reseller endpoints | No published contract yet | | Wallet top-up and transfers | Balances are readable through `dashboard()`. Moving money is not open | Link a customer to the hosted flow for anything on that list rather than trying to reach it from your theme. ## Failures and empty states Account pages fail in ways catalog pages do not: the session expires, the network drops mid-page, a shop turns a feature off. Three habits keep that honest. * **Show the server's sentence.** `message` is written for the customer. A friendlier guess of your own is often simply wrong, and a customer who is told the wrong reason cannot fix anything. * **Tell the three states apart.** Loading, empty and failed are different. An error rendered as an empty list reads as "you have no orders", which is the one thing it does not mean. * **Never fill a gap.** If the API did not send a value, do not compute a replacement. A number your storefront made up is worse than a missing one, because nobody can tell. ## Where to go next Project structure, the commerce layer, and working locally. Every SDK call, including the catalog, cart and checkout surfaces. # Code storefront development Source: https://docs.shoppex.io/storefront/code-storefront-development The folder structure, the commerce layer, and how to work locally in an Advanced storefront project. A code storefront is a React and Vite project with a small commerce layer, one file for editable content, and a standard Vite build. This page assumes a project already exists on disk. To get one, export a storefront or run `shoppex storefront pull`. See [Code storefronts](/storefront/code-storefronts) and the [Theme CLI](/storefront/theme-cli). Move a project in and out of Shoppex as a ZIP package. Pull, push, build, and deploy from the command line. Serve sign-in and order history from your own theme. ## Project structure Every official template shares this shape. File names inside `commerce/` and `config/` can differ between templates, but the folders and their purpose stay the same. ``` . ├── index.html Loads the Shoppex SDK script and mounts the app ├── package.json Scripts and dependencies ├── vite.config.ts Build config ├── src/ │ ├── main.tsx React root, wraps the app in CartProvider │ ├── App.tsx Routes and page layout │ ├── commerce/ The commerce layer, see below │ │ ├── shoppex.ts Wraps window.shoppex and the bootstrap globals │ │ ├── sdk.d.ts Types for window.shoppex │ │ ├── cart.tsx CartProvider and the useCart hook │ │ └── checkout-fields.ts Custom checkout fields per product │ ├── config/ │ │ ├── site-config.ts Reads the catalog and the merchant's content │ │ ├── content-defaults.json Default content for the Design tab, see below │ │ └── content-schema.json Field types the Design tab renders │ ├── components/ Page sections and UI │ └── styles/ CSS └── tests/ Optional unit tests for commerce logic ``` ## The commerce layer `src/commerce/` is where a storefront talks to Shoppex. It is not an npm package. Open `package.json` and no `@shoppexio/*` dependency appears there. `index.html` instead loads the Storefront SDK as a script, pinned to a version channel: ```html theme={"system"} ``` The browser exposes it as `window.shoppex`. `src/commerce/shoppex.ts` wraps that global with two functions: | Function | Returns | | -------------------- | ----------------------------------------------------------------------------------------------------- | | `getShoppex()` | The initialized SDK. Throws if the script did not load, or if a required bootstrap global is missing. | | `getBuyerCurrency()` | The currency the injected prices use. | `getShoppex()` also runs the SDK's one-time setup. It uses bootstrap globals a Shoppex worker injects next to the product catalog: `shopSlug`, `shopId`, `apiBaseUrl`, `defaultCurrency`, and an optional `checkoutBaseUrl`. A storefront without these globals cannot price a cart or check out, so `getShoppex()` fails fast instead of rendering with broken commerce. The catalog and the merchant's content arrive the same way, read by `src/config/site-config.ts`: | Function | Returns | | ----------------- | ------------------------------------------------------------------------------------------- | | `getProducts()` | The shop's catalog, as an array of products with price, stock, variants, and custom fields. | | `getSiteConfig()` | The merchant's content, merged over `content-defaults.json`. | Neither function makes a network request. Both read `window.__SHOPPEX_INITIAL__`, injected by the same worker before the page renders. For cart and checkout, use the `useCart` hook from `src/commerce/cart.tsx` instead of calling the SDK directly. A component under `CartProvider` gets cart state and the actions that keep it, the SDK, and the current price quote in agreement: ```tsx theme={"system"} import { useCart } from './commerce/cart'; function AddToCartButton({ product }: { product: StorefrontProduct }) { const { add } = useCart(); return ; } ``` `useCart()` returns `lines`, `count`, `quote`, and the actions `add`, `update`, `remove`, `clear`, `applyCoupon`, `removeCoupon`, and `checkout`. `checkout` calls the SDK's `checkout()` and returns `{ success, message, code, redirectUrl, invoiceId }`. If a product has required checkout fields, collect them before calling `checkout`. `resolveCheckoutFields(lines, products)` and `checkoutNeedsDetails(fields)` in `checkout-fields.ts` tell you when a form must run first, and what to write onto the cart lines. `src/commerce/sdk.d.ts` types the full `window.shoppex` surface this project uses. The complete method and type reference for the SDK lives at [Storefront SDK reference](/developers/storefront-sdk/reference). ## Editable content: content-defaults.json `src/config/content-defaults.json` is the template's own text and settings: brand name, navigation links, hero copy, theme colors, cart labels, and the content for each page section. `getSiteConfig()` reads this file and merges the merchant's saved content over it. The merge is a partial override: objects merge field by field, and arrays and single values replace completely. `src/config/content-schema.json` describes the field types in `content-defaults.json`. The Design tab then renders a color picker, a select, or a number field instead of raw text, for every value it can. Without `content-defaults.json`, the Design tab has no fields to show. The storefront still works. Content edits then happen in Code or with AI instead of the Design tab. ## Working locally Every template runs on the same two commands. ```bash theme={"system"} bun install bun run dev # starts the Vite dev server, default http://localhost:5173 bun run build # writes dist/index.html and hashed assets ``` `bun run dev` on its own renders no storefront. Every route shows a configuration error instead, because the store record and the SDK bootstrap globals have no local substitute. The values the app needs are these: | Global | Type | Holds | | ---------------------------- | ---------------- | ------------------------------------------------ | | `window.shopSlug` | string | The shop's slug | | `window.shopId` | string | The shop's id | | `window.apiBaseUrl` | string | The base URL for the Shoppex API | | `window.defaultCurrency` | string | The currency the injected prices use | | `window.checkoutBaseUrl` | string, optional | The base URL for Hosted Checkout | | `window.__SHOPPEX_INITIAL__` | object | The store record and the product catalog | | `window.shoppex` | object | The SDK itself, matching `src/commerce/sdk.d.ts` | ### See it running with real data Templates ship no local mock for these values on purpose, so a half-built shop never looks like a working one. Two paths reach real data: * **Push and preview.** Run `shoppex storefront push` to send your local project, then open the storefront under **Store → Storefronts**. This path needs no stub and matches production exactly. * **Write your own stub.** Serve a build with a static server, drop the `cdn.shoppex.io` script tag, and inject the globals above before the app runs. Keep the stub out of version control. Give every global a real value, not an empty string, and keep its synchronous methods, as typed in `sdk.d.ts`, returning values instead of promises. ## What the build expects Every code storefront builds the same way: * A `build` script in `package.json` * No network access while the script runs * Output in `dist`, `out`, or `build`, with an `index.html` at its root See [Code storefronts](/storefront/code-storefronts) for the lockfile requirement and the full size limits. # Code storefronts Source: https://docs.shoppex.io/storefront/code-storefronts Move an Advanced storefront in and out of Shoppex as a portable ZIP package. An Advanced storefront is a React and Vite project you own. Shoppex packages it as a ZIP so you can keep a backup, hand the project to someone else, work on it locally, or bring an existing project in as a new storefront. Easy (visual) themes are not part of this page. They export as ThemeDocument JSON instead — see [Visual themes](/storefront/visual-themes). Download the current source tree plus a package manifest as one ZIP. Upload a ZIP as a brand new storefront, or replace the source of an existing one. ## Export a storefront There are three ways to get the ZIP, and all three produce the same archive: * **From the storefront list** — open **Store → Storefronts**, switch to **Advanced**, and choose **Download source ZIP** from a storefront card's menu. * **From the code editor** — open the storefront and use the **Download** action in the toolbar. * **From the CLI** — run `shoppex storefront export`. See the [Theme CLI](/storefront/theme-cli) for flags and authentication. The export always describes one moment: the file list, the source revision, and your Design content are read together, so the archive is never a mix of two revisions. The downloaded file is named after that revision (`storefront-source-8f1c4a90d2b7.zip`). ## What the ZIP contains The archive holds your complete source tree for the current revision, plus one extra file at the archive root: `shoppex.theme.json`, the package manifest. ```json theme={"system"} { "manifest_version": 1, "kind": "code-storefront", "name": "My React Store", "theme_version": "1.0.0", "code_template": "nova", "source_revision": "8f1c4a90d2b73e55", "exported_at": "2026-08-01T09:41:12.503Z", "content": { "hero": { "headline": "Summer drop" } } } ``` `content` is what you edited in the **Design** tab: the draft when one exists, otherwise the published copy, and `null` when the storefront still runs on the defaults shipped in its files. `shoppex.theme.json` is metadata, not source. Shoppex adds it to the ZIP on export and consumes it on import, and you cannot create a source file with that name. The name is reserved. ### The round-trip promise * **Import** applies the manifest's `content`. Export a storefront, import the ZIP, and your Design content comes back with it instead of falling back to the template defaults. * **Replace** never applies it. The storefront you are replacing already owns its own Design content, and a source swap must not silently overwrite it. The response tells you when a package carried content that was not applied. * `code_template` is provenance for humans only. It is never restored onto the imported storefront, so a paid template stays a purchase and cannot travel in an archive. ## Import a ZIP as a new storefront Open **Store → Storefronts**, switch to **Advanced**, and use **Import ZIP**. Give the storefront a name, drop in the archive, and Shoppex creates it and starts the first build immediately. The archive must satisfy these: | Requirement | Limit | | --------------------- | ------------------------------------------- | | Uploaded ZIP | 42 MiB | | Extracted source tree | 40 MiB | | Files | 400 | | Single file | 5 MiB | | Path length | 512 characters | | Root `package.json` | Required, with a non-empty `"build"` script | Also true of every accepted archive: * Symbolic links are rejected, and so are absolute paths or paths containing `..`. * Paths that collide only by letter case (`Header.tsx` and `header.tsx`) are rejected. * If everything sits inside a single top-level folder, that folder is stripped, so zipping a project directory works as expected. * These are dropped before anything is stored: `__MACOSX`, `.git`, `node_modules`, `dist`, `.turbo`, `.shoppex`, and any `.DS_Store`. * A `shoppex.theme.json` at the root is consumed as the package manifest. If it is present but not a valid manifest, the import fails rather than quietly importing only the files. ### The Design tab If the archive ships `src/config/content-defaults.json`, the **Design** tab in the editor has fields to edit and the import opens the editor directly. An archive without it is still a perfectly valid storefront. The import tells you the Design tab will be empty, and you edit in **Code** or with AI instead. ## Replace the source of an existing storefront Open the storefront in the editor and use **Replace Source from ZIP**. Every current file is deleted and replaced by the contents of the archive. A replace is destructive and covers the whole tree. Download the current source first if you want to keep a copy, and save or discard unsaved editor changes. They only exist in your browser. A replace is guarded by the source revision you loaded. If someone else, an AI edit, or a CLI push saved in the meantime, the upload is rejected with a conflict instead of overwriting their work. Reload the storefront and try again. The replace uploads the tree. It does not build. The dialog starts a build for you right after, and you can always press **Build** yourself. The CLI covers the same ground for a local checkout: `shoppex storefront push` sends your working copy under the same revision guard and enqueues the build. See the [Theme CLI](/storefront/theme-cli). ## What the build expects Every import and every replace ends in the same pipeline: install, build, collect the output, upload it as a build that never changes afterwards. * Dependencies are installed from your lockfile with lifecycle scripts disabled. Commit `bun.lock` or `bun.lockb` for a reproducible, frozen install. * Your `build` script runs in a sandbox with **no network access**. Anything it needs must already be in the project or its dependencies. * The output must land in `dist`, `out`, or `build`, and must contain `index.html`. * The finished artifact is capped at 200 MB and 20,000 files. A build that fails leaves the currently published storefront untouched. Only publishing changes what visitors see. # Custom domain Source: https://docs.shoppex.io/storefront/custom-domain Step-by-step DNS walkthrough to run your shop on your own domain. By default your shop lives at `yourshop.shoppex.io`. To run it on your own domain, for example `shop.yourbrand.com` or `yourbrand.com`, add a custom domain from **Store → Domains** (`/store/domains`). This page walks through the DNS setup in detail, because it is where most people get stuck. ## What you need * A domain you own, registered with any registrar: Namecheap, Cloudflare, GoDaddy, or another. * Access to that domain's **DNS settings**. * A few minutes for DNS to propagate. ## Subdomain vs. apex (root domain) You can connect either: * **Subdomain** — `shop.yourbrand.com`, `store.yourbrand.com`, `www.yourbrand.com`. **Easier.** Works on every DNS provider. * **Apex / root domain** — `yourbrand.com`, with no prefix. **Harder.** This only works if your DNS provider supports ALIAS or ANAME records, or if you migrate DNS to Cloudflare. If you are not sure, start with a subdomain. You can switch to apex later. ## Setup — subdomain ### Step 1: Add the domain in Shoppex 1. Open **Store → Domains** in your Shoppex dashboard. 2. Click **Add Custom Domain**. 3. Type your subdomain, for example `shop.yourbrand.com`. 4. Click **Add Domain**. Shoppex shows a **DNS Configuration** dialog with the initial CNAME: | Type | Name | Target | | ----- | -------------------- | --------------------- | | CNAME | `shop.yourbrand.com` | `custom.myshoppex.io` | For the CNAME, use only the subdomain part as the name, for example `shop`. Most DNS interfaces append your domain automatically. ### Step 2: Add the CNAME to your DNS provider Open your registrar's DNS dashboard and add the CNAME record exactly as shown. Save it. ### Step 3: Trigger verification Back in Shoppex, open **Domains**, click the row for your new domain, then click **Check Verification**. This step pulls the **TXT records** from Cloudflare. Shoppex shows you the two TXT records you now need to add: one for ownership verification, one for SSL/TLS validation. Both names and values are unique to your domain. Copy them exactly. ### Step 4: Add the TXT records and verify again Add the two TXT records in your registrar's DNS dashboard. Click **Check Verification** again in Shoppex. If DNS has propagated, the status moves to **Verified**. DNS propagation typically takes a few minutes to a few hours, depending on your registrar. There is no fixed time. If it is not ready the first time, click **Check Verification** again. ### Step 5: Wait for SSL After verification, Cloudflare issues an SSL certificate for the domain. The status in the dashboard moves from **Pending** to **Active** once the certificate is live. This typically takes a few minutes to an hour. If SSL stays on **Pending** for more than an hour, use **Refresh SSL** in the domain row's menu to start the certificate process again. Once active, your shop is live on the custom domain. ## Setup — apex / root domain Apex domains do not allow CNAME records, by the DNS specification. You need one of these: ### Option A: Migrate DNS to Cloudflare (recommended) Cloudflare supports **CNAME flattening at the root**: you can put a CNAME at `@` and Cloudflare resolves it transparently. This is the cleanest solution. 1. Add your domain to Cloudflare. The free plan works. 2. Update your registrar's nameservers to the two Cloudflare gives you when you add the domain. 3. Once Cloudflare has the domain, follow the subdomain setup steps above. Add a CNAME at `@` pointing to `custom.myshoppex.io`. 4. Keep the CNAME on **DNS only** (grey cloud icon, not orange). Cloudflare proxying breaks the custom-hostname verification. ### Option B: ALIAS / ANAME record at your existing DNS Some DNS providers, for example DNSimple, NS1, easyDNS, and Hover, support ALIAS or ANAME records: a CNAME equivalent that is legal at the apex. If your provider supports it, use it the same way as a CNAME, pointing to `custom.myshoppex.io`. ### Option C: Use a subdomain instead If you do not want to move DNS, or your provider does not support ALIAS, connect `www.yourbrand.com`, or another subdomain, instead of the bare root. You can then configure your DNS to **redirect** `yourbrand.com` to `www.yourbrand.com`. Most registrars offer this as a simple toggle. ## Multiple domains pointing at one shop You can attach extra domains to a single shop, for example a typo-catching alias or a regional variant. Add them the same way, under **Store → Domains**, but choose the **Additional** type instead of Custom. The Custom domain is your shop's primary URL. Additional domains route to the same content, then redirect to the Custom domain for SEO consolidation. ## Removing a custom domain Disabling a custom domain from the dashboard reverts the shop to its default `*.shoppex.io` URL. Shoppex keeps the DNS records and the Cloudflare-side configuration, so re-enabling is instant and does not require re-verifying DNS. ## Common pitfalls * **"Domain already registered to Paylix."** This is an old internal error message. It means the domain is already in use by another Shoppex shop (Paylix is a legacy name). Open a ticket through Discord or Telegram if you believe the domain is yours. * **Cloudflare proxy turned on during verification.** If the orange-cloud proxy is enabled on the CNAME, Cloudflare custom-hostname verification fails silently. Keep it grey-cloud (DNS only) until the domain is fully verified and SSL is active. You can turn the proxy on afterward, but for storefronts there is rarely a reason to. * **No automatic retry.** Shoppex does not poll DNS in the background. After you add records, click **Check Verification** in the dashboard yourself. If it does not verify, check the records again, then try once more. DNS propagation can still be in progress. * **Subdomain works, apex does not.** If `shop.yourbrand.com` verifies but `yourbrand.com` does not, your DNS provider does not support ALIAS at the root. Use Option A or Option C above. * **Apex with `A` records pointing somewhere else.** An existing A record at `@` that points to a different server, for example a website builder or an old host, conflicts with this setup. Delete that record before you add the ALIAS record or the CNAME flattening. * **TLS taking forever.** If SSL stays on `pending_issuance` for more than an hour, click **Refresh SSL** in the domain row's dropdown menu. This starts Cloudflare's certificate process again. # Advanced styling for Easy themes Source: https://docs.shoppex.io/storefront/easy-theme-advanced-styling Add theme CSS, block CSS, custom classes, custom HTML, and scrolling announcement bars without a code storefront. Easy themes provide several styling surfaces before you need an Advanced code storefront. Use the smallest surface that matches your change. | Surface | Scope | Limit | Best use | | ---------------- | ----------------------- | -------------------------- | ------------------------------------- | | Theme Custom CSS | The complete storefront | 65,536 UTF-8 bytes (64 KB) | Fonts, animations, and shared styles | | Section CSS | One block | 16,384 UTF-8 bytes (16 KB) | A unique layout or shape | | Extra Classes | One block root | 256 characters | Stable class names for CSS selectors | | Custom HTML | One sandboxed iframe | 262,144 characters | Self-contained HTML, CSS, and scripts | | Announcement Bar | The shop header | 160 text characters | Static or scrolling promotion text | ## Add theme-level CSS Theme Custom CSS applies across the storefront. Shoppex scopes each selector to the storefront container. Theme CSS can declare global names with `@keyframes` and `@font-face`. 1. Open the Easy theme in the visual Builder. 2. Select the **Theme** tab. 3. Open **Custom CSS**. 4. Paste the CSS. 5. Preview the affected pages. 6. Publish the theme. ### Animate a heading First, select the heading block. Open **Custom CSS & Class**, then paste `glitch-heading` into **Extra Classes**. Paste this example into the theme-level **Custom CSS** field: ```css theme={"system"} @keyframes cyber-glitch { 0%, 88%, 100% { transform: translate(0); text-shadow: 2px 0 #00f5ff, -2px 0 #ff2bd6; } 90% { transform: translate(-2px, 1px); text-shadow: 5px 0 #00f5ff, -5px 0 #ff2bd6; } 92% { transform: translate(2px, -1px); text-shadow: -4px 0 #00f5ff, 4px 0 #ff2bd6; } } .glitch-heading h1, .glitch-heading h2 { animation: cyber-glitch 2.8s steps(1, end) infinite; } @media (prefers-reduced-motion: reduce) { .glitch-heading h1, .glitch-heading h2 { animation: none; } } ``` The extra class gives the block a stable selector. The animation affects headings inside that block only. ### Load a font outside the curated list `@font-face` can load a font from a full HTTPS URL. This example loads Atkinson Hyperlegible from an external font CDN. ```css theme={"system"} @font-face { font-family: "Atkinson Shop"; src: url("https://cdn.jsdelivr.net/fontsource/fonts/atkinson-hyperlegible@5.2.8/latin-400-normal.woff2") format("woff2"); font-style: normal; font-weight: 400; font-display: swap; } body { font-family: "Atkinson Shop", system-ui, sans-serif; } ``` The font host must permit cross-origin font requests. For your own asset, use its complete HTTPS URL. ## Style one block Section CSS stays inside one block. Shoppex adds the block ID to each selector before it renders the page. 1. Select the block in the Builder. 2. Open **Custom CSS & Class** in the block inspector. 3. Paste `cyber-card` into **Extra Classes**. 4. Paste the CSS into **Section CSS**. ```css theme={"system"} body.cyber-card { clip-path: polygon( 0 18px, 18px 0, 100% 0, 100% calc(100% - 18px), calc(100% - 18px) 100%, 0 100% ); border: 1px solid #00f5ff; background: linear-gradient(135deg, #0b1020, #17102a); box-shadow: inset 0 0 24px rgb(0 245 255 / 12%); } body.cyber-card:hover { border-color: #ff2bd6; } ``` In Section CSS, a leading `body` targets the block root. Here, `body.cyber-card` targets the root with your extra class. Extra Classes accepts letters, numbers, hyphens, and underscores. Separate multiple class names with spaces. ## Know the CSS safety rules Shoppex parses Custom CSS before it saves or renders the theme. Invalid CSS returns an error instead of partial output. | The sanitizer rejects | Use instead | | ---------------------------------------------------------------------- | ------------------------------------------------- | | `@import` | Paste the required CSS into Custom CSS. | | Relative, `http://`, protocol-relative, and `data:` values in `url()` | Use a full `https://` URL or a local `#fragment`. | | The `behavior` and `-moz-binding` properties | Use standard CSS properties. | | The `expression()`, `image-set()`, and `-webkit-image-set()` functions | Use static values or one HTTPS asset URL. | | A closing `` tag | Put CSS only in the CSS field. | | Escapes in property or function names | Write the property or function name directly. | Theme CSS permits `@keyframes`, `@font-face`, `@page`, and `@property`. Section CSS cannot contain these page-wide rules. Declare them in theme-level Custom CSS, then reference them from Section CSS. For example, Section CSS can use `animation: cyber-glitch 2.8s infinite` after theme CSS declares `@keyframes cyber-glitch`. ## Add self-contained HTML Use a `custom-html` block for a self-contained interface that needs HTML, CSS, or JavaScript. Shoppex renders the block in a separate sandboxed iframe. 1. Add a **Custom HTML** block to the page. 2. Paste the complete example into the **HTML** field. 3. Set **Height mode** to **Auto**. 4. Preview the page. ```html theme={"system"}

MEMBER ACCESS

``` The sandbox permits scripts, forms, and popups. It does not grant same-origin or top-navigation access to the storefront page. Theme CSS does not enter the iframe. Include all required styles inside the Custom HTML content. The `custom-html` block is forbidden on Checkout. Use it on storefront pages or merchant-created pages. ## Scroll an announcement bar The Announcement Bar widget has **Static** and **Scrolling** behavior. Scrolling mode repeats the message as a marquee and uses a static version for reduced-motion visitors. 1. Open the **Widgets** page. 2. Add an **Announcement Bar**. 3. Enter the following values. 4. Save the widget. ```text theme={"system"} Text: NEW DROP // 20% OFF UNTIL MIDNIGHT Link label: SHOP NOW Link URL: /all-products Behavior: Scrolling Scroll speed: 30 ``` The scroll speed uses seconds per cycle. The field accepts values from 8 through 40 seconds. ## Continue the theme workflow Use [Editing with AI](/storefront/editing-with-ai) for chat-based or local AI changes. Use the [Theme CLI](/storefront/theme-cli) to edit a ThemeDocument checkout with local files. # Editing with AI Source: https://docs.shoppex.io/storefront/editing-with-ai Edit an Easy theme through MCP chat or a local ThemeDocument checkout, then review and publish it. Shoppex AI tooling works with the same structured theme document as the visual Builder. You can use an MCP connection or a local file checkout. ## Which path to choose | Path | Working model | Best fit | | -------------- | ------------------------------------------------------------------ | ---------------------------------------------------------- | | MCP | No local files. Ask for changes in chat. | Chat-first work and small Builder changes | | Local checkout | Full local ThemeDocument files with generated guidance and schemas | Claude Code, Cursor, Codex, and version-controlled changes | Both paths use the ThemeDocument contract. Neither path uploads executable theme source for an Easy theme. Read [Advanced styling for Easy themes](/storefront/easy-theme-advanced-styling) before you ask AI to add CSS or Custom HTML. ## Edit a local checkout with AI Use the local checkout for a document-authoritative Easy theme. A catalog theme has a read-only checkout because Builder settings remain its source of truth. 1. Pull the theme into an empty directory. ```bash theme={"system"} shoppex theme pull --theme --dir ./theme cd theme ``` 2. Open the directory in Claude Code, Cursor, Codex, or another AI tool. 3. Ask the tool to read the generated `AGENTS.md` before it changes files. 4. Start the live preview. ```bash theme={"system"} shoppex theme dev ``` 5. Ask for one focused change. ```text theme={"system"} Read AGENTS.md. Add the class "glitch-heading" to the home hero. Add the matching animation to theme-level custom CSS. Keep every other field unchanged. ``` 6. Review the local diff and the live preview. 7. Push the draft, then publish its recorded revision. ```bash theme={"system"} shoppex theme push shoppex theme publish ``` `theme dev` reloads the hosted preview after each valid file change. If another editor saves first, it stops with a conflict instead of replacing that change. Read the [Theme CLI guide](/storefront/theme-cli) for checkout files, revision conflicts, scopes, and command details. ## Edit through MCP The MCP path uses this loop: ```text theme={"system"} inspect -> propose a small change -> save -> review -> publish ``` ## Connect your AI client * Choose the theme you want to edit in **Store → Themes**. * Open its **⋯** menu and select **Edit with AI**. * Create a scoped key for that theme and follow the generated setup commands for your AI client. The handoff also gives read-only CLI commands for the current document and schema. Use them for inspection. Use the MCP tools below for revision-gated saves and publishing. Give the assistant a scoped API key. Review the saved change before publishing, especially when it affects product purchase or checkout handoff UI. Preview stays a dashboard action. Publish through the MCP tool with the exact revision returned by the save, or publish the reviewed draft in the dashboard. ## Two provenance workflows The handoff response tells the assistant which workflow to use, through `document_provenance`, `mcp_tools`, and `instructions`: * **`settings_derived`** is the normal workflow for hosted themes. The assistant edits Builder settings, through `theme_settings_get` and `theme_settings_update`. * **`document_authoritative`** is used for ThemeDocument imports and rebuilds. The assistant edits the complete saved ThemeDocument, through `get_theme_document` and `save_theme_document`. Both workflows publish through `publish_theme_document`. The tool description repeats this mapping. The assistant must follow the provenance instead of trying the document save flow for every theme. ## Make a focused change Ask the assistant to inspect the theme before editing: ```text theme={"system"} Inspect my active theme. On the home page, change only the hero heading to "Your next favorite download". Keep every other setting unchanged. ``` Good tasks are narrow and observable, for example: * "Change the homepage hero heading and keep the existing buttons." * "Add an FAQ block after the product grid." * "Use the current primary color for collection-card links." The assistant must read the current schema and the provenance-specific source, identify the target block, and apply a structured change. Ask it to list the exact fields it changed before saving. ## Save a settings-derived hosted theme For `settings_derived` themes, the assistant must: 1. Call `theme_settings_get` and change the returned Builder settings. 2. Call `theme_settings_update` with the changed settings and the revision from the read unchanged. The tool increments the revision for the update. 3. Review the saved result and keep the Builder revision returned by the update. 4. Call `publish_theme_document` with that Builder revision as `expected_revision`. Do not call `save_theme_document` for a `settings_derived` theme. That workflow intentionally returns `409` because Builder settings are the source of truth. ## Save a document-authoritative theme For a `document_authoritative` import or rebuild, the assistant must: 1. Call `get_theme_document` and change the complete ThemeDocument it returns. 2. Call `save_theme_document` with the read revision as `expected_revision`. 3. Review the saved result and keep the new ThemeDocument revision returned by the save. 4. Call `publish_theme_document` with that new revision as `expected_revision`. In both workflows, publishing validates the result and creates a new locked serving revision. Before publishing, review the saved result in the visual Builder on desktop and mobile, especially purchase and checkout handoff UI. If another editor saves the theme first, Shoppex returns a revision conflict instead of overwriting your change. Load the current source again: Builder settings for `settings_derived`, or the ThemeDocument for `document_authoritative`. Apply your change to it again, then save. Never overwrite the other editor's change. ## Good follow-up prompts * "Add an FAQ block after the product grid, using the existing spacing." * "Use the current primary color for collection-card links." * "List the exact settings you changed and the saved revision without publishing." # Theme CLI Source: https://docs.shoppex.io/storefront/theme-cli Inspect ThemeDocument settings and schemas with the Shoppex CLI. Run the CLI with `npx @shoppexio/cli`, or install it globally. Authenticate with a Shoppex API key that has the required theme scopes. ```bash theme={"system"} shoppex theme list shoppex theme settings get --theme shoppex theme settings update --theme --file shoppex theme document get --theme shoppex theme document schema --theme ``` `settings update` validates the complete settings object. Fetch the current settings first, make a focused change, and keep a local copy for review. The CLI can also install the Shoppex MCP server configuration for supported AI clients. Run `shoppex --help` for the exact commands available in your installed version. ## Editing an Easy theme locally An Easy theme is a ThemeDocument, not a source tree. It is not one opaque file either. `theme pull` splits it into files you can read, diff, and commit. `theme push` puts them back. Use [Editing with AI](/storefront/editing-with-ai) for the complete Claude Code, Cursor, or Codex workflow. Read [Advanced styling for Easy themes](/storefront/easy-theme-advanced-styling) for Custom CSS, custom classes, fonts, Custom HTML, and announcement bars. ```bash theme={"system"} shoppex theme pull --theme --dir ./theme cd theme shoppex theme dev shoppex theme push shoppex theme publish ``` A checkout looks like this: ``` theme/ |-- theme.json name, version, style slots, fonts, presets, custom CSS |-- global-blocks.json navigation, footer, cart drawer |-- AGENTS.md rules for an AI assistant editing this checkout |-- pages/ | |-- home.json | |-- product.json | `-- collections/summer.json `-- .shoppex/ |-- document.json tracking: theme id, revision, checksums `-- schema/ JSON Schema for each file kind ``` Every authored file carries a `$schema` pointer into `.shoppex/schema/`. VS Code and other JSON language servers use it to complete block types, element types, and settings. They also flag mistakes while you type. The schemas come from the server on each pull, so they always match the contract that validates your push. `pull` requires an empty target directory. Add `--force` to overwrite matching files in a non-empty one. Pages deleted upstream are removed locally, and each removal is printed. `theme.json` carries a `pageOrder` array. Page aliases resolve in document page order, so the checkout records that order instead of inferring it from the directory listing. Reorder the entries to reorder the document. A page file you add locally is appended, and the push output names it. Move it inside `pageOrder` to place it somewhere else. `pull` also writes `AGENTS.md`, so Cursor, Claude Code, and Codex pick up the rules that the files do not show: which block types exist, that `.shoppex/` is off limits, and that a push conflict means pull-and-reapply. A pull refreshes the guide it wrote itself. It never touches an `AGENTS.md` you wrote: it says so and leaves your file alone. Ask your AI tool to read `AGENTS.md` before it changes a checkout. See [Editing with AI](/storefront/editing-with-ai) for a copy-paste workflow. `push` sends the reassembled document with the revision recorded at pull time. If the Builder saved the theme in the meantime, the push is rejected with a conflict. It does not overwrite that edit. Pull again, reconcile, then push. The server is the only validator. A contract violation comes back as a validation error that names the offending path, so the CLI keeps no second copy of the schema. `push` writes the draft. `publish` promotes exactly the revision the checkout recorded, so a Builder save in between is a conflict instead of a silent promotion of an edit this checkout never saw. ### Which themes can be pushed `pull` works for every Easy theme. `push` only works for themes whose document is authoritative, which means themes imported from a ThemeDocument. A theme installed from the catalog renders its document from Builder settings. The server refuses direct document writes to it, and `pull` says so up front. That checkout is still useful for reading, diffing, and backing up. Edit it in the Builder instead. ### Live preview while you edit `theme dev` watches the checkout and saves the draft after each change. It also serves a local page that frames the hosted preview and reloads it on every save. ```bash theme={"system"} shoppex theme dev --dir ./theme --port 4300 ``` It prints a `http://localhost:` address. Open that address, not the preview URL, because the local page performs the reload. The preview renders on the server from the draft, so you see the real renderer, not a local approximation. A save that would store an identical document is skipped, so no-op writes do not spend a revision. If the Builder saves the same theme while `dev` runs, the next save conflicts and `dev` stops. Pull again, reconcile, then restart. A file that is not valid JSON yet is normal mid-edit. It is reported once and does not stop the loop. The API key needs `themes.read` for `pull`. It needs `themes.write` for `push`, `dev`, and `publish`. A key bound to one theme can use the equivalent theme-specific scopes. ## Advanced storefronts Advanced storefronts use merchant-owned Vite and React source instead of a ThemeDocument. The CLI can pull the current source, push a complete local project, build it, and publish the completed artifact. ```bash theme={"system"} shoppex storefront list shoppex storefront pull --theme --dir ./storefront cd storefront shoppex storefront push shoppex storefront push --delete shoppex storefront status shoppex storefront deploy shoppex storefront export --out ./backup.zip ``` `pull` requires an empty target directory. Add `--force` when you intentionally want to overwrite matching files in a non-empty directory. After a pull, the CLI writes `.shoppex/storefront.json`: ```json theme={"system"} { "themeId": "11111111-1111-4111-8111-111111111111", "sourceRevision": "a1b2c3d4e5f67890", "files": { "package.json": "5f8f04f6a3a892aaabbddb6cf2738948f8f4d46fdc2f5e58d3a8f8f40f6f9f25", "src/App.tsx": "8c73402ad40b0a4f8fd900e5d8ebf32e071c70af879a82a44cbbbc7f0c7f3a31" } } ``` The revision is the optimistic concurrency guard. The `files` map records the last successful pull or push. On every pull, the CLI writes the full remote manifest and removes locally tracked files that were deleted remotely. It prints each removed path. `--force` still controls overwriting in a non-empty directory. `pull` downloads the whole revision as a single archive instead of one request per file, so even large storefronts arrive in one round trip. Every extracted file is verified against the manifest checksum, and the pull stops without writing anything if the archive and the manifest disagree. `push` sends every tracked file plus new local files. New files under `node_modules`, `dist`, `.git`, `.turbo`, or `.shoppex`, and `.DS_Store` files are ignored. A `shoppex.theme.json` at the storefront root is ignored too. The name is reserved for theme package metadata, and the API rejects it as source. Already tracked files are never ignored, so a tracked file such as `dist/vendor.js` remains part of the source tree. ## Exporting a storefront package `export` downloads the complete source tree of the current revision as a single ZIP. It adds one `shoppex.theme.json` package manifest, holding the theme metadata and the storefront's content. It is the portable form of a storefront: back it up, hand it to another developer, or import it into another shop. ```bash theme={"system"} shoppex storefront export --theme shoppex storefront export --dir ./storefront --out ./backup.zip ``` Inside a pulled storefront directory the theme id comes from `.shoppex/storefront.json`, so `--theme` is optional there. Without `--out` the CLI writes the server-provided filename into the current directory. It refuses to overwrite an existing file unless you pass `--force`, and never writes through a symlink. `export` needs only read access, so `themes.read` or the theme-specific read scope is enough. Pushes above about 10 MB automatically upload the complete source tree as a single ZIP archive. This transport switch requires no flags and keeps the same revision, deletion-confirmation, and build behavior as smaller pushes. If a tracked file is missing locally, `push` prints the complete deletion list and stops. Re-run with `--delete` to confirm those deletions. The full snapshot then removes them remotely. If the remote revision changed, pull the latest source before you push again. Pulling also applies remote deletions locally. By default, `push` starts a build and prints its job ID. Use `--no-build` to upload source without starting one. `deploy` checks the current revision's build every five seconds, waits up to 600 seconds, and publishes only after the build reaches `COMPLETED`. Override the wait with `--timeout `. Immediately before publishing, `deploy` re-checks that the server is still on the revision recorded in `.shoppex/storefront.json`. If another developer pushed, deploy stops and asks you to pull or push first. When a build fails, `deploy` and `status` print the failure kind and the reason recorded by the build. They also print what to do about it, plus the tail of the build log. The most common failure is `lockfile_out_of_sync`: your `package.json` and lockfile disagree, because a dependency changed without a matching `bun install`. Run `bun install`, then push again so the updated lockfile ships with your source. `workspace_unavailable` is ours, not yours: retry the build. The API key needs `themes.read` for `list`, `pull`, `status`, and the build polling in `deploy`. It needs `themes.write` for `push`, build enqueueing, and the publish step in `deploy`, so `deploy` needs both scopes. A key bound to one theme can use the equivalent theme-specific read and write scopes. # ThemeDocument structure Source: https://docs.shoppex.io/storefront/theme-document Every field, page ID, and URL route inside a ThemeDocument, with pointers to blocks and design tokens. A ThemeDocument is one JSON object: a name, a map of pages, blocks that repeat on every page, and one theme object for tokens. This page follows the ThemeDocument schema in `packages/builder-contracts` and the route resolver that serves it. It covers the Easy lane only. See [Themes](/storefront/themes) for how Easy compares to an Advanced code storefront. ## The shape of a document | Field | Type | Holds | | -------------- | --------------- | ------------------------------------------------------------------------------------------------------------------------------ | | `version` | number | Schema version. The parser accepts version 1 on input and always writes out version 2. | | `name` | string | Display name of the look, for example "Default" or "Starlight". | | `pages` | object | A map from page ID to page. See [Page IDs](#page-ids). | | `globalBlocks` | array of blocks | Blocks that render on every page: header, footer, and the cart drawer. | | `theme` | object | Scheme name, style slots, fonts, presets, and custom CSS. See [Design tokens and style slots](#design-tokens-and-style-slots). | A minimal document looks like this: ```json theme={"system"} { "version": 2, "name": "Default", "pages": { "home": { "slug": "/", "title": "Home", "blocks": [ { "id": "blk_hero", "type": "hero", "variant": "split-right", "visible": true, "settings": { "hero.title": "Premium digital goods" } } ] } }, "globalBlocks": [ { "id": "blk_header", "type": "header", "visible": true, "settings": {} }, { "id": "blk_footer", "type": "footer", "visible": true, "settings": {} } ], "theme": { "scheme": "default", "style_slots": { "color.primary": "#7C3AED" }, "fonts": { "heading": "Inter", "body": "Inter" }, "presets": [] } } ``` A document has hard limits. Element nesting can go up to 12 levels deep, and a single node can have up to 200 children. A page renders up to 2,000 nodes. That count includes its own blocks plus every global block. The whole document, as serialized JSON, cannot pass 2,000,000 bytes. ## Page IDs Every entry in `pages` has an ID. Some IDs are built in and reserved. A custom page created in the Builder gets its own ID instead, and cannot reuse a reserved one. Shoppex rejects the collision at conversion time. | Page ID | Common blocks | | ------------------- | ------------------------------------------------------------------------------------------------------ | | `home` | Content blocks chosen by the merchant, for example `hero`, `products`, `faq`. | | `product` | `gallery`, a buy box (`buy-box`, `product-buy-box`, or `product-form`), `details`, `related-products`. | | `cart` | `page-cart`. | | `checkout` | `page-checkout`. | | `all-products-page` | `page-all-products`. | | `faq-page` | `page-faq-page`. | | `reviews-page` | `page-reviews-page`. | | `feedback-page` | `page-feedback-page`. | | `status-page` | `page-status-page`. | | `tool-page` | `page-tool-page`. | | `guide-page` | `page-guide-page`. | | `contact-page` | `page-contact-page`. | | `support-page` | A contact form block, for example `contact-form`. | | `terms` | `terms-page`. | | `privacy-policy` | `terms-page`. A reserved ID for a store with a separate privacy page. | | `refund-policy` | `terms-page`. A reserved ID for a store with a separate refund page. | | `trust-page` | `page-trust-page`. Present in some themes only. | | `not-found` | `page-not-found`. Shoppex serves this page when no URL matches. | A merchant-created custom page can hold four block types only: `custom-html`, `image`, `media-embed`, and `text-block`. Shoppex converts it into a normal ThemeDocument page, keyed by the same ID the merchant chose. ## Which page renders which URL Shoppex turns an incoming URL path into a page ID before it renders anything. | URL | Page ID | | -------------------------------------------------- | ---------------------------------------------------------- | | `/`, `/home`, `/index.html` | `home` | | `/product/:slug`, `/products/:slug` | `product`, with the slug as the product to show | | `/guide/:slug` | `guide-page`, with the slug as the guide to show | | `/guide` | `guide-page` | | `/all-products`, `/products`, `/all-products-page` | `all-products-page` | | `/cart` | `cart` | | `/checkout` | `checkout` | | `/faq` | `faq-page` | | `/reviews`, `/reviews-page` | `reviews-page` | | `/feedback` | `feedback-page`, or `reviews-page` if that page is missing | | `/status` | `status-page` | | `/tool` | `tool-page`, or `tool` if that page is missing | | `/contact` | `contact-page` | | `/support` | `support-page`, or `contact-page` if that page is missing | | `/terms`, `/terms-of-service` | `terms` | | `/privacy-policy`, `/privacy-policy-page` | `privacy-policy` | | `/refund-policy`, `/refund-policy-page` | `refund-policy` | | `/page/` or `/page/` | The custom page with that slug or ID | | `/` | That exact page ID, as a last resort | A page can add more paths in its own `aliases` field. Shoppex checks pages in document order and returns the first match, so an earlier page's alias can win over a later page's own slug. Shoppex has no direct URL for `not-found`. It serves that page, with a 404 status, only when no other route matches. ## Blocks | Field | Type | Notes | | ------------- | ------- | ------------------------------------------------------------------------------- | | `id` | string | Stable across edits. Cannot be blank, `.`, or `..`, and cannot contain a slash. | | `type` | string | Registered block type, for example `hero` or `products`. | | `variant` | string | Optional. Selects a visual variant of the block. | | `visible` | boolean | Hides the block without removing it from the document. Defaults to `true`. | | `settings` | object | Block-specific settings. | | `customCss` | string | Optional. Merchant CSS scoped to this one block. Up to 16,384 bytes (16 KB). | | `customClass` | string | Optional. Extra CSS class names on the block root, separated by spaces. | | `children` | array | Optional. An element tree rendered inside the block. | See the [Block reference](/storefront/block-reference) for every block type and every setting it reads. A block's `children` array holds a small set of element types: * `heading`, `text`, `button`, `image`, `icon` * `spacer`, `divider` * `countdown`, `marquee`, `accordion`, `tabs`, `before-after`, `carousel`, `stat-counter`, `flip-card` * `group`, which nests other elements inside it, up to the depth and count limits above ## Design tokens and style slots `theme.style_slots` is a flat map from a slot name to a value. A fixed set of core slots covers color, radius, spacing, and type: | Style slot | Value type | | -------------------------------------------------------------------------------------- | ----------------------------------------- | | `button.radius`, `input.radius`, `card.radius` | responsive number | | `button.background`, `button.foreground`, `button.border` | color | | `input.height` | responsive number | | `input.border`, `input.background`, `input.foreground` | color | | `card.background`, `card.border` | color | | `section.padding.y`, `section.padding.x`, `container.width` | responsive number | | `color.primary`, `color.accent`, `color.background`, `color.foreground`, `color.muted` | color | | `link.color` | color | | `button.font.weight`, `typography.heading.weight` | font weight (100 to 900, in steps of 100) | | `typography.body.size` | responsive number | A responsive number takes a `base` value plus optional overrides at the `sm`, `md`, `lg`, and `xl` breakpoints, for example `{ "base": 14, "lg": 16 }`. A slot name that starts with `theme.`, for example `theme.hero.overlayOpacity`, is scheme-specific and free-form. Its value can be a color, a string, a number, a boolean, or a responsive number or string. `theme.presets` is an array of named style bundles. Each preset targets one element type. It can set values only for these CSS properties: * `fontSize`, `fontWeight`, `color`, `backgroundColor` * `padding`, `paddingTop`, `paddingRight`, `paddingBottom`, `paddingLeft` * `margin`, `marginTop`, `marginRight`, `marginBottom`, `marginLeft` * `borderRadius`, `letterSpacing`, `lineHeight`, `textTransform` An element in `children` picks up a preset through its own `preset` field, by ID. `theme.custom_css` is merchant CSS applied after the scheme's own styles, up to 65,536 bytes (64 KB). Shoppex validates it before saving: `media`, `supports`, `font-face`, `keyframes`, `-webkit-keyframes`, `layer`, `container`, `scope`, `page`, and `property` at-rules are allowed. The `behavior` and `-moz-binding` properties are not, and neither are the `expression()`, `image-set()`, or `-webkit-image-set()` functions. ## Revisions A publish turns the current draft into a new revision. A published revision never changes again. See [Visual themes](/storefront/visual-themes) for the full editing loop, and [Themes](/storefront/themes) for what a publish does across both storefront lanes. # Themes Source: https://docs.shoppex.io/storefront/themes Choose between an Easy visual theme and an Advanced code storefront, then see how Shoppex serves both. Every Shoppex storefront runs in one of two lanes: Easy, a visual ThemeDocument you edit in the Builder, or Advanced, your own React and Vite project. ## Easy or Advanced | | Easy | Advanced | | ------------- | -------------------------------------------------------- | --------------------------------------------------------------- | | Source | A ThemeDocument: pages, ordered blocks, and settings | Your own React and Vite project | | You edit with | The Theme Builder, the CLI, or AI tooling | Your code editor, the CLI, or AI on your source files | | Build step | None. The Builder saves structured data directly. | Each publish installs your dependencies and builds the project. | | Rendered by | The Shoppex block renderer | Your built output, served as a locked build that never changes | | Safety net | A revision history. Publishing an old draft restores it. | Your own version control, plus the build history in Shoppex | | Best for | Fast setup and guided editing, with no code required | Full control over markup, components, and behavior | If you are not sure which lane to pick, start Easy. The two lanes do not mix inside one storefront, so switching from Easy to Advanced replaces the ThemeDocument with a new source project. Edit pages and blocks in the Theme Builder, then preview and publish. Bring your own React and Vite source, or import an existing project. ## Installable themes Open **Store → Themes** to install one of five active themes: `default`, `starlight`, `pulse`, `clean-minimal`, and `classic`. Each one creates a ThemeDocument you can edit in the Builder right away. If you are not sure which theme to start from, use `default`. Five older themes are retired: `apex`, `nebula`, `phantom`, `shadow`, and `vault`. An existing storefront on one of these keeps rendering, and you can still export and import it. You cannot create a new theme on a retired scheme. ## Easy: the ThemeDocument contract A ThemeDocument splits ownership across four layers. | Layer | Owns | | ------------------------- | ---------------------------------------------------------------------- | | ThemeDocument | Pages, ordered blocks, design tokens, content settings, and custom CSS | | Shoppex backend | Validation, drafts, revisions, and catalog and route context | | Block renderer | HTML, structured data, and renderer-owned styles | | Platform commerce runtime | Cart, product actions, coupons, and checkout handoff | Every block has a stable ID, a registered type, a visibility flag, and typed settings. An unknown block type or an invalid setting fails validation. Shoppex does not fall back to a different block or a guessed value in its place. The backend supplies request data such as products, collections, locale, and currency. A theme setting never calculates a final price, a stock level, payment eligibility, or a checkout rule. Those stay server-side, on every theme. See [Visual themes](/storefront/visual-themes) for the editing workflow itself. ## Choosing a runtime or SDK | You are building | Use | | ------------------------------------------------------ | --------------------------------------------------------------------------- | | An Easy storefront hosted by Shoppex | The Theme Builder, or the CLI and API against ThemeDocument settings | | An Advanced storefront hosted by Shoppex | Your own React and Vite source, edited with the CLI, the code editor, or AI | | A storefront you host yourself, for example on Next.js | `@shoppexio/storefront` | | A trusted server integration | `@shoppexio/sdk` and the `/dev/v1/*` Developer API | A storefront hosted by Shoppex, Easy or Advanced, includes the platform commerce runtime automatically. A block does not start its own cart SDK or duplicate checkout logic. A storefront you host yourself imports `@shoppexio/storefront` and owns its own rendering and deployment. It still calls Shoppex APIs for the official commerce data and buyer actions. ## How storefronts serve Shoppex resolves your domain at the edge and renders the published document or artifact from there. * Publishing a ThemeDocument creates a new document revision. That revision never changes again. * Publishing an Advanced storefront creates a new build. That build never changes again. * A renderer update ships on its own revision, independent of your document. * Public assets use versioned URLs, so a given URL always returns the same file. * Custom domain routing and cache purges are a Shoppex responsibility, not yours. * A protected download or a private draft never appears through the public asset path. If a shop has no valid published document or artifact, the storefront returns the normal unavailable response. Shoppex does not fall back to another theme or an older source. # Troubleshooting Source: https://docs.shoppex.io/storefront/troubleshooting Resolve ThemeDocument validation, preview, revision, and build problems on either lane. ## A setting is rejected Fetch the current document schema, then compare it against the field name and value type you sent. Shoppex rejects unknown keys and unsupported block types on purpose, instead of guessing what you meant. ## The preview shows an older change Reload the draft, then confirm you edited the theme and page you expected. If another editor published a newer revision, load it, apply your change to it again, and preview it. ## Publishing reports a revision conflict Someone else saved or published just before you did. Fetch the latest draft, reapply your change, preview it, then publish again. ## The live storefront is unavailable Confirm that the theme has a valid published document and that it is active. If the dashboard state looks correct, contact Shoppex Support. Give them the shop domain, the theme ID, the rough time it happened, and the request ID if one is shown. ## An Advanced storefront fails to build `deploy` and `status` print the failure kind, the reason recorded by the build, what to do about it, and the tail of the build log. * `lockfile_out_of_sync` is the most common failure. Your `package.json` and your lockfile disagree, because a dependency changed without a matching `bun install`. Run `bun install`, then push again so the updated lockfile ships with your source. * `workspace_unavailable` is a Shoppex-side failure, not a problem with your source. Retry the build. See [Theme CLI](/storefront/theme-cli) for the full `deploy` and `status` behavior. ## An import or replace is rejected Every import and replace enforces the same limits: a 42 MiB ZIP, a 40 MiB extracted source tree, 400 files, a 5 MiB single file, and 512-character paths. A symbolic link, an absolute path, a path containing `..`, or two paths that collide only by letter case also fail the upload. A missing root `package.json`, or one without a `build` script, fails too. See [Code storefronts](/storefront/code-storefronts) for the complete list of requirements. ## A CLI push or replace reports a revision conflict Someone else saved a newer revision while you worked: another developer, an AI edit, or a different CLI push. Pull the latest source, then push or replace again. A pull also applies any remote deletions to your local copy. # Visual themes Source: https://docs.shoppex.io/storefront/visual-themes Edit ThemeDocument settings, pages, and blocks in the Theme Builder, then preview and publish. An Easy theme is a ThemeDocument: a set of pages built from ordered blocks, each with typed settings. Edit it in the Theme Builder, or automate it with the CLI and API. ## What settings cover ThemeDocument settings are structured data, validated against the theme's schema. They cover: * Colors, typography, spacing, and shape tokens * Page and block content * Block visibility and variants * Approved custom CSS Fetch the current settings before you update them. An update sends the complete validated settings object, so an omitted key removes that value. Settings hold presentation data only. Do not store secrets in them. Do not reproduce pricing, stock, payment, or checkout rules there. Those stay server-side. ## Blocks and stable IDs Each block on a page has a stable ID, a registered type, a visibility flag, and typed settings. The ID stays the same when you reorder or edit the block. A later automated update or an AI edit targets that ID, so a changed ID looks like a deleted block plus a new one. An unknown block type or an invalid setting fails validation. Shoppex does not select a fallback block or guess a value in its place. ## The editing loop The Theme Builder holds the source of truth for a draft. Open the page in the Builder, or fetch the current document and settings, before you change anything. Change only the page, block, or setting you intend to update. Check every page type that uses the changed block: product, collection, content, and system pages. Saving stores your draft. It does not change the live storefront. Publishing validates the document and creates a new revision. Visitors see the new revision only after it completes. ## Revisions and conflicts Every publish creates a new revision. A published revision never changes afterwards, so a visitor never receives a half-written document. Another editor, an AI edit, or a CLI push can save a newer draft while you work. If that happens, your save or publish is rejected with a revision conflict instead of silently overwriting that work. If you get a revision conflict, reload the latest draft. Then reapply your change on top of it. For a self-hosted headless application, use `@shoppexio/storefront` instead of the Theme Builder. See [Themes](/storefront/themes) for how the two compare.