İçeriğe geç

Blog posts & image uploads

Bu içerik henüz dilinizde mevcut değil.

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).

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.

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 gives 409 with Retry-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 key or slug, so a new Idempotency-Key with the same content simply updates the post again.

POST /apps/media/uploads: scope write_media, Idempotency-Key required. Response 201.

Send either form:

Terminal window
# 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 prefix
curl -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.

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.

Terminal window
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.

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.

{
"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.

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.

Scope read_blogs. The same shape as the single-post answer above; 404 when the id is unknown.

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" } } } ]

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 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.

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.