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.
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.
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
| Param | Type | Default |
|---|---|---|
recordSize | query | 3 |
{
"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
| Param | Type | Notes |
|---|---|---|
key | query, optional | Omit 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
| Param | Type | Default |
|---|---|---|
recordSize | query | 3 |
Same ProductEntity shape as Search / Product Details below. Returns "Data": [] when no products are marked featured (default seed data has none).
GET /search/{category?}
| Param | Type | Notes |
|---|---|---|
category | query (preferred) | Category sanitized-name. Prefer ?category=adapters over the /search/adapters route segment — see callout below. |
categoryID | query | Alternative to category |
q | query | Free-text search term |
priceFrom / priceTo | query | Price range filter |
sortby | query | |
pageNo | query | default 1 |
recordSize | query | default 20 |
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}
| Param | Type | Default |
|---|---|---|
pageNo | query | 1 |
recordSize | query | 10 |
{
"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}
| Param | Type | Default |
|---|---|---|
recordSize | query | 6 |
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}
| Param | Type | Default |
|---|---|---|
quantity | query | 1 |
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
| Param | Type | Notes |
|---|---|---|
promoCode | query string | A 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
| Field | Required | Notes |
|---|---|---|
FullName | yes | |
Email | yes | Must be a valid email format |
PhoneNumber | no | |
Country / City / Address / ZipCode | yes | |
CreateAccount | no (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:
| Field | Required | Notes |
|---|---|---|
FundingSource | no | paypal (default) or card. Card payments return no ApproveURL and need a PayPal SDK in the app. |
ReturnURL / CancelURL | no | Where 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
| Field | Required | Notes |
|---|---|---|
PayPalOrderID | yes | From /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
| Field | Required |
|---|---|
FullName | yes |
UserName | yes |
Email | yes, valid email |
Password | yes, 6–100 chars |
ConfirmPassword | must 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
| Param | Default |
|---|---|
searchTerm | empty |
pageNo | 1 |
recordSize | 10 |
Same shape as product-comments, scoped to the calling user.
GET /user/orders
| Param | Default |
|---|---|
orderStatus | 0 (all) |
pageNo | 1 |
recordSize | 10 |
{
"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).
-
Set the collection variables
The collection ships with
baseUrl(http://localhost:5209),lang(en),username/password(the seeded admin),productId, andorderIdalready filled in — adjust them under the collection’s Variables tab if your setup differs. -
Run “Auth → Issue Token”
Its test script automatically saves the returned
access_tokeninto the{{token}}collection variable. -
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. -
Cart flow
Enable Postman’s cookie jar for
{{baseUrl}}so Add To Cart, Apply Promo, Get Cart, and Place Order share the sameAppSessioncookie — 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.