Lập trình viên / Tra cứu
API mẫu cửa hàng và danh mục dịch vụ, sản phẩm
Phiên chủ cửa hàng, snapshot có phiên bản và ảnh; phân biệt với Project Auth và Data SDK.
Phạm vi và xác thực
Endpoint quản lý chạy cùng origin https://app.whypik.com, yêu cầu phiên tài khoản WhyPik có membership owner đang hoạt động của cửa hàng. Mutation cần X-CSRFToken; lấy csrf_token từ GET /api/v1/auth/session sau khi đăng nhập. Cửa hàng phải active để ghi. Phiên staff, Cloud API key, Project Auth token và khóa Auth/Data public không thay thế được phiên owner này.
Đây là API của cửa hàng do WhyPik quản lý, không có method tương ứng trong @whypik/js. Auth/Data SDK dùng cho ứng dụng của bạn và không tự truy cập danh mục của cửa hàng. Không nhúng credential quản lý vào SPA công khai.
Tên hiển thị cửa hàng
GET /api/v1/seller/shops/{shop_id} trả {shop}, trong đó shop.id là UUID, shop.slug là ID cửa hàng hiển thị và shop.version là phiên bản cài đặt. PATCH cùng path nhận {name,version} để đổi tên; trả {shop} mới. name là chuỗi 1–120 ký tự trên một dòng, được trim, không nhận ký tự điều khiển. Có thể gửi thêm default_language (vi/en) trong cùng lần PATCH; PATCH chỉ có default_language và version vẫn tương thích.
Chỉ nhận các trường name, default_language, version; các trường id, slug, hostname, domain, project_id, account_id, template_id bị từ chối với 400 unsupported_fields. Đổi tên yêu cầu owner đang hoạt động, cửa hàng active và CSRF. UUID, ID cửa hàng, domain, liên kết dự án và nội dung đã nhập không thay đổi. version phải là số nguyên hiện tại; 409 state_changed cần GET lại trước khi gửi. Phiên bản tăng một lần khi có thay đổi thực; gửi lại cùng tên với version hiện tại không làm tăng phiên bản. Đây là version cài đặt, độc lập với appearance.version và catalog.version.
const path = `/api/v1/seller/shops/${shopId}`;
const { shop } = await fetch(path, { credentials: 'same-origin' }).then(r => r.json());
const result = await fetch(path, {
method: 'PATCH', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken },
body: JSON.stringify({ name: 'Mộc Nhiên', version: shop.version })
});
if (!result.ok) throw new Error('Reload shop details before retrying');
const updated = await result.json();Tên miền riêng của cửa hàng
GET /api/v1/seller/shops/{shop_id}/domains trả {shop_id,default_hostname,primary_hostname,limit,can_add,domains}. POST cùng path nhận JSON {hostname} tối đa 1 KiB và trả cùng snapshot với HTTP 201. Chỉ phiên owner ở app host được dùng; thao tác ghi cần X-CSRFToken. Không dùng Project Auth/SDK browser public key để quản lý tên miền cửa hàng.
domains[] gồm id,hostname,status,provision_state,provision_error,txt_name,txt_value,cname_target,is_primary,tls_ready_at,last_checked_at. Tối đa 3 bản ghi mỗi cửa hàng, bao gồm bản đang gỡ. Hostname được chuẩn hóa IDNA/chữ thường; không nhận IP, wildcard, URL hoặc namespace hệ thống. Registry unique toàn bộ Hosting và cửa hàng. Trùng trả 409 domain_unavailable; vượt giới hạn trả 409 domain_limit.
POST /api/v1/seller/shops/{shop_id}/domains/{domain_id}/check yêu cầu kiểm tra lại khi đang chờ DNS; không bỏ qua lease hoặc cooldown HTTPS. POST .../{domain_id}/primary chọn tên miền đã active, verified và TLS-ready; chưa sẵn sàng trả 409 domain_not_ready. DELETE .../{domain_id} ngừng routing ngay và hủy lease cũ; worker dọn ingress/certificate trước khi giải phóng hostname. Cả ba trả snapshot như GET.
Tên miền chính là custom domain active đã chọn, hoặc custom active đầu tiên, hoặc địa chỉ mặc định. shop.url từ GET chi tiết cửa hàng dùng tên miền chính; shop.hostname tiếp tục là địa chỉ mặc định. Metadata SSR, canonical và email dùng tên miền chính. Session/CSRF vẫn khóa theo host thực tế, không chia sẻ cookie giữa các tên miền. ALLOWED_HOSTS và trusted origins không được mở wildcard.
const base = `/api/v1/seller/shops/${shopId}/domains`;
const session = await fetch('/api/v1/auth/session', { credentials: 'same-origin' }).then(r => r.json());
const response = await fetch(base, {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': session.csrf_token },
body: JSON.stringify({ hostname: 'www.example.com' })
});
const result = await response.json();
if (!response.ok) throw new Error(result.message || 'Cannot add domain');
// Show result.domains TXT/CNAME instructions; only advertise HTTPS when
// status === 'active' and tls_ready_at is present.
console.log(result.domains);Điểm nổi bật và các bước dùng chung
business_section dùng cho nội dung ngành và hai nhóm trang chủ Phần mềm; có eyebrow (80), catalog_title/contact_title/benefits_title/process_title (160), catalog_intro/contact_intro (1000), catalog_action (80 ký tự); benefits_enabled/process_enabled là boolean. benefits và process có tối đa 8 mục, mỗi mục gồm id 12 hex, enabled, title tối đa 160 và text tối đa 1200 ký tự.
Các nội dung chữ dùng translations theo khóa business_section.<field> hoặc business_section.benefits.<id>.<field>/business_section.process.<id>.<field>. Danh mục dịch vụ dùng một bản nội dung theo ngôn ngữ lúc tạo, không tự dịch khi khách đổi ngôn ngữ. Public projection lọc mục đã tắt; khi tắt cả nhóm hoặc trang chủ, không trả nội dung các mục và tiêu đề nhóm đó. Mẫu dịch vụ không trả nội dung phần mềm ẩn.
const response = await fetch(`/api/v1/seller/shops/${shopId}/appearance`, {
method: "PATCH", credentials: "same-origin",
headers: { "Content-Type": "application/json", "X-CSRFToken": csrfToken },
body: JSON.stringify({ version: appearance.version, template_id: "salon",
starter_content: true, catalog_version: catalog.version })
});
if (!response.ok) throw new Error("Reload current content and catalogue before retrying");
const result = await response.json();Chọn mẫu và nội dung
GET /api/v1/seller/shop-templates hoặc GET /api/v1/seller/shops/{shop_id}/appearance trả registry 10 mẫu: standard, spa, salon, fitness, education, studio, home-services, automotive, restaurant, stay. software được chuẩn hóa về standard. Mỗi mẫu có id/name/description, pricing.kind=free, preview/catalog và preview_en/catalog_en; preview không tạo hay giữ chỗ một cửa hàng.
PATCH appearance với {version,template_id} đổi mẫu và giữ nội dung. Cửa hàng mới dùng standard hoặc một trong 8 mẫu ngành được thêm nội dung theo content_language; Spa giữ cách tạo hiện có. Với cửa hàng có sẵn, gửi starter_content:true để điền phần trống trong cùng transaction. Nội dung, ảnh, danh mục và boolean đã lưu được giữ; chuỗi trống và mảng rỗng có thể nhận nội dung khởi đầu. standard không tạo Product/Plan, không sửa service_catalog và không yêu cầu catalog_version. Với mẫu dịch vụ, gửi thêm catalog_version hiện tại; chỉ thêm danh mục khi không có nhóm lẫn mục. 409 appearance_changed hoặc service_catalog_changed cần đọc lại để đối chiếu, không tự retry.
Các trường title/intro/features, feature_section và ảnh banner/features dùng chung giữa các mẫu. business_section bổ sung nội dung theo ngành hoặc điểm nổi bật/các bước trên trang chủ Phần mềm, không lấy các thẻ tính năng phần mềm để hiển thị trên trang dịch vụ. Các phần đã tắt không được bật lại khi thêm nội dung khởi đầu. Nội dung chuẩn Phần mềm không tạo địa chỉ tải, đơn hàng, thanh toán hoặc license.
Bố cục và minh họa Phần mềm
software_section là cấu hình trình bày của standard: layout nhận split hoặc centered; visual nhận workspace, analytics hoặc automation; visual_enabled là boolean; section_order chỉ nhận [benefits,process] hoặc [process,benefits]. PATCH có thể gửi một phần software_section; các trường còn lại được giữ. Trường hoặc giá trị ngoài hợp đồng trả 400 invalid_appearance_section.
demo_section vẫn là tên trường API; giao diện gọi Hình giao diện. Tối đa 6 items; ngoài id/enabled/title/body hiện có, mỗi mục nhận illustration tùy chọn là chuỗi rỗng, workspace, analytics hoặc automation. Không nhận URL minh họa tùy ý. Bỏ trường illustration giữ tương thích với mục cũ; chuỗi rỗng tắt minh họa của mục. Ảnh upload ở slot demo{id} được ưu tiên hơn minh họa; ảnh banner được ưu tiên hơn software_section.visual.
Public projection chỉ trả hình đã bật, có nội dung và có ảnh upload hoặc kiểu minh họa hợp lệ. Mục/khu vực/trang chủ đã tắt bị lọc khỏi nội dung công khai. Chữ dùng translations hiện có; software_section và illustration là cấu hình chung, không nhận bản dịch. Bộ gói khởi đầu là bản nháp trong trình quản lý, tiếp tục lưu qua luồng Product/Plan hiện có, không có API tự tạo bộ gói mới.
const base = `/api/v1/seller/shops/${shopId}/appearance`;
const current = await fetch(base, { credentials: "same-origin" });
if (!current.ok) throw new Error("Cannot read appearance");
const { appearance } = await current.json();
const response = await fetch(base, {
method: "PATCH", credentials: "same-origin",
headers: { "Content-Type": "application/json", "X-CSRFToken": csrfToken },
body: JSON.stringify({ version: appearance.version, template_id: "standard",
starter_content: true, software_section: { layout: "centered",
visual: "analytics", visual_enabled: true, section_order: ["process", "benefits"] } })
});
if (!response.ok) throw new Error("Read current appearance before retrying");
const saved = await response.json();Bảng giá Phần mềm
pricing_section là cấu hình trình bày của standard. layout nhận cards (mặc định), comparison hoặc compact; title tối đa 160 ký tự, subtitle tối đa 1000 ký tự. show_intro, show_description, show_features, show_duration và show_devices là boolean, mặc định true. Trường/giá trị sai trả 400 invalid_appearance_section; PATCH một phần giữ các trường còn lại.
Chỉ pricing_section.title và pricing_section.subtitle thuộc translations. Bố cục và show_* dùng chung giữa ngôn ngữ. pages.pricing là công tắc trang hiện có; không có pricing_section.enabled. Trang tắt trả /pricing 404 và loại nội dung bảng giá tùy chỉnh khỏi public payload. show_intro=false loại title/subtitle công khai; owner vẫn đọc/sửa được.
Các kiểu chỉ đọc sản phẩm/gói active và dùng cùng liên kết checkout theo product/plan ID. Không tự tạo hay kích hoạt Product/Plan, không sửa amount, binding_mode hoặc quyền cấp. Trang chi tiết sản phẩm vẫn dùng nội dung đầy đủ khi bảng giá tắt. API công khai không nhận nội dung hoặc giá minh họa của trình thiết kế.
const base = `/api/v1/seller/shops/${shopId}/appearance`;
const current = await fetch(base, { credentials: "same-origin" });
if (!current.ok) throw new Error("Cannot read appearance");
const { appearance } = await current.json();
const response = await fetch(base, {
method: "PATCH", credentials: "same-origin",
headers: { "Content-Type": "application/json", "X-CSRFToken": csrfToken },
body: JSON.stringify({ version: appearance.version,
pricing_section: { layout: "comparison", show_devices: true } })
});
if (!response.ok) throw new Error("Read current appearance before retrying");
const saved = await response.json();Favicon của cửa hàng
PUT /api/v1/seller/shops/{shop_id}/appearance/images/favicon nhận bytes ICO/PNG/JPEG/WebP tĩnh, không phải JSON hay multipart; DELETE cùng path gỡ biểu tượng. Cả hai dùng phiên owner, X-CSRFToken và X-Appearance-Version hiện tại; response {appearance} có version mới. X-Image-Crop không áp dụng cho favicon.
Tối đa 2.000.000 byte đầu vào và 4.000.000 điểm ảnh. Máy chủ kiểm tra các khung ICO trước giải mã, chuẩn hóa thành ICO nhiều kích thước, bỏ metadata và giữ alpha. File sau chuẩn hóa tính vào quota ảnh cửa hàng; xung đột trả 409 appearance_changed. Đọc lại trước khi thử lại khi kết quả không rõ.
appearance.images.favicon là URL có digest, image_sources.favicon chỉ có width/height và không có bộ AVIF/WebP/PNG. Dùng URL API trả về; GET/HEAD ảnh public chỉ trên đúng hostname cửa hàng. Xóa ảnh làm URL cũ không còn truy cập được; thiếu favicon thì dùng /favicon.ico của WhyPik. Public SSR và trang tài khoản khách đặt link rel=icon ngay trong HTML.
const base = `/api/v1/seller/shops/${shopId}/appearance`;
const current = await fetch(base, { credentials: 'same-origin' });
if (!current.ok) throw new Error('Cannot read current appearance');
const { appearance } = await current.json();
const response = await fetch(`${base}/images/favicon`, {
method: 'PUT', credentials: 'same-origin',
headers: { 'Content-Type': file.type || 'application/octet-stream',
'X-CSRFToken': csrfToken, 'X-Appearance-Version': String(appearance.version) },
body: file
});
if (!response.ok) throw new Error('Read current appearance before retrying');
const saved = await response.json();Đọc và ghi danh mục
GET /api/v1/seller/shops/{shop_id}/service-catalog trả {catalog:{version,categories,items}}. PATCH cùng path gửi toàn bộ {version,categories,items}; đây không phải partial update từng mục. Response trả snapshot mới, version tăng sau mỗi lần ghi. Không gửi items[].image trong PATCH vì đây là trường chỉ đọc.
Bỏ một mục khỏi snapshot sẽ xóa mục và ảnh của mục đó; bỏ nhóm khi còn mục tham chiếu bị từ chối. Muốn ẩn, đặt active:false. UI hiện dùng Ẩn/Hiện. Không gửi mảng rỗng để sửa riêng một mục.
Chỉ owner đọc được cả mục đã ẩn. 409 service_catalog_changed: giữ bản nhập, GET lại để đối chiếu trước khi gửi thay đổi đã được người dùng quyết định. Timeout/mất kết nối không chứng minh thao tác chưa lưu; đọc lại, không tự lặp mutation.
| Trường | Hợp đồng |
|---|---|
| categories | Tối đa 24; mỗi phần tử có đúng id,name,description,position,active |
| items | Tối đa 120; các trường id,category_id,kind,name,description,price,duration_minutes,position,active và tùy chọn price_unit |
| id/category_id | 12 ký tự hex chữ thường; ID không trùng trong từng loại, category_id phải có trong snapshot |
| name/description | Nhóm: 100/1000 ký tự; mục: 160/2000. Tên không được trống sau trim; mô tả là text |
| kind/duration_minutes | service: số nguyên 1–1440 phút; product: null |
| price | Số nguyên VND 0–1.000.000.000; không nhận boolean, chuỗi hoặc số thập phân |
| position/active | Vị trí nguyên 0–99 cho nhóm, 0–999 cho mục; active là boolean |
| price_unit | Chuỗi tùy chọn tối đa 32 ký tự, ví dụ buổi, khóa, đêm. Không làm thay đổi đơn vị tiền tệ VND. |
| Payload | Tối đa 1 MiB; version là số nguyên không âm lấy từ lần đọc gần nhất |
{
"version": 0,
"categories": [{
"id": "123456abcdef", "name": "Chăm sóc da", "description": "",
"position": 0, "active": true
}],
"items": [{
"id": "abcdef123456", "category_id": "123456abcdef",
"kind": "service", "name": "Chăm sóc da cơ bản", "description": "",
"price": 450000, "duration_minutes": 60, "position": 0, "active": true
}]
}Ví dụ sửa một giá từ phiên owner
Chạy trong mã cùng origin trang app sau khi chủ cửa hàng đăng nhập. Truyền shopId và itemId lấy từ cửa hàng/danh mục đã chọn, cùng giá mới. Hàm giữ các mục khác, bỏ metadata ảnh chỉ đọc và không tự retry khi thất bại. Không dùng đoạn này trong trang khách hay SPA trên hostname Hosting.
async function updateSpaPrice(shopId, itemId, price) {
const base = "/api/v1/seller/shops/" + encodeURIComponent(shopId) + "/service-catalog";
async function read(url) {
const response = await fetch(url, { credentials: "same-origin" });
if (!response.ok) throw new Error("HTTP " + response.status);
return response.json();
}
const { csrf_token } = await read("/api/v1/auth/session");
const { catalog } = await read(base);
if (!catalog.items.some(item => item.id === itemId)) throw new Error("Item not found");
const items = catalog.items.map(({ image, ...item }) =>
item.id === itemId ? { ...item, price } : item);
const response = await fetch(base, {
method: "PATCH", credentials: "same-origin",
headers: { "Content-Type": "application/json", "X-CSRFToken": csrf_token },
body: JSON.stringify({ version: catalog.version, categories: catalog.categories, items })
});
const result = await response.json();
if (!response.ok) throw new Error(result.error?.message || "HTTP " + response.status);
return result.catalog;
}Ảnh mục danh mục
PUT /api/v1/seller/shops/{shop_id}/service-catalog/items/{item_id}/image gửi bytes PNG/JPEG/WebP tĩnh, không phải JSON hoặc multipart. DELETE cùng path gỡ ảnh. Cả hai cần X-CSRFToken và X-Catalog-Version chứa version danh mục hiện tại; response {catalog:...} có version mới.
X-Image-Crop tùy chọn là JSON {zoom,x,y,blur}; mặc định căn giữa, khung 900×900. Máy chủ xử lý qua pipeline ảnh hiện có, tạo AVIF/WebP/PNG và tính quota ảnh cửa hàng. UI danh mục hiện dùng căn mặc định và lưu ngay khi chọn.
items[].image là null hoặc {url,width,height,sources}; dùng URL/sources API trả. GET/HEAD URL owner cần đúng phiên và quyền. Public URL khác owner URL; không tự ghép path từ ID hay digest.
Public API, SSR và giới hạn chức năng
Trên hostname cửa hàng, GET /api/v1/store/service-catalog trả {catalog:{version,categories,items}} theo shop được hostname xác định. Chỉ trả nhóm active và mục active thuộc nhóm active khi một trong chín mẫu dịch vụ đang dùng; mẫu Phần mềm trả các mảng rỗng. Các mục được sắp theo position rồi id. API trả toàn bộ danh mục hữu hạn; giao diện khách chia 9 thẻ mỗi trang và lọc nhóm ở trình duyệt.
Public page API/SSR dùng cùng danh mục hiển thị. Các mẫu dịch vụ dùng trang chủ và danh mục tại /pricing theo Trang & hiển thị; /features, /guide và chi tiết sản phẩm phần mềm không được công khai trong các mẫu dịch vụ. Cài đặt hiển thị phần mềm cũ được giữ khi chuyển lại mẫu. Thanh đầu trang có cụm ngôn ngữ, giao diện và tài khoản; quyền/license và đường tài khoản cũ không bị xóa khi đổi mẫu.
Nút liên hệ sử dụng các kênh đã cấu hình, không gọi API đặt lịch hay thanh toán. Mẫu chưa có booking/availability, tồn kho, giỏ hàng, POS, employee PIN hoặc cấp quyền nhân viên cửa hàng. API này không cấp license phần mềm và không tạo đơn hàng.
Cài đặt và vòng đời cửa hàng
Dùng phiên đăng nhập chủ sở hữu trên app và CSRF cho thao tác ghi. GET /api/v1/seller/shops/SHOP_UUID/settings trả shop (gồm sales_paused và version), can_delete, has_transactions. POST cùng URL nhận action pause/resume/delete và version hiện tại; delete cần confirm_slug đúng ID cửa hàng. Phiên bản cũ trả 409, sai chủ sở hữu trả 404, sai xác nhận trả 400. Không dùng Cloud API key thay phiên chủ sở hữu.
sales_paused chỉ chặn đơn NEW, RENEW, UPGRADE và báo giá nâng cấp mới; replay idempotency vẫn trả kết quả cũ. Đơn đã tạo, thanh toán, quyền sử dụng, tải file và licensing không bị thu hồi.
POST delete trả 202 {deleted:true, shop_id, cleanup_status:"pending"}. Website và quyền quản lý bị tắt ngay; worker dọn nội dung/file riêng và chờ gỡ ingress tên miền. Chủ sở hữu vẫn đọc GET settings để xem cleanup_status pending/complete. Mã/UUID và receipt được giữ để không tái cấp nhầm địa chỉ; không gọi delete Project hoặc đổi tài nguyên Cloud.
POST /api/v1/seller/shops/SHOP_UUID/domains/default/primary đặt địa chỉ mặc định làm tên miền chính, giữ các custom domain đang kết nối. Tên miền mặc định gắn ID và không đổi theo tên hiển thị.
POST /api/v1/seller/shops/SHOP_UUID/settings
Content-Type: application/json
X-CSRFToken: CSRF_FROM_SESSION
{"action":"pause","version":CURRENT_VERSION}