Writing through the API
Idempotency keys, the draft and publish model, what goes live at once, deletes, errors and photo uploads.
Besides reading, a secret key can change a studio's orders, gift vouchers, availability, settings, catalog and studio page. Every write follows the same rules, described here once. Which scope an endpoint needs is in Keys and auth; each operation names its scope in the reference.
A publishable key never writes, apart from POST /orders. Every other write
answers it with 403 INSUFFICIENT_SCOPE.
Every write carries an Idempotency-Key
Every write needs an Idempotency-Key header, POST, PUT, PATCH and
DELETE alike. It is a token you choose, 64 characters at most. A missing,
blank or too long key is 400 INVALID_INPUT with field: Idempotency-Key, and
nothing is written.
curl -X PATCH https://api.shyni.io/api/v1/services/SERVICE_ID \
-H "Authorization: Bearer $SHYNI_KEY" \
-H "Idempotency-Key: crm-5521-rename" \
-H "Content-Type: application/json" \
-d '{"name": "Full detail"}'A repeat under the same key never runs the write twice:
| Situation | Response |
|---|---|
| Same key, same body, an answer is stored | That status and body, replayed as-is |
| Same key, same body, the first request is still running | 409 IDEMPOTENCY_IN_PROGRESS: retry shortly |
| Same key, a different body | 422 IDEMPOTENCY_KEY_MISMATCH: use a new key |
A key stays replayable for 24 hours. For an upload the "body" is the file:
another file under the same key is a 422. Use one key per intended action and
retry network errors and 5xx with the same key. The full rules are in
Idempotency.
Draft, then publish
Edits to the catalog and to the studio page do not change what customers see. They go to a draft, and the live version changes only when you publish.
| What you edit | Where it goes | How it goes live |
|---|---|---|
| Services, service groups and their questions, packages, places | Catalog draft | POST /catalog/publish |
| Opening hours, weekday rules | Catalog draft | POST /catalog/publish |
Basic studio info, deposit amounts, footer texts (PATCH /studio) | Staged in the draft | The studio's next publish |
| Studio page: description, FAQ, why us, brands, custom sections, layout, theme, gallery, hero, about and logo photos | Studio page draft | POST /storefront/publish |
| Both of the above | Both drafts | POST /publish (needs catalog:write and storefront:write) |
POST /publish is also the only way to publish a studio for the first time.
POST /catalog/publish and POST /storefront/publish need a studio that is
already published and answer 409 otherwise.
GET /publish-state tells you whether anything is waiting: visibility,
publishedAt, hasUnpublishedChanges and, per part (CATALOG, STOREFRONT),
which sections differ.
Worked example: a new service
Create a service. The answer is the draft service, with active and the rest of
its fields (excerpt):
curl -X POST https://api.shyni.io/api/v1/services \
-H "Authorization: Bearer $SHYNI_KEY" \
-H "Idempotency-Key: svc-ceramic-1" \
-H "Content-Type: application/json" \
-d '{
"categoryId": "CATEGORY_ID",
"name": "Ceramic coating",
"description": "Two layers of ceramic coating.",
"scope": "PRIMARY",
"basePrice": 890000,
"durationMinutes": 480
}'{ "id": "SERVICE_ID", "name": "Ceramic coating", "basePrice": 890000, "active": true, "...": "..." }basePrice is in minor units (hellers for CZK). description is plain text or
a rich text document. variants is left out here, so the studio's default
variants are used.
The draft shows it:
curl https://api.shyni.io/api/v1/catalog/draft/services \
-H "Authorization: Bearer $SHYNI_KEY"{ "data": [ { "id": "SERVICE_ID", "name": "Ceramic coating", "...": "..." } ],
"meta": { "page": 1, "perPage": 20, "total": 1 } }The published catalog does not, and neither do customers or publishable keys.
GET /services lists only what was published:
curl https://api.shyni.io/api/v1/services \
-H "Authorization: Bearer $SHYNI_KEY"Publish:
curl -X POST https://api.shyni.io/api/v1/catalog/publish \
-H "Authorization: Bearer $SHYNI_KEY" \
-H "Idempotency-Key: publish-2026-10-19"The answer is the publish state (excerpt):
{ "visibility": "PUBLISHED", "hasUnpublishedChanges": false, "...": "..." }Now GET /services includes the service. Publishing needs at least one active
service, otherwise 409 CANNOT_PUBLISH_WITHOUT_SERVICES. A studio page without
a phone or a public e-mail cannot be published either
(409 CANNOT_PUBLISH_WITHOUT_CONTACT).
A service can carry two sales rules. orderAudience KNOWN_CARS sells it only
to a car the studio already knows (the order must carry a valid knownCarToken),
and an add-on with soldOnlyWithServiceIds sells only together with one of
those services (empty means any main service). An order that breaks a rule is
rejected with 422 SERVICE_KNOWN_CARS_ONLY or 422 ADDON_SOLD_ONLY_WITH, and
nothing is created.
To throw the unpublished changes away instead, call
DELETE /catalog/draft (catalog) or DELETE /storefront/draft (studio page).
Each puts the draft back to the live version, all or nothing; when one section
cannot go back without breaking another, nothing is reverted and the 409
names what to discard first.
What goes live at once
Not everything has a draft. These changes take effect immediately:
- Availability blocks (
/availability/blocks, and a whole repeating series at/availability/block-series/{seriesId}).GET /availability/startshonours them as soon as they are written. - Discount codes, service categories and the default variants
(
PUT /catalog/variants). - The operational studio settings: booking pause, deposit collection mode,
reminders, review mail, Google review link, employee rights and the
confirmation settings (
PUT /studio/...). The basic info ofPATCH /studiois staged in the draft instead, see above. - The description and focal point of a photo (
PATCH /images/{id}). - Orders, booking links, reviews and gift vouchers: these act on records, not on a published version.
Each operation's description says whether it is a draft or live. When unsure, check it there.
Deleting
DELETE answers 200 with the id and a flag, not 204:
{ "id": "BLOCK_ID", "deleted": true }An unknown id, or an id of another studio, is 404 NOT_FOUND.
Errors on writes
Errors are the single {code, message} object described in
Errors. When the error is about one input, it also
carries field, the name of that input: a body path (customer.email), a query
parameter, or a header.
{ "code": "NOT_FOUND", "message": "...", "field": "categoryId" }code is the contract; message is an English sentence for developers and may
change. Beyond the common refusals, a write can answer 403 PLAN_REQUIRED when
the change goes past the studio's plan, for example more main services than the
free plan allows, and 409 CONFLICT when the studio's state forbids it.
Uploading a photo
Photos are uploaded as multipart/form-data, with the file in the part file.
JPEG, PNG, WebP and HEIC are accepted, up to 60 MB; the file's bytes decide the
format, not the declared type. The call needs the storefront:write scope.
curl -X POST https://api.shyni.io/api/v1/images \
-H "Authorization: Bearer $SHYNI_KEY" \
-H "Idempotency-Key: img-hood-1" \
-F "file=@hood.jpg"The answer is 201 with the photo (excerpt):
{ "id": "IMAGE_ID", "alt": null, "width": 4000, "height": 3000, "...": "..." }Uploading the same photo again returns the one already in the library. An empty
file or an unreadable image is 400 with field: file; a file over the limit
is 413 FILE_TOO_LARGE.
The id is what the other endpoints point at. Put it on a service:
curl -X PUT https://api.shyni.io/api/v1/services/SERVICE_ID/image \
-H "Authorization: Bearer $SHYNI_KEY" \
-H "Idempotency-Key: svc-ceramic-image" \
-H "Content-Type: application/json" \
-d '{"imageId": "IMAGE_ID"}'{"imageId": null} takes the photo off. An id that does not belong to the
studio is 404 with field: imageId. The photo on a service is a draft change
(POST /catalog/publish); the studio page's photos (/storefront/hero-image,
/about-image, /logo, /gallery, /before-after) go live with
POST /storefront/publish. Deleting a photo (DELETE /images/{id} or
POST /images/delete) removes it from every draft that shows it, is never
refused for being in use, and disappears from the live page with the next
publish.
Related: Idempotency, Errors, Keys and auth.