Home / API Reference

API Reference

A JSON REST API under Areas/API, for a first-party mobile/headless client. Every route (except token issuance) is relative to /api/{lang}, where {lang} is a language short code such as en.

Import the Postman collection

Every endpoint below — with the same request bodies and real example responses — is in eCommerceMVCCore.postman_collection.json, ready to import into Postman. See Postman Collection for setup instructions.

Authentication

Sign in with a POST to /api/Token, form-urlencoded:

POST /api/Token
Content-Type: application/x-www-form-urlencoded

grant_type=password&username=adm_use&password=adm_use123

Response:

{
  "access_token": "eyJhbGciOiJIUzI1NiIs...",
  "token_type": "bearer",
  "expires_in": 1209599,
  "userID": "86344938-10f6-4e38-9eb4-3b756766ef78",
  "userName": "adm_use",
  "pictureURL": "/content/images/..."
}

The token is a self-issued JWT (no external OAuth server) containing the user’s ID, username, email, and role claims. Send it on subsequent requests:

Authorization: Bearer <access_token>

Token lifetime is controlled by Jwt:ExpiryDays in appsettings.json (default 14 days). A failed login returns 400 with {"error":"invalid_grant", "error_description":"..."}; an unsupported grant_type returns {"error":"unsupported_grant_type"}. Only grant_type=password is supported.

Response Envelope

Every endpoint under Areas/API (everything except /api/Token itself) returns the same JSON envelope, with the HTTP status code mirrored inside the body:

{
  "StatusCode": 200,
  "StatusMessage": "OK",
  "Data": { ... }
}

Data is omitted entirely when there’s nothing to return (e.g. a successful POST with no payload). This envelope is serialized directly with Newtonsoft.Json and is always PascalCase, regardless of the MVC-wide JSON casing setting.

Unhandled exceptions become opaque 500s in production

When the app-level Environment configuration key is LIVE (see Go-Live Checklist), any unhandled exception in this area is caught and replaced with a bare {"StatusCode":500,"StatusMessage":"Internal Server Error"} — and because the exception is caught at the MVC filter level, it never reaches ASP.NET Core’s normal diagnostic logging either. If a client reports a mysterious 500, reproduce it with a debugger or temporarily flip Environment to something other than LIVE rather than searching the server log.

Storefront

All anonymous, read-only. Base path /api/{lang}.

GET /store-info

Store name/intro, contact details, currency symbol — sourced from Configurations.

{
  "StatusCode": 200,
  "StatusMessage": "OK",
  "Data": {
    "StoreName": "Your Store",
    "StoreIntro": "eCommerce MVC is an extendable, adaptable eCommerce project developed with C# ASP.NET Core MVC. It has all the features a fully functional online shopping website requires.",
    "Address": " ",
    "CurrencySymbol": "$",
    "PhoneNumber": null,
    "MobileNumber": null,
    "WhatsAppNumber": null,
    "Email": "contact@email.com",
    "FacebookURL": null,
    "TwitterURL": null,
    "InstagramURL": null,
    "YouTubeURL": null,
    "LinkedInURL": null
  }
}

GET /sliders

Parsed home-page slider data (see Configurations → Sliders for the raw storage format).

{
  "StatusCode": 200,
  "StatusMessage": "OK",
  "Data": [
    {
      "ImageLink": "/content/images/site/sliders/slider1.jpg",
      "Heading": "Welcome to eCommerce MVC",
      "Summary": "eCommerce MVC is an extendable, adaptable eCommerce project...",
      "Link": "/en/search"
    }
  ]
}

GET /featured-categories

ParamTypeDefault
recordSizequery3
{
  "StatusCode": 200,
  "StatusMessage": "OK",
  "Data": [
    {
      "ID": 2,
      "ParentCategoryID": null,
      "Name": "Headphones",
      "IsFeatured": true,
      "SanitizedName": "headphones",
      "Picture": "/content/images/demo/c0ed2af7-6eb6-4b8a-bb73-45c4b4d410f1.png",
      "ProductsCount": 8
    }
  ]
}

GET /categories-menu

Full category tree (one level of children). The “Uncategorized” category is always filtered out.

{
  "StatusCode": 200,
  "StatusMessage": "OK",
  "Data": [
    {
      "Children": [],
      "ID": 2,
      "Name": "Headphones",
      "SanitizedName": "headphones",
      "ProductsCount": 8
    },
    {
      "Children": [
        { "ID": 3, "Name": "Stands", "SanitizedName": "stands", "ProductsCount": 18 }
      ],
      "ID": 4,
      "Name": "Chargers",
      "ProductsCount": 18
    }
  ]
}

GET /configurations

ParamTypeNotes
keyquery, optionalOmit to get every configuration key/value

Single key:

GET /api/en/configurations?key=CurrencySymbol

{
  "StatusCode": 200,
  "StatusMessage": "OK",
  "Data": { "Key": "CurrencySymbol", "Value": "$" }
}

Unknown key → 404 {"StatusCode":404,"StatusMessage":"Not Found"}.

GET /featured-products

ParamTypeDefault
recordSizequery3

Same ProductEntity shape as Search / Product Details below. Returns "Data": [] when no products are marked featured (default seed data has none).

GET /search/{category?}

ParamTypeNotes
categoryquery (preferred)Category sanitized-name. Prefer ?category=adapters over the /search/adapters route segment — see callout below.
categoryIDqueryAlternative to category
qqueryFree-text search term
priceFrom / priceToqueryPrice range filter
sortbyquery
pageNoquerydefault 1
recordSizequerydefault 20
Use the query string, not the route segment

An absent optional route segment still occupies the category model-binding key with an empty value, which short-circuits the binder’s fallback to the query string for a same-named parameter. In practice: /search?category=adapters works; relying on the bare route segment for anything beyond a literal path can silently bind category="". This is documented directly in the controller source.

GET /api/en/search?q=adapter&pageNo=1&recordSize=3

{
  "StatusCode": 200,
  "StatusMessage": "OK",
  "Data": {
    "TotalRecords": 5,
    "TotalPages": 0,
    "SearchFilters": {
      "CategoryID": null, "SearchTerm": "adapter", "PriceFrom": 0.0,
      "PriceTo": 0.0, "SortBy": "", "PageNo": 1, "RecordSize": 3
    },
    "Products": [
      {
        "ID": 149, "CategoryID": 6, "Name": "Apple 18W USB-C Power Adapter",
        "Price": 12.50, "Discount": 8.50, "Cost": 0.00,
        "SKU": "10231", "StockQuantity": 8,
        "ProductSpecifications": [],
        "Pictures": [ { "URL": "/content/images/demo/150.png", "IsThumbnail": true } ]
      }
    ]
  }
}

GET /product-details/{ID}

Same ProductEntity shape as Search, plus TotalRatings / AverageRatings. 404 if the product doesn’t exist.

{
  "StatusCode": 200,
  "StatusMessage": "OK",
  "Data": {
    "ID": 149, "CategoryID": 6, "Name": "Apple 18W USB-C Power Adapter",
    "Price": 12.50, "Discount": 8.50, "StockQuantity": 8,
    "TotalRatings": 0, "AverageRatings": 0,
    "Pictures": [ { "URL": "/content/images/demo/150.png", "IsThumbnail": true } ]
  }
}

GET /product-comments/{ID}

ParamTypeDefault
pageNoquery1
recordSizequery10
{
  "StatusCode": 200,
  "StatusMessage": "OK",
  "Data": {
    "TotalComments": 2,
    "TotalPages": 0,
    "CommentsListFilters": { "PageNo": 1, "RecordSize": 10 },
    "Comments": [
      {
        "ID": 2,
        "Product": { "ProductID": 30, "ProductTitle": "GameSir-F2- Grip puBG hand Controller" },
        "TimeStamp": "2026-09-13T14:31:13.0651861",
        "UserID": "a634ecb5-d723-40f8-8d45-b12e4d762eb8",
        "UserName": "doc_demo_user",
        "Rating": 5,
        "Text": "Great grip, works well for long gaming sessions."
      }
    ]
  }
}

GET /related-products/{ID}

ParamTypeDefault
recordSizequery6

Falls back to featured products if there aren’t at least recordSize in the same category.

Cart

Base path /api/{lang}. Cart state is session-based (an AppSession cookie), not tied to a signed-in user — identical behavior for anonymous and authenticated callers, which is what makes guest checkout possible.

POST /add-to-cart/{ID}

ParamTypeDefault
quantityquery1

Adding an item already in the cart increments its quantity rather than duplicating the row.

{
  "StatusCode": 200,
  "StatusMessage": "OK",
  "Data": {
    "Message": "Product added to cart.",
    "CartItems": [ { "ItemID": 149, "Quantity": 2, "Price": 8.50, "ProductTotal": 17.00 } ],
    "CartTotal": 17.00
  }
}

Insufficient stock → 400 with a localized Message.

POST /apply-promo

ParamTypeNotes
promoCodequery stringA plain string parameter binds from the URI under [ApiController], not the POST body — sending it as form/JSON data (or omitting it) still hits this action but with a null value.
POST /api/en/apply-promo?promoCode=SAVE10

{
  "StatusCode": 200,
  "StatusMessage": "OK",
  "Data": { "Message": "Success. Enjoy discount of 10.00 %." }
}

Invalid code → {"Message":"Invalid promo/voucher."}. Expired code → {"Message":"Sorry. This promo/voucher has expired."}. Both 400.

GET /cart

{
  "StatusCode": 200,
  "StatusMessage": "OK",
  "Data": {
    "CartItems": [
      {
        "Product": { "ID": 149, "Title": "Apple 18W USB-C Power Adapter", "Picture": "/content/images/demo/150.png" },
        "Price": 8.50, "Quantity": 2, "ProductTotal": 17.00
      }
    ],
    "PromoApplied": false,
    "Promo": null,
    "SubTotal": 17.00,
    "Discount": 0.0,
    "DeliveryCharges": 0.0,
    "FinalTotal": 17.00
  }
}

POST /clear-cart

Empties the session cart. {"StatusCode":200,"StatusMessage":"OK"}, no Data.

Orders

Base path /api/{lang}. Orders are placed with Cash on Delivery (/place-order-cod) or PayPal (below).

GET anon* /order-tracking/{orderID}

Accessible without a token for guest orders. For a non-guest order, requires a valid JWT belonging to that order’s owner, or a token whose role claims include Administrator.

{
  "StatusCode": 200,
  "StatusMessage": "OK",
  "Data": {
    "Status": "Placed",
    "PaymentMethod": "Cash On Delivery",
    "PlacedOn": "13-09-2026 14:00:11",
    "UpdatedOn": "13-09-2026 14:00:11",
    "UniqueCode": "fc527d8d-0241-4d5c-bde8-1886aa274ccf",
    "OrderDetails": {
      "OrderItems": [ { "ID": 144, "ProductName": "Apple HDMI To HDMI Cable", "Price": 6.00, "Quantity": 1, "ProductTotal": 6.00 } ],
      "TotalAmmount": 6.00, "Discount": 0.00, "PromoDetails": "", "DeliveryCharges": 0.00, "FinalAmmount": 6.00
    },
    "ContactDetails": {
      "GuestOrder": true, "CustomerID": null,
      "CustomerName": "Guest API Buyer",
      "CustomerEmail": "gue************@example.com",
      "CustomerPhone": "5559876543"
    },
    "ShippingDetails": {
      "CustomerCountry": "Testland", "CustomerCity": "Testville",
      "CustomerAddress": "1 Guest Way", "CustomerZipCode": "99999"
    },
    "OrderHistory": [
      { "ModifiedOn": "13-09-2026 14:00:11", "Status": "Placed", "Note": "Order Placed." }
    ]
  }
}

Non-guest order, no/wrong token → 401. Nonexistent order → 404. Note CustomerEmail is automatically masked (HideEmail()).

POST anon* /place-order-cod

FieldRequiredNotes
FullNameyes
EmailyesMust be a valid email format
PhoneNumberno
Country / City / Address / ZipCodeyes
CreateAccountno (bool)Ignored if a valid Bearer token is already attached — the order is linked to that user instead

Places whatever is currently in the caller’s session cart (run add-to-cart first). Behavior branches on identity:

  • Bearer token attached — order linked to that authenticated user.
  • No token, CreateAccount=true — a new account is created with the given name/email and signed in via cookie (not a token). No password is set on this account It can never subsequently authenticate via /api/Token without a password-reset flow — a pre-existing gap shared with the classic storefront checkout, not specific to this port.
  • No token, CreateAccount=false — guest order (IsGuestOrder: true), trackable by anyone with the order ID.
{
  "StatusCode": 200,
  "StatusMessage": "OK",
  "Data": {
    "Message": "Your order has been placed successfuly.",
    "OrderID": 10
  }
}

Empty cart → 400 {"Message":"Invalid products in cart."}. Validation failure → 400 with the ModelState error messages joined by newlines.

POST anon* /paypal/create-order

Starts a PayPal payment for the session cart (requires PayPal to be set up — see PayPal Payments). Takes the same fields as /place-order-cod, plus:

FieldRequiredNotes
FundingSourcenopaypal (default) or card. Card payments return no ApproveURL and need a PayPal SDK in the app.
ReturnURL / CancelURLnoWhere PayPal sends the buyer after approving or cancelling on ApproveURL — normally the app’s deep link
{
  "StatusCode": 200,
  "StatusMessage": "OK",
  "Data": {
    "PayPalOrderID": "5O190127TN364715T",
    "ApproveURL": "https://www.sandbox.paypal.com/checkoutnow?token=5O190127TN364715T"
  }
}

Open ApproveURL in a browser/custom tab (or hand PayPalOrderID to PayPal’s native mobile SDK), then call capture once PayPal returns to ReturnURL. Nothing is charged and no order exists until capture.

POST anon* /paypal/capture-order

FieldRequiredNotes
PayPalOrderIDyesFrom /paypal/create-order, in the same session

Checks the cart still matches what PayPal was asked to charge, captures the payment and places the order (same account/guest rules as /place-order-cod, decided from the create-order call). Safe to retry: a second call for the same PayPal order returns the same OrderID.

{
  "StatusCode": 200,
  "StatusMessage": "OK",
  "Data": {
    "Message": "Your order has been placed successfuly.",
    "OrderID": 11,
    "IsPending": false
  }
}

IsPending: true means PayPal is still reviewing the payment; the order is On Hold until it clears. Failures → 400 {"Message": "...", "CartChanged": false}; CartChanged: true means the cart or prices changed after create-order — nothing was charged, start again from create-order.

Account (Users)

Base path /api/{lang}/user. Every endpoint below requires Authorization: Bearer <token> except Register and Logout, which are anonymous.

POST anon /user/register

FieldRequired
FullNameyes
UserNameyes
Emailyes, valid email
Passwordyes, 6–100 chars
ConfirmPasswordmust match Password if provided

New accounts get the User role only. Success: {"StatusCode":200,"StatusMessage":"OK"}. Failure examples (all 400, message in Data as a plain string, not an object):

"Username 'api_test_user' is already taken."
"The password and confirmation password do not match."
"The Email field is required.\nThe Full Name field is required.\n..."

GET anon /user/logout

No-op — JWTs are stateless and self-expiring, there is nothing server-side to invalidate. Kept only so an existing client’s logout call keeps working.

GET /user/info

{
  "StatusCode": 200,
  "StatusMessage": "OK",
  "Data": {
    "ID": "86344938-10f6-4e38-9eb4-3b756766ef78",
    "PictureURL": "/content/images/f1b36ae8-d1b7-4bd4-8112-5ddcba541141.jpg",
    "FullName": "Admin", "UserName": "adm_use", "Email": "adm_use@domain.com",
    "PhoneNumber": "(312)555-0690", "Country": "Adminsburg", "City": "Adminstria",
    "Address": "404 Block D, Adm Street", "ZipCode": "123456",
    "RegisteredOn": "2026-09-11T14:52:02.354265"
  }
}

POST /user/info/update

Form fields: FullName, Email, UserName, PhoneNumber, Country, City, Address, ZipCode. Success/failure use the same shape as Register.

POST /user/password/update

Form fields: OldPassword, NewPassword, ConfirmPassword. Wrong old password → 400 "Incorrect password."

GET /user/comments

ParamDefault
searchTermempty
pageNo1
recordSize10

Same shape as product-comments, scoped to the calling user.

GET /user/orders

ParamDefault
orderStatus0 (all)
pageNo1
recordSize10
{
  "StatusCode": 200,
  "StatusMessage": "OK",
  "Data": {
    "TotalOrders": 1,
    "TotalPages": 0,
    "OrdersListFilters": { "PageNo": 1, "RecordSize": 10 },
    "Orders": [
      { "ID": 9, "PlacedOn": "2026-09-13T13:03:44.1717599", "Status": "Processing", "PaymentMethod": "Cash On Delivery", "FinalAmmount": 8.50 }
    ]
  }
}

POST /user/avatar/update

multipart/form-data with a file field (e.g. picture). Must be a genuine multipart request — sending no body at all returns 400, not a silent no-op.

Postman Collection

Download eCommerceMVCCore.postman_collection.json (in this same Documentation/ folder) and import it into Postman: Import → File (or drag the file onto Postman).

  1. Set the collection variables

    The collection ships with baseUrl (http://localhost:5209), lang (en), username/password (the seeded admin), productId, and orderId already filled in — adjust them under the collection’s Variables tab if your setup differs.

  2. Run “Auth → Issue Token”

    Its test script automatically saves the returned access_token into the {{token}} collection variable.

  3. Run anything under “Account (Users)”

    That folder has folder-level Bearer auth wired to {{token}}, so every request in it picks up the token automatically — no per-request header editing needed.

  4. Cart flow

    Enable Postman’s cookie jar for {{baseUrl}} so Add To Cart, Apply Promo, Get Cart, and Place Order share the same AppSession cookie — they’re session-based, not token-based (see Cart).

Every request has one or more saved example responses attached (visible in Postman’s response panel dropdown) — success and the realistic failure cases (400/401/404) — all captured from a real running instance of this codebase, not fabricated.