Blog posts & image uploads
Five routes let an installed app run a store’s blog: one upsert, three reads, and a direct image upload. They are built for automations (n8n, scripts, AI writers) that publish one document at a time.
| Route | Scope | Idempotency-Key |
|---|---|---|
PUT /apps/blog/posts |
write_blogs |
required |
GET /apps/blog/posts |
read_blogs |
no |
GET /apps/blog/posts/{id} |
read_blogs |
no |
GET /apps/blog/categories |
read_blogs |
no |
POST /apps/media/uploads |
write_media |
required |
A post also announces itself: subscribe to blog_post.* webhooks (scope read_blogs) instead of
polling.
write_blogs includes read_blogs, and write_media includes read_media (see scopes).
Authentication
Section titled “Authentication”Send Authorization: Bearer <token> to the store’s own host (https://{store domain}/apps/...). The host
decides which store you are writing to; there is no tenant parameter.
For a tool that cannot do the token exchange, use a standing token: Settings → Apps → your app → Tokens in
the store’s admin. It is shown once and carries the installation’s scopes. See credentials & tokens.
The installation must have been granted write_blogs (and write_media to upload); a missing scope is a 403
with an empty body.
Requests count against the per-installation rate limit.
Idempotency
Section titled “Idempotency”Both write routes require an Idempotency-Key header (up to 255 characters), scoped to your installation and
remembered for 24 hours. The general rules are on the Idempotency page; for these routes:
- A retry with the same key and the same bytes returns the first answer (header
Idempotent-Replay: true). A retried post does not re-import its images. - The same key with a different body is refused with
422; a key whose first request is still running gives409withRetry-After: 1. - The body is compared byte for byte. A multipart upload is re-encoded with a fresh boundary every time it is built, so a re-built multipart retry looks like a different request. For uploads either resend the exact same bytes or use a new key (a repeated upload only costs an extra unused asset).
- The post upsert is also safe to repeat without a replay: it finds the post by
keyorslug, so a new Idempotency-Key with the same content simply updates the post again.
Upload an image
Section titled “Upload an image”POST /apps/media/uploads: scope write_media, Idempotency-Key required. Response 201.
Send either form:
# multipart: the file part is named "file" (optional "fileName" field)curl -X POST https://shop.example.com/apps/media/uploads \ -H "Authorization: Bearer $CIQRA_TOKEN" \ -H "Idempotency-Key: acme/2026-10-15/1-cover" \
# JSON: base64, with or without a data: URL prefixcurl -X POST https://shop.example.com/apps/media/uploads \ -H "Authorization: Bearer $CIQRA_TOKEN" \ -H "Idempotency-Key: acme/2026-10-15/1-cover" \ -H "Content-Type: application/json" \ -d '{ "fileName": "cover.png", "contentBase64": "iVBORw0KGgo…" }'{ "mediaAssetId": "5b7f…", "storageKey": "media/2026/10/cover.png", "url": "https://shop.example.com/media/…/cover.png", "fileName": "cover.png", "contentType": "image/png", "sizeBytes": 183422, "width": 1672, "height": 941}| Rule | Detail |
|---|---|
| Type | JPEG, PNG, GIF, WebP or AVIF, read from the bytes. The file name and declared content type are ignored. SVG is refused. |
| Size | Base64 JSON: 10 MB decoded (413 above it). Multipart is bounded by the media library’s own image limit. |
| File name | Reduced to letters, digits, -, _ and .; spaces become -; the extension follows the detected type. |
| Storage quota | Over the store’s plan allowance gives 409 with the plan-limit body. |
Use mediaAssetId for coverImage / bannerImage, and url inside the post body.
Create or update a post
Section titled “Create or update a post”PUT /apps/blog/posts: scope write_blogs, Idempotency-Key required.
One call writes one language edition of one post. The post is found by key, else by slug; if none is
found it is created.
curl -X PUT https://shop.example.com/apps/blog/posts \ -H "Authorization: Bearer $CIQRA_TOKEN" \ -H "Idempotency-Key: acme/2026-10-15/1-en-US-v1" \ -H "Content-Type: application/json" \ -d '{ "key": "acme/2026-10-15/1", "locale": "en-US", "slug": "how-to-choose-a-banner", "title": "How to choose a banner", "body": "<p>…</p><img src=\"https://shop.example.com/media/…/inline.png\" alt=\"\">", "excerpt": "Short summary.", "metaTitle": "How to choose a banner | Acme", "metaDescription": "…", "status": "published", "categories": ["45", "guides"], "tags": ["banners"], "coverImage": { "mediaAssetId": "5b7f…" }, "bannerImage": { "url": "https://cdn.example.com/banner.jpg" } }'| Field | Notes |
|---|---|
slug, title |
Required. Leading/trailing / and spaces are trimmed. |
body |
HTML, sanitised by the platform. External https <img> sources are imported into the media library and rewritten; any that fail keep their original address and add a warning. |
locale |
Language of this edition (tr-TR, en…). Omitted = the store’s default language. en-US is accepted on a store configured with en (and the reverse) when exactly one configured language shares the primary subtag; this is reported as a warning. Anything else gives 400 listing the configured codes. |
key |
Your own stable id for the post (up to 200 characters). Calls with the same key land on the same post, so a second language is just a second call. If that post has been moved to the trash, the call restores it (and says so in warnings) instead of failing on the post’s own address. Needs an app token. |
excerpt, metaTitle, metaDescription |
Optional. Omitted on update = unchanged. |
status |
draft, published or scheduled. Omitted = draft on create, unchanged on update. scheduled needs a publishedAt in the future (else 400). published with a past publishedAt uses it; a post already live keeps its date. draft unpublishes. |
publishedAt |
ISO-8601 with offset. |
categories |
Each entry a category uuid, slug, name, or the legacy numeric id. Unknown entries are warnings; if none match, the call is refused with 400 (an uncategorised post would vanish from listings). Omitted = unchanged. |
tags |
Existing tag names, slugs or uuids. Tags are never created; unknown ones are warnings. Omitted = unchanged; [] clears. |
coverImage |
Card/list image: { "mediaAssetId": "…" } or { "url": "https://…" }. |
bannerImage |
Wide header image, same shape. On a new post without one, the header shows the cover. |
Languages
Section titled “Languages”The first edition you send, in any language, creates the post and becomes its base text. Send the store’s
default-language edition for the post’s own text, categories and status; every other language is stored as
a translation with its own title, body, meta fields, slug (and its own cover, banner and publishedAt). A
status sent with a non-default edition of an existing post is ignored, with a warning. Categories and tags are
shared by all editions of a post.
Send the languages of one post one after another. The platform serialises calls that carry the same key (or the
same slug when there is no key), so overlapping calls no longer lose an edition; a call that cannot get its turn
within about 20 seconds answers 409 and is safe to retry. Sequential sending is still the cleanest: in n8n set the
PUT node’s Batching option to 1 item per batch with an interval of about a second and a half, and send the store’s
default language first.
Response: 201 when created, 200 otherwise
Section titled “Response: 201 when created, 200 otherwise”{ "id": "0c1e…", "outcome": "created", "locale": "en", "slug": "how-to-choose-a-banner", "baseSlug": "how-to-choose-a-banner", "status": "published", "publishedAt": "2026-10-12T09:30:00+00:00", "categoryIds": ["…"], "warnings": []}| Field | Meaning |
|---|---|
outcome |
created, updated (default-language edition of an existing post) or translated (another language written onto an existing post). |
locale |
The configured language code the edition was written as (may differ from what you sent; see the warning). |
slug / baseSlug |
The address of this edition / of the post’s base. |
warnings |
Things that were skipped or adjusted. Check it on every call; an empty array means nothing was dropped. |
Look posts up
Section titled “Look posts up”GET /apps/blog/posts
Section titled “GET /apps/blog/posts”Scope read_blogs. With key and/or slug it returns one post; without them it lists recent posts.
| Query | Meaning |
|---|---|
key |
Your caller key (app token only). Tried first. |
slug |
The post’s slug. With locale, that language’s address is tried first, then the base slug. |
locale |
Only used together with slug. |
limit |
List mode only: 1 to 250, default 50. |
One post (404 { "error": "No post matches." } when absent):
{ "id": "0c1e…", "baseSlug": "how-to-choose-a-banner", "defaultLocale": "en", "title": "…", "status": "published", "publishedAt": "…", "excerpt": "…", "metaTitle": "…", "metaDescription": "…", "coverImageKey": "…", "bannerImageKey": "…", "categoryIds": ["…"], "tagIds": ["…"], "editions": [ { "locale": "tr", "slug": "…", "title": "…", "excerpt": "…", "metaTitle": "…", "metaDescription": "…", "hasBody": true, "coverImageKey": null, "bannerImageKey": null, "publishedAt": null } ]}List mode returns an array of { id, baseSlug, title, status, publishedAt, updatedAt }, most recently changed
first, drafts included. It is not paginated.
GET /apps/blog/posts/{id}
Section titled “GET /apps/blog/posts/{id}”Scope read_blogs. The same shape as the single-post answer above; 404 when the id is unknown.
GET /apps/blog/categories
Section titled “GET /apps/blog/categories”Scope read_blogs. Use it to find the ids, slugs and names the categories field accepts.
[ { "id": "…", "slug": "guides", "name": "Guides", "position": 0, "locales": { "tr": { "name": "Rehberler", "slug": "rehberler" } } } ]Webhooks
Section titled “Webhooks”Four webhook events announce blog changes. An installation receives them while it holds
read_blogs (write_blogs includes it); withdraw the scope and the next event is not delivered. Subscribe with
POST /apps/webhooks/subscriptions (scope write_webhooks).
| Event | Sent when |
|---|---|
blog_post.created |
A post exists, as a draft, scheduled or live. Writing a second language of an existing post is an update, not a creation. |
blog_post.updated |
Any committed edit: text, images, address, categories, tags, or a language edition. |
blog_post.published |
A save makes the post live (a draft published, a scheduled post published early, a post created already published), or a scheduled post reaches its time (sent by the scheduled-publish sweep, within a minute or two of publishedAt). Editing a post that is already live does not send it again. |
blog_post.deleted |
The post was deleted. |
They fire for every way a post changes (the admin editor, this API, the Outrank receiver, imports), once per
save. A post created already published sends created and published; publishing a draft sends updated and
published.
{ "postId": "5b1e…", "slug": "bathrobe-or-towel", "title": "Bornoz mu havlu mu?", "status": "published", "publishedAt": "2026-10-15T08:00:00+00:00", "editions": [ { "locale": "en", "slug": "bathrobe-or-towel-en" }, { "locale": "tr", "slug": null } ], "categoryIds": [ "…" ], "deleted": false, "occurredAt": "2026-10-15T08:00:02+00:00"}status is draft, scheduled or published. An edition’s slug is null when it has no address of its own and
follows the post’s. There is no body text and no personal data in the payload, so read_blogs alone receives all of
it; fetch the post with GET /apps/blog/posts/{id} for the rest. blog_post.deleted carries postId, deleted: true
and, when the row could still be read, slug and title.
Scheduled posts.
A post scheduled for a future time sends created / updated with the future publishedAt and
status: "scheduled". When that time arrives, the platform’s scheduled-publish sweep (it runs about once a minute)
sends blog_post.published once for the post, so you do not need your own timer. The announcement can arrive up to a
minute or two after publishedAt; the page itself is live at publishedAt. A post published early by a save sends
published at that save, and the sweep then has nothing left to announce.
Errors
Section titled “Errors”Errors from these routes are { "error": "…" } with the status below. 401 and 403 have empty bodies, as
elsewhere (errors).
| Status | Cause |
|---|---|
400 |
Missing slug/title; bad status, locale or key; no category matched; mediaAssetId not in this store; url used without an app token; missing Idempotency-Key; upload not an image or not valid base64. |
409 |
Slug conflict the platform could not resolve; storage quota exceeded (uploads); an identical request still running. |
413 |
Base64 upload over 10 MB. |
422 |
Idempotency-Key reused with a different request. |
429 |
Rate limit. |
A post is only written if the call succeeds. Image problems are not errors: a failed coverImage.url or body
image import is reported in warnings and the post is still saved.
Moving from the legacy CIQRA API / n8n
Section titled “Moving from the legacy CIQRA API / n8n”The legacy flow sent one request per language document with Ciqra_* fields and images as pscover_,
pslist_ and psdetail_ files. This API keeps the same one-document-per-call shape.
| Legacy | Here |
|---|---|
Ciqra_Title |
title |
Ciqra_Meta_Title |
metaTitle |
Ciqra_Meta_Description |
metaDescription |
Ciqra_Slug |
slug |
Ciqra_CatId (comma list) |
categories, an array of strings. Legacy numbers still work: ["45","46"]. |
Ciqra_Culture |
locale |
pscover_… / pslist_… image |
coverImage: upload, then { "mediaAssetId": "…" } |
psdetail_… image |
bannerImage: upload, then { "mediaAssetId": "…" } |
{psimageN} placeholder in the body |
Upload image N, then replace the placeholder with <img src="{url}" alt="…"> using the returned url |
First post created with notpublish, then published |
"status": "draft" first, later "status": "published" |
| Cache-clear call after publishing | Not needed: pages refresh at the edge within about 5 minutes |
For the Drive-folder flows, use "<brand>/<YYYY-MM-DD>/<n>" as key. The Turkish and English documents of the
same folder then land on one post (one call per language, the same key, a different locale). Make the
Idempotency-Key per document and per version, for example "<key>-<locale>-v1".
A step-by-step n8n recipe is in the n8n blog guide.