Skip to main content

Blog API

The Blog API allows automated article agents (such as LLM publishing pipelines) or external scripts to ingest, update, and publish posts programmatically without opening the admin editor. Call it on the shop domain—the Next.js app proxies requests to Lambda, authorized by a bearer token.

Categories already exist before you write a post. Create them in the admin, or import blogCategories/ as described in Sample data. This API lists active categories. It does not create, edit, or delete them, and it has no request that deletes a post.

Authentication​

Open System → Site Settings and find Blog Content API. Generate a token, copy it, and save. After a successful save the field is empty again. Leave it empty on a later save to keep the stored token. Generate a new token if the old one is lost.

Send the token on every request:

Authorization: Bearer <token>

A missing or wrong token returns 401 with UNAUTHORIZED.

If Site Settings has no token, Lambda uses BLOG_API_TOKEN from the environment that was present when the sandbox or the Amplify backend was last deployed. A token saved in Site Settings is checked first, on the next request, and does not require a redeploy. The local sandbox loads .env before it deploys, which is how that fallback is set. Do not put the token in storefront client code.

BLOG_API_ALLOWED_ORIGINS is optional. It is a comma-separated list, read when the backend is deployed, and it sets CORS on the Lambda Function URL. The default is *. The shop-domain proxy answers a browser preflight with *. The bearer token is what rejects an unknown caller.

Calling the API​

https://{shop-domain}/api/blog-content

The kit includes scripts/examples/publish-blog.example.sh. That script reads custom.blogContentApiUrl from amplify_outputs.json and calls the Function URL directly. A redeploy can change that URL. Callers you keep should use the shop domain above. The proxy forwards Authorization, Content-Type, and the raw body.

{slug} and {language} are single path segments. Through the shop domain, each segment may contain letters, digits, ., _, ~, and -.

MethodPathResult
GET/categoriesActive categories, ordered by sortOrder.
GET/posts/{language}Posts in that language. The list omits HTML. Drafts are included unless you filter them.
GET/posts/{slug}/{language}One post, including drafts and HTML.
PUT/posts/{slug}/{language}Create or replace that post.
POST/posts/{slug}/{language}/publishSet the post published.
POST/posts/{slug}/{language}/unpublishSet the post unpublished.
POST/media/uploadStore one image and return its key.

{language} is the content language, not the storefront URL prefix. Accepted codes are en, zh-TW, ja, ko, de, fr, es, zh, and vi. Prefixes such as hk, jp, and kr are rejected. The mapping from prefix to content language is in Storefront locales. One post is the pair of slug and language. The same slug in another language is a different post.

A published post is shown at /{locale}/post/{slug} for the storefront locale that uses that content language. Drafts stay available to this API and are omitted from the storefront.

Write a post​

PUT requires title, content, and categorySlug. content is HTML. The API does not convert Markdown. categorySlug must match an active category from GET /categories. An unknown or inactive slug returns 400 with INVALID_CATEGORY.

curl -X PUT "https://{shop-domain}/api/blog-content/posts/desk-care/en" \
-H "Authorization: Bearer $BLOG_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"title":"Desk care","content":"<p>Wipe spills promptly.</p>","categorySlug":"your-category","isPublished":false}'

The response includes created (true when the pair did not exist), isPublished, publishedAt, categorySlug, tags, and orphanedUploadsDeleted.

PUT writes the editable fields. It is not a merge of a partial JSON object.

FieldWhen omitted
excerpt, seoTitle, seoDescriptionStored empty.
coverImageKeyThe current cover is kept. Send an empty string to clear it.
tagsThe current tags are kept. A new post stores []. Send a string array, or one string split on commas.
isPublishedThe current value is kept. A new post is a draft.
publishedAtKept when the post already has one. If you publish and there is no date, the server sets the current time. Send an ISO-8601 string to choose the date.
isNewsletterFeaturedThe current value is kept. A new post stores false.
newsletterSentAt, newsletterExternalIdThe current value is kept.
uploadedKeysNo session cleanup. See images below.

viewCount is not a write field. A replace keeps the stored count.

POST .../publish accepts an optional JSON body { "publishedAt": "<ISO-8601>" }. Without it, publishedAt is the current time. POST .../unpublish takes no body. It sets isPublished to false and leaves publishedAt as stored. Both return 404 with NOT_FOUND when that slug and language do not exist.

Images​

POST /media/upload accepts JSON:

FieldRule
filenameOriginal file name. The stored name keeps letters, digits, ., _, and -.
contentTypeAn image/* type.
dataBase64 file bytes. The decoded file must be non-empty and at most 5 MB.

The response is { "key", "url" }. key looks like admin-pictures/blog/<timestamp>-<filename>. url is the key on the store CDN when the CDN hostname is configured. Put key in coverImageKey. In HTML, an img src may be that key or the current CDN URL. On save, a URL on the current CDN host is stored as the key.

uploadedKeys is the list of keys you uploaded for this save, including any you then removed from the draft. Keys in that list that are not the cover and do not appear in the HTML are deleted. The response lists them in orphanedUploadsDeleted. Replacing the cover, or removing an image from the HTML, also deletes the key that the previous saved post used and this save does not.

List​

GET /posts/{language} accepts these query parameters.

ParameterRule
limitPage size. Default 50. Maximum 100.
isPublishedtrue or false. Omit to include drafts and published posts.
isNewsletterFeaturedtrue or false.
nextTokenPass the nextToken from the previous page. An invalid token returns 400.

The response is { "language", "items", "nextToken" }. nextToken is null on the last page. Each item has slug, language, title, excerpt, categorySlug, coverImageKey, tags, isNewsletterFeatured, isPublished, publishedAt, updatedAt, and viewCount. Fetch one post when you need content.

GET /categories returns { "items" }. Each item has slug, name, and isActive. name is an object of content-language strings. Inactive categories are omitted.

Errors​

StatuserrorWhen
400VALIDATION_ERRORThe language is not accepted, the JSON is invalid, a required write field is missing, the upload is not an image or is over 5 MB, or nextToken is invalid.
400INVALID_CATEGORYcategorySlug is missing from the active categories.
400BAD_PATHThe shop-domain proxy rejected a path segment.
401UNAUTHORIZEDThe bearer token is missing or does not match.
404NOT_FOUNDThe post does not exist, or the path is not one of the routes above.
405VALIDATION_ERRORThe method is not allowed on that path.
500INTERNAL_ERRORThe token is not configured, or the request failed inside Lambda.
502UPSTREAM_UNAVAILABLEThe shop-domain proxy could not reach Lambda.
503NOT_CONFIGUREDblogContentApiUrl is missing from amplify_outputs.json.