MojoShop

eCommerce Exchange API

Versioned catalog, cart, checkout, customer account, and partner endpoints for the Exchange. MojoShop is the reference storefront; institutions can whitelabel against the same /api/v1 contract. Public catalog and order JSON may include shop { name, slug, fulfillment_mode } (delivery|pickup|both) for storefronts and Track, but never vendor_id, email, payout, commission, or markup %. Soft anonymity: the main MojoShop catalog UI may hide shop names even when the JSON includes shop. Whole-order shipping_method must be allowed by every shop in the cart. Catalog, cart, and checkout amounts are shopper sale prices (list × (1 + shop markup%)).

Contract

Send JSON. Use Accept: application/json and Content-Type: application/json on POST, PATCH, and DELETE. Successful payloads wrap in data. Lists that paginate also include Laravel links and meta (page, per_page, total).

Customer auth: Authorization: Bearer {token} from login or register (Sanctum token named storefront). Checkout and delivery quotes require that Bearer token. Partners: X-Api-Key. Carts: send X-Cart-Token and read it back on every cart response (guests may browse and fill a cart).

Institution context: the Exchange resolves the active institution from the request hostname (see Institutions in /admin). Catalog, shops, cart, checkout, and flash banner only include vendors on that institution’s allowlist. Carts are scoped per institution — use a separate X-Cart-Token per host (wrong host returns 409). Customer Bearer tokens stay platform-wide and work on every institution site. Partner API keys are tied to one institution via the API client record.

Checkout and partner orders accept optional idempotency_key (max 255). Keys are scoped per signed-in shopper or partner API client so one client cannot replay another’s key. Reuse the same key to get the original order instead of creating another. Empty carts and stock problems return 422 with an errors.cart array.

Rate limits: 60 requests per minute per IP or customer by default. Register 10/min, login and reviews 20/min, password reset and OTP resend 6/min, checkout 30/min, guest order lookup 60/min, partner orders 30/min. Over the limit returns 429 with Retry-After.

Delivery works like the MojoShop checkout: resolve a drop-off pin with GET /api/v1/geocode/search or GET /api/v1/geocode/reverse, send shipping_address.lat and shipping_address.lng on the quote and checkout, then pay. Pickup may omit the address. Account orders page 10 at a time.

Errors

401 Missing or invalid Bearer token, or missing/invalid X-Api-Key (partners may see a more specific message).

{ "message": "Unauthenticated." }

403 Authenticated but not allowed (non-customer token, or partner key without external payment).

{ "message": "This endpoint is for customer accounts." }

404 Unknown product, cart item, or order number + email pair.

{ "message": "Not found." }

409 X-Cart-Token belongs to a different institution host. Use a separate cart token per storefront hostname.

{ "message": "This cart belongs to another storefront. …" }

422 Validation failed, empty cart, unavailable variant, not enough stock, shipping_method not allowed by a shop’s fulfillment_mode, or delivery quote failed.

{ "message": "…", "errors": { "field": ["…"] } }

429 Rate limit exceeded. Retry-After is set. Default 60/min; register, login, password reset, checkout, and partners are tighter.

{ "message": "Too Many Attempts." }

502 MojoPay rejected the checkout session, or the map geocode lookup failed.

{ "message": "…" }

503 MojoPay or Swoove Delivery is not configured in /admin.

{ "message": "Delivery is temporarily unavailable. …" }

Catalog

GET /api/v1/products Public

List published product cards (slim ProductCard — no description/variants/gallery; includes default_variant_id and has_options). Prices (from_price) and min_price / max_price / sort=price_* use shopper sale amounts (list × (1 + shop markup%)). Query: q (max 200), category_id, vendor (shop slug), vendor_id, min_price, max_price, in_stock, on_sale, min_rating (0–5), sort (newest|popular|rating|price_asc|price_desc|name), per_page (1–48, default 24), page. Invalid filters return 422. Includes rating_avg, rating_count, sold_count, discount_percent, shop { name, slug, fulfillment_mode }, and pagination meta/links. Never vendor_id, email, markup_rate, or payout fields.

GET /api/v1/products/{id} Public

Product detail with variants (sale prices), images, related cards, shop { name, slug, fulfillment_mode }, and recent anonymous reviews. Unpublished IDs return 404.

GET /api/v1/products/{id}/related Public

Related product cards in the same category (same slim shape as the catalog list).

GET /api/v1/products/{id}/reviews Public

Paginated approved reviews (20 per page). Soft anonymity — labelled Verified buyer, no names.

POST /api/v1/products/{id}/reviews Bearer

Leave a 1–5 star review after buying the product. Body: rating, optional body. Returns 201 with the review and updated product.

GET /api/v1/categories Public

Active categories (id, parent_id, name, slug, description, sort_order).

GET /api/v1/shops/{slug} Public

Public shop profile for an approved vendor (name, slug, description, city, fulfillment_mode: delivery|pickup|both). 404 when missing or not approved. Never email, payout, or webhook fields.

GET /api/v1/flash-banner Public

Permanent header promo slides: staff promo lines, featured products, and sale items. Product embeds include shop { name, slug, fulfillment_mode } and sale prices.

Checkout

GET /api/v1/geocode/search Public

Forward-geocode a place into a drop-off pin. Query: q (min 3 chars), optional country (ISO-2, default GH). Returns data.lat, data.lng, display_name, and address parts, or data null when nothing matches. Used by the shop map pin. 502 when Nominatim is unreachable.

GET /api/v1/geocode/reverse Public

Reverse-geocode a map pin into address fields. Query: lat (−90–90), lng (−180–180). Same data shape as search. 502 when Nominatim is unreachable.

Cart

GET /api/v1/cart Optional Bearer

Get or create cart. Item unit_price / line_total are shopper sale prices. Each line nests product.shop { name, slug, fulfillment_mode }. Send and read header X-Cart-Token (also in data.token and meta.cart_token). Merges into the account cart when signed in.

DELETE /api/v1/cart Optional Bearer

Remove every item from the current cart. Returns an empty cart with X-Cart-Token. Empty or missing cart still returns 200 with no items.

POST /api/v1/cart/items Optional Bearer

Add item. Body: product_variant_id, quantity. Variant must be active on a published product. Response includes X-Cart-Token and sale-priced lines with shop.fulfillment_mode.

PATCH /api/v1/cart/items/{id} Optional Bearer

Update item quantity. Unknown items for this cart return 404.

DELETE /api/v1/cart/items/{id} Optional Bearer

Remove cart item. Unknown items for this cart return 404.

Checkout

POST /api/v1/checkout/shipping-quote Bearer

Preview Swoove delivery fee for the current cart. Customer Bearer required. Requires customer_name, customer_email, customer_phone, and shipping_address including lat/lng from the map pin (geocode search or reverse). Rejects 422 when any shop in the cart is pickup-only (fulfillment_mode). Item costs use sale prices. Returns data.shipping_total, currency, and legs with shop { name, slug, fulfillment_mode } (never vendor_id). 503 when Delivery is not configured.

POST /api/v1/checkout Bearer

Create order from cart and initiate Mojo Omni payment. Customer Bearer required (guest checkout is not allowed). Body requires shipping_method (delivery|pickup), accept_terms, and optional idempotency_key. shipping_method must be allowed by every shop in the cart (each shop’s fulfillment_mode is delivery, pickup, or both); otherwise 422. Charges shopper sale totals (markup applied); seller share is snapshotted as list − commission. Delivery re-quotes Swoove server-side and adds shipping_total to grand_total (phone + Ghana shipping_address + lat/lng pin required). Pickup has zero shipping; pickup addresses with shop name appear on order lookup after pay. Returns 201 with data.order + data.payment.checkout_url.

Orders

GET /api/v1/orders/{number} Public

Guest order lookup (Track). Query: email (must match checkout email, case-insensitive). Includes shipping_method, shipping_total, fulfillments with shop { name, slug, fulfillment_mode }, pickup blocks when pickup, and shopper shipment { status, tracking_number, carrier, handover_code, tracking_url } when shipped. Item amounts are sale prices; items may include image_path + shop. Never vendor_id, markup_rate, vendor_amount, or seller email/payout fields.

Account

POST /api/v1/auth/register Public

Create a customer account (phone required). When Postmark is connected, emails a signed confirmation link (not an OTP). When Hubtel is connected, also sends an SMS OTP for the phone. Returns 201 with token + user (needs_verification when email/phone still pending).

POST /api/v1/auth/login Public

Customer login. Returns Sanctum token. After 5 failed attempts for an email, locks for 10 minutes (422). Re-sends the email confirmation link and/or SMS OTP when the account still needs verification. Vendor/admin accounts must use their portals (422).

POST /api/v1/auth/verify Bearer

Confirm phone_code (6-digit SMS OTP) when phone verification is pending. Email is verified only via the signed link from the confirmation email (GET /verify-email/{user}/{hash}) — email_code is rejected.

POST /api/v1/auth/resend-verification Bearer

Resend verification. Body: channel = email | phone | both. Email resends the confirmation link; phone resends the SMS OTP.

POST /api/v1/auth/change-email Bearer

Correct a mistyped shopper email while email is still unverified and phone is already verified. Body: email. Clears email_verified_at and sends a new confirmation link when Postmark is connected.

POST /api/v1/auth/change-phone Bearer

Correct a mistyped shopper phone while it is still unverified. Body: phone. Replaces the number and sends a new SMS code when Hubtel is connected (the old code stops working).

POST /api/v1/auth/forgot-password Public

Email a shop password reset link when Postmark is connected. Always returns the same message.

POST /api/v1/auth/reset-password Public

Set a new shopper password with the emailed token. Body: email, token, password, password_confirmation.

POST /api/v1/auth/logout Bearer

Revoke the current storefront token.

GET /api/v1/auth/me Bearer

Current customer profile. Non-customer tokens return 403.

PATCH /api/v1/auth/me Bearer

Update name, email, phone, and optional shipping_address (Ghana region plus lat/lng pin required when country is GH). Clear by omitting address lines. Changing email clears email_verified_at and re-sends the confirmation link when Postmark is connected.

GET /api/v1/account/orders Bearer

Paginated order history for the signed-in customer (10 per page). Same Order shape as guest lookup: created_at, items with image_path + shop { name, slug, fulfillment_mode }, fulfillments (shop, shipment tracking/handover), and payment summary. Line amounts are shopper sale prices. Laravel meta (current_page, last_page, total, from, to) like the catalog.

GET /api/v1/account/push-vapid Bearer

VAPID public key for Web Push subscriptions (shopper PWA). Returns 503 when VAPID keys are not configured.

POST /api/v1/account/push-subscription Bearer

Upsert a browser push subscription for the signed-in shopper. Body: endpoint (URL), keys.p256dh, keys.auth, optional content_encoding (aesgcm|aes128gcm). Returns 201.

DELETE /api/v1/account/push-subscription Bearer

Remove this shopper’s push subscription for the given endpoint (body or query: endpoint).

Partners

POST /api/v1/external/orders X-Api-Key

Create a paid order using a third-party payment reference. Requires shipping_method (delivery|pickup), accept_terms, and Ghana shipping_address.region when delivering. shipping_method must match each line’s shop fulfillment_mode. Optional idempotency_key is scoped per API client. Response shape matches checkout: data.order + data.payment.

Vendor webhooks

Vendors enter an HTTPS callback URL in the vendor panel at /vendor (Webhooks). There is no API endpoint to register or change webhook URLs. MojoShop POSTs JSON to your URL when events happen. Respond with 2xx; failed deliveries retry a few times.

Item unit_price and fulfillment.subtotal are what the shopper paid (sale = list × (1 + markup%)). vendor_amount is your settle share (list − commission). markup_amount is the platform markup on your lines — do not assume vendor_amount ≈ subtotal × (1 − commission%).

Headers: X-MojoShop-Event, X-MojoShop-Timestamp, X-MojoShop-Signature (HMAC-SHA256 of {timestamp}.{raw_body} using your signing secret).

POST order.paid Your URL

Customer payment confirmed. JSON includes the order (your customer + shipping) and only your fulfillment and line items. Example below uses 15% markup on a 40 list price (sale 46) and 10% commission on list.

{
  "event": "order.paid",
  "occurred_at": "2026-08-20T19:00:00+00:00",
  "data": {
    "order": {
      "number": "MS-ABCDEF1234",
      "status": "paid",
      "customer_name": "Ama Buyer",
      "customer_email": "ama@example.com",
      "customer_phone": "0240000000",
      "shipping_address": {
        "line1": "12 Independence Ave",
        "city": "Accra",
        "region": "greater_accra",
        "country": "GH"
      },
      "currency": "GHS",
      "paid_at": "2026-08-20T19:00:00+00:00"
    },
    "fulfillment": {
      "number": "FF-ABCDEF1234",
      "status": "pending",
      "subtotal": 92.00,
      "markup_amount": 12.00,
      "vendor_amount": 72.00,
      "items": [
        {
          "product_name": "Shea Butter",
          "variant_name": "Jar",
          "sku": "SHEA-JAR",
          "quantity": 2,
          "unit_price": 46.00,
          "base_unit_price": 40.00,
          "markup_rate": 15,
          "markup_amount": 12.00,
          "line_total": 92.00
        }
      ],
      "shipment": null
    }
  }
}

POST fulfillment.packed Your URL

Your packing list was marked packed (or a Swoove delivery was booked). Full order/fulfillment shape (same customer + shipping fields as order.paid); shipment may include tracking.

{
  "event": "fulfillment.packed",
  "occurred_at": "2026-08-20T19:10:00+00:00",
  "data": {
    "order": {
      "number": "MS-ABCDEF1234",
      "status": "partially_fulfilled",
      "customer_name": "Ama Buyer",
      "customer_email": "ama@example.com",
      "customer_phone": "0240000000",
      "shipping_address": {
        "line1": "12 Independence Ave",
        "city": "Accra",
        "region": "greater_accra",
        "country": "GH"
      },
      "currency": "GHS",
      "paid_at": "2026-08-20T19:00:00+00:00"
    },
    "fulfillment": {
      "number": "FF-ABCDEF1234",
      "status": "packed",
      "subtotal": 92.00,
      "markup_amount": 12.00,
      "vendor_amount": 72.00,
      "items": [
        {
          "product_name": "Shea Butter",
          "variant_name": "Jar",
          "sku": "SHEA-JAR",
          "quantity": 2,
          "unit_price": 46.00,
          "base_unit_price": 40.00,
          "markup_rate": 15,
          "markup_amount": 12.00,
          "line_total": 92.00
        }
      ],
      "shipment": {
        "provider": "swoove",
        "status": "label_created",
        "tracking_number": "SD-123456",
        "carrier": "swoove",
        "shipped_at": null,
        "delivered_at": null
      }
    }
  }
}

POST fulfillment.shipped Your URL

Parcel is in transit. Same full order/fulfillment payload shape as order.paid; shipment status and tracking are populated when available.

{
  "event": "fulfillment.shipped",
  "occurred_at": "2026-08-20T19:30:00+00:00",
  "data": {
    "order": {
      "number": "MS-ABCDEF1234",
      "status": "partially_fulfilled",
      "customer_name": "Ama Buyer",
      "customer_email": "ama@example.com",
      "customer_phone": "0240000000",
      "shipping_address": {
        "line1": "12 Independence Ave",
        "city": "Accra",
        "region": "greater_accra",
        "country": "GH"
      },
      "currency": "GHS",
      "paid_at": "2026-08-20T19:00:00+00:00"
    },
    "fulfillment": {
      "number": "FF-ABCDEF1234",
      "status": "shipped",
      "subtotal": 92.00,
      "markup_amount": 12.00,
      "vendor_amount": 72.00,
      "items": [
        {
          "product_name": "Shea Butter",
          "variant_name": "Jar",
          "sku": "SHEA-JAR",
          "quantity": 2,
          "unit_price": 46.00,
          "base_unit_price": 40.00,
          "markup_rate": 15,
          "markup_amount": 12.00,
          "line_total": 92.00
        }
      ],
      "shipment": {
        "provider": "swoove",
        "status": "in_transit",
        "tracking_number": "SD-123456",
        "carrier": "swoove",
        "shipped_at": "2026-08-20T19:30:00+00:00",
        "delivered_at": null
      }
    }
  }
}

POST fulfillment.delivered Your URL

Parcel marked delivered. Same full order/fulfillment payload shape as order.paid.

{
  "event": "fulfillment.delivered",
  "occurred_at": "2026-08-20T20:00:00+00:00",
  "data": {
    "order": {
      "number": "MS-ABCDEF1234",
      "status": "fulfilled",
      "customer_name": "Ama Buyer",
      "customer_email": "ama@example.com",
      "customer_phone": "0240000000",
      "shipping_address": {
        "line1": "12 Independence Ave",
        "city": "Accra",
        "region": "greater_accra",
        "country": "GH"
      },
      "currency": "GHS",
      "paid_at": "2026-08-20T19:00:00+00:00"
    },
    "fulfillment": {
      "number": "FF-ABCDEF1234",
      "status": "delivered",
      "subtotal": 92.00,
      "markup_amount": 12.00,
      "vendor_amount": 72.00,
      "items": [
        {
          "product_name": "Shea Butter",
          "variant_name": "Jar",
          "sku": "SHEA-JAR",
          "quantity": 2,
          "unit_price": 46.00,
          "base_unit_price": 40.00,
          "markup_rate": 15,
          "markup_amount": 12.00,
          "line_total": 92.00
        }
      ],
      "shipment": {
        "provider": "swoove",
        "status": "delivered",
        "tracking_number": "SD-123456",
        "carrier": "swoove",
        "shipped_at": "2026-08-20T19:30:00+00:00",
        "delivered_at": "2026-08-20T20:00:00+00:00"
      }
    }
  }
}