Partner Portal — Cover Image Self-Serve API Contract
Base URL: /api/partner/v1
Authentication: Bearer token (JWT via Knock or Doorkeeper OAuth)
Authorization: Authenticated staff must have access to the restaurant (default_restaurant). No creator-ownership check on image edits — Admin-created packages are editable for images by anyone with restaurant access.
Feature flag gate: All three new endpoints (1, 2, 3) require the global Flipper flag
ff_partner_cover_image_selfserve_v1to be enabled. When the flag is off, every endpoint returns501 Not Implementedwith{ "success": false, "message": "Feature not enabled" }.Restaurant ownership rule: Image edits never check
package_created_by_partner?. Only restaurant-level multi-tenant isolation applies. This is a deliberate divergence from the existingPATCH /restaurant_packages/:id(whole-package edit) flow.
Common Error Responses
| Status | When | Body shape |
|---|---|---|
400 Bad Request | Missing required file field | { "success": false, "message": "image is required" } |
401 Unauthorized | Missing/invalid Bearer token | (Knock default) |
403 Forbidden | Wrong restaurant OR (change_active only) Admin-created package with no flipper override | { "success": false, "message": "<i18n>" } |
404 Not Found | package_menu belongs to different restaurant | { "success": false, "message": "Not found" } |
422 Unprocessable Entity | File validation failure (format, size) OR unsupported package type | { "success": false, "message": "<i18n>", "errors": [...] } |
429 Too Many Requests | Rate limit exceeded (>10 image changes per record per rolling 24h) | { "success": false, "message": "Too many image changes. Try again later.", "retry_after_seconds": <int> } + Retry-After header |
501 Not Implemented | Feature flag ff_partner_cover_image_selfserve_v1 is disabled | { "success": false, "message": "Feature not enabled" } |
1. Set Package Cover Image
PATCH /restaurant_packages/:id/cover_image
Instant, creator-agnostic. Does not go through PackageSubmission /
PackageDraftVersion review. Bypasses the existing
PATCH /restaurant_packages/:id review flow entirely. Applies the image
directly to the package’s cover_image field on the HhPackage::Package::*
subtype.
Path Parameters
| Param | Type | Required | Description |
|---|---|---|---|
id | integer | Yes | HhPackage::RestaurantPackage.id (the restaurant-package link, not the package subtype id) |
Request Body
Content-Type: multipart/form-data
| Field | Type | Required | Description |
|---|---|---|---|
cover_image | file | Yes | Image file. Max 10 MB. Allowed formats: jpg, jpeg, png, heic, tif, tiff, webp. Any other format → 422. |
Response 200 OK (success — set)
{
"success": true,
"message": "Cover image updated",
"data": {
/* full Api::Partner::RestaurantPackageSerializer payload for this restaurant_package */
"id": "123",
"type": "restaurant_package",
"attributes": {
"package_id": 456,
"cover_image": "https://cdn.example.com/uploads/hh_package/package/ayce/cover_image/456/foo.jpg",
"created_by_admin": false,
"can_toggle": true,
"can_edit_other_fields": true,
/* ... all other existing serializer fields ... */
}
}
}
Response 422 Unprocessable Entity (file too large)
{
"success": false,
"message": "File too large (Max 10MB). Please upload a smaller image.",
"errors": ["cover_image file size must be between 1 byte and 10 MB"]
}
Response 422 Unprocessable Entity (unsupported format, e.g. PDF)
{
"success": false,
"message": "Unsupported format. Please upload a supported image (JPEG, JPG, PNG, HEIC, TIFF, WEBP).",
"errors": ["cover_image has an invalid content type (application/pdf)"]
}
Response 403 Forbidden (wrong restaurant)
{
"success": false,
"message": "Restaurant package missing"
}
Same i18n key as
PATCH /restaurant_packages/:id— the controller’sset_restaurant_packageraises this for any cross-restaurant access.
Response 429 Too Many Requests
HTTP/1.1 429 Too Many Requests
Retry-After: 4523
Content-Type: application/json
{
"success": false,
"message": "Too many image changes. Try again later.",
"retry_after_seconds": 4523
}
retry_after_seconds = seconds until the earliest change in the rolling
24h window expires (i.e. when the partner will have at least one slot free
again).
2. Remove Package Cover Image
DELETE /restaurant_packages/:id/cover_image
Instant. Clears cover_image to nil. After removal, the package falls back to
its default cover placeholder (driven by existing cover_image_url logic on
the package subtype). Audit entry recorded with action_type: remove.
No request body. All other headers/cookies same as Endpoint 1.
Response 200 OK (success — remove)
{
"success": true,
"message": "Cover image removed",
"data": {
"package_id": 123,
"cover_image_url": null
}
}
Note: response shape is slim for DELETE (unlike PATCH which returns the full serializer). FE should clear the local row’s image and re-render the “Add Cover Image” action label.
Error responses
Identical to Endpoint 1 (404, 403, 429, 501). No 422 possible — no file to validate.
3. Set Menu Item Image
PATCH /package_menus/:id
Instant, single-record, single-field. Strict — any field other than image
is rejected (parameter filtering drops it silently; field validation rejects
missing image).
This is the action that was previously missing on PackageMenusController —
the route existed but the controller action did not. Now implemented.
Path Parameters
| Param | Type | Required | Description |
|---|---|---|---|
id | integer | Yes | HhPackage::PackageMenu.id |
Request Body
Content-Type: multipart/form-data
{
"package_menu": {
"image": "<file>"
}
}
| Field | Type | Required | Description |
|---|---|---|---|
package_menu[image] | file | Yes | Image file. Max 10 MB. Allowed formats: jpg, jpeg, png, pdf, heic. Note: format list differs from cover image — allows PDF, does NOT allow tif/tiff/webp. |
The body MUST be wrapped in
package_menuto match the existing pattern used byPATCH /restaurant_packages/:id(which wraps inpackage).
Response 200 OK (success)
{
"success": true,
"message": "Menu image updated",
"data": {
/* Api::Partner::PackageMenuSerializer payload */
"id": "789",
"type": "package_menu",
"attributes": {
/* ... existing serializer fields ... */
"image_url": "https://cdn.example.com/.../menu.jpg",
"image_menu_url": "https://cdn.example.com/.../menu.jpg"
}
}
}
Response 422 Unprocessable Entity (file validation)
{
"success": false,
"message": "File too large (Max 10MB). Please upload a smaller file."
}
{
"success": false,
"message": "Unsupported format. Please upload a supported file (JPG, JPEG, PNG, PDF, HEIC)."
}
Response 422 Unprocessable Entity (package menu_type: imenupro)
{
"success": false,
"message": "Menu image not supported for this package type"
}
Returned when the menu item’s parent package uses
menu_type: imenuproand has nopackage_menuscontent to attach a photo to. Per PRD, this is a content-availability state, not a permission block.
Response 404 Not Found (wrong restaurant)
{
"success": false,
"message": "Not found"
}
Response 400 Bad Request (missing image field)
{
"success": false,
"message": "image is required"
}
Response 429 Too Many Requests
Same shape as Endpoint 1.
4. change_active (existing endpoint — behavior change)
POST /restaurant_packages/change_active (existing route — same shape)
Behavior change
Previously: no ownership check. Any partner with restaurant access could
toggle any package’s active flag, including Admin-created packages.
After this feature: a package_created_by_partner? guard is added. Toggle is
allowed only if one of:
- The package was created by the partner (existing
HhPackage::PackageSubmission.package_created_by_partner?returns true), OR - The per-restaurant Flipper flag
:partner_full_package_controlis enabled for the partner’sdefault_restaurant.
Response 200 OK (toggle allowed)
{
"success": true,
"message": "Change success"
}
Response 403 Forbidden (toggle blocked — admin-created, no flipper override)
{
"success": false,
"message": "This package was created by Admin and cannot be toggled from the partner portal."
}
i18n key:
partner.restaurant_packages.change_active_admin_blocked. Already present inpartner.{en,th,cn}.yml.
5. New fields on Api::Partner::RestaurantPackageSerializer
Three new attributes added to the existing serializer. Returned in every
GET /restaurant_packages, GET /restaurant_packages/:id, and inside
PATCH /restaurant_packages/:id / PATCH /restaurant_packages/:id/cover_image
responses.
| Attribute | Type | Meaning |
|---|---|---|
created_by_admin | boolean | true iff !HhPackage::PackageSubmission.package_created_by_partner?(package). Informational only — drives the “Created by Admin” badge on package cards. |
can_toggle | boolean | true iff partner-created OR :partner_full_package_control Flipper enabled for the restaurant. Drives visibility of the on/off toggle control. |
can_edit_other_fields | boolean | Same formula as can_toggle. Drives visibility of the “Edit Package” / edit-other-fields action. |
Computing rules (server-side — do NOT re-derive client-side)
created_by_admin = !package_created_by_partner?(package)
can_toggle = package_created_by_partner?(package)
|| Flipper.enabled?(:partner_full_package_control, restaurant)
can_edit_other_fields = same as can_toggle
Why server-side:
package_created_by_partner?depends onHhPackage::PackageSubmissiondata the client doesn’t have. The Flipper flag is per-restaurant, also server-side. Trust the server booleans — do not derive these client-side.
Independence of fields
- The
created_by_adminbadge stays visible even whencan_toggleistruevia the Flipper override. - Image-edit controls (cover image, menu image) are always rendered and
enabled regardless of
created_by_admin,can_toggle, orcan_edit_other_fields.
6. Feature flag summary
| Flag | Type | Default | What it gates |
|---|---|---|---|
ff_partner_cover_image_selfserve_v1 | Global (no actor) | off | All three new image endpoints (1, 2, 3). When off → 501 Not Implemented. |
:partner_full_package_control | Per-restaurant actor (Flipper actor = Restaurant instance) | off | change_active permission for Admin-created packages + can_toggle / can_edit_other_fields on serializer. Does NOT affect image-edit endpoints (which never check this flag). |
7. Rate-limit semantics
10 image changes per HhPackage::RestaurantPackage (for cover) or per
HhPackage::PackageMenu (for menu item) per rolling 24h window.
The window slides — every change starts a fresh 24h clock from its
created_at. The 11th change in any 24h-since-first-change window returns
429 Too Many Requests with Retry-After: <seconds until window slides>.
There is no cross-record rate limit (changing cover on package A does not consume slots on package B).
The limit applies independently to set and remove operations on the same record — 5 sets + 5 removes + 1 more = 11th rejected.
8. Cross-references
- Product PRD:
PRD/Partner_Cover_Image_SelfServe_20260909000000.md - Backend spec:
PRD/BE_Partner_Cover_Image_SelfServe_20260909000000.md - Frontend spec:
PRD/FE_Partner_Cover_Image_SelfServe_20260909000000.md - QA spec:
PRD/QA_Partner_Cover_Image_SelfServe_20260909000000.md - Figma (cover-image flows only): Add: Package Image Self-Service
- hh-server implementation plan:
hh-server/docs/superpowers/plans/2026-09-14-partner-cover-image-selfserve.md