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:

SituationResponse
Same key, same body, an answer is storedThat status and body, replayed as-is
Same key, same body, the first request is still running409 IDEMPOTENCY_IN_PROGRESS: retry shortly
Same key, a different body422 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 editWhere it goesHow it goes live
Services, service groups and their questions, packages, placesCatalog draftPOST /catalog/publish
Opening hours, weekday rulesCatalog draftPOST /catalog/publish
Basic studio info, deposit amounts, footer texts (PATCH /studio)Staged in the draftThe studio's next publish
Studio page: description, FAQ, why us, brands, custom sections, layout, theme, gallery, hero, about and logo photosStudio page draftPOST /storefront/publish
Both of the aboveBoth draftsPOST /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/starts honours 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 of PATCH /studio is 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.

On this page