TikTok Photo Posting API: Endpoints, Limits and Domain Verification
TikTok has two separate APIs for publishing photos, and a payload copied from one never works on the other. This covers the Business API path: photo_images, photo_cover_index, the URL ownership verification that blocks the first publish, the limits that reject a post, and which failures to retry.
The TikTok photo posting API is a different endpoint from the video one, and picking the wrong one is the most common reason a working video integration cannot publish a carousel. On the Business API the call is POST /open_api/v1.3/business/photo/publish/, with photo_images and photo_cover_index. Before the first image publishes, the domain hosting it has to be verified for ownership. That is a separate gate from the app audit, and it blocks day one.

Two TikTok APIs, two photo endpoints
TikTok runs two publishing surfaces and both can post photos. They are not two names for the same thing.
| Content Posting API | Business API | |
|---|---|---|
| Photo endpoint | POST open.tiktokapis.com/v2/post/publish/content/init/ | POST business-api.tiktok.com/open_api/v1.3/business/photo/publish/ |
| Account identifier | open_id | business_id |
| Photo fields | media_type: PHOTO, post_info.photo_images, post_info.photo_cover_index | photo_images, photo_cover_index, both top level |
| Publish mode | post_mode: DIRECT_POST or MEDIA_UPLOAD | no post_mode; post_info.is_draft instead |
The left column is TikTok's Content Posting API direct-post reference; the right one lives in the Business API portal, which has no stable link per endpoint. Send a Content Posting API body to /business/photo/publish/ and you get a parameter error naming a field you did not think you sent. The split is hard to spot because business_id takes the value of the open_id returned by /tt_user/oauth2/token/: same value, different field name, different host. Check the hostname in any curl before copying out of it. We run the Business API path, and that is what the rest of this describes.
The photo payload, field by field
curl -X POST 'https://business-api.tiktok.com/open_api/v1.3/business/photo/publish/' \ -H "Access-Token: ${ACCESS_TOKEN}" -H 'Content-Type: application/json' \ -d '{ "business_id": "'"${OPEN_ID}"'", "photo_images": ["https://cdn.example.com/verified/1.jpg", "https://cdn.example.com/verified/2.jpg"], "photo_cover_index": 0, "post_info": { "privacy_level": "PUBLIC_TO_EVERYONE", "caption": "Two frames from the same afternoon", "is_brand_organic": false, "is_branded_content": false } }'
The response is {"code": 0, "data": {"share_id": "p_pub_url~v1.2345123456789123456"}}. Six notes on that body.
post_info is required, and so are two of its flags. is_brand_organic and is_branded_content are both mandatory; set both true and is_brand_organic is ignored.
photo_cover_index is an index, not an upload. Default 0, and the cover is one of the images you already sent, not a separate file.
title (90 runes) and caption (4,000 runes, max 30 mentions) are two fields. The video endpoint has no title; we map everything onto caption and never send it.
Draft is is_draft, not upload_to_draft. Same concept, two names, one API. It blanks everything in post_info except title and caption, and needs the video.upload scope, which is not a typo.
music_sound_info is narrower here. For photos it takes music_sound_id only: no volume, start or end. The video-shaped object is a parameter error.
We send fields the photo reference does not list. disable_duet, disable_stitch, thumbnail_offset / custom_thumbnail_url and is_ai_generated are documented for video only, and TikTok does not say whether the photo endpoint ignores unknown keys or rejects them. Split the payloads rather than assume.
URL ownership verification: the day-one blocker
/business/photo/publish/ takes photo_images, a list of publicly accessible HTTP(s) URLs, and has no multipart alternative; the Content Posting API reference says photo posts are PULL_FROM_URL only there too. TikTok fetches the bytes from you, so this lands before your first successful publish: you cannot publish photo posts using unverified photo URLs. Verify the domain in the developer portal under URL properties, with a signature file or a DNS record.
To check the state, call GET /business/property/list/ with app_id and secret. The secret has been required there since 15 October 2025. Each entry returns property_status: 0 pending, 1 verified, 2 failed. One trap: subdomains of a verified domain count as verified but are absent from the list unless added through /business/property/add/, so an empty-looking list proves nothing.
Now the part that costs a day. If you published video through /business/video/publish/ before 7 April 2023, TikTok automatically marked the domains from your video_url values as verified. So video keeps publishing from a grandfathered host, verification never enters your runbook, and the first photo post from a newer bucket or CDN alias fails with an error that reads like a malformed URL. We classify it as terminal: /review our url ownership verification rules/i is in the non-retryable list in retryability.util.ts, because no retry fixes a portal setting.
This is not the app audit. Domain verification is per app and takes minutes; the audit is a separate review that decides whether posts are visible at all; see TikTok API approval.
bundle.social
Photo posts without choosing between two TikTok APIs.
Audited and domain-verified on our side, so your posts are public from day one.

Photo limits that actually reject the post
Verified 4 August 2026 against the Business API reference and our own validator. Cross-platform constraints: social media API media requirements.
| Rule | Value | Source |
|---|---|---|
| Images per post | up to 35 URLs | photo_images in /business/photo/publish/ |
| File type | JPG, JPEG, WebP; PNG is not listed | /business/photo/publish/ |
| File size | 20 MB per image | /business/photo/publish/ |
| Resolution | max 1080 × 1920 or 1920 × 1080; min 360 px on both sides | endpoint reference; picture_size_check_failed |
| Rate | 6 photo posts per minute, 15 per day per account | /business/photo/publish/ |
One row needs a caveat rather than a number.
The 360 px minimum is not in the photo endpoint's own docs, which give a maximum only. It comes from the description of picture_size_check_failed, which covers video and photo together and is not scoped to stills. Our validator does not check one.
You cannot mix photos and video
A TikTok post is a photo post or a video post. Not both. That is the shape of the API, not a style guideline: photo_images is a list of image URLs on one endpoint, video_url a single value on another, and no field combines them. Our validator branches on fileType: "image" and fileType: "video" in post.validation.service.ts and never reaches a state where the two can meet. Do the same in your composer. If a mixed selection reaches the request builder, you find out after spending a call against a rate limit measured in single digits per minute. The video side: uploading video through the TikTok API.
Polling, webhooks and failure reasons
The endpoint returns a share_id, and photo ids carry a different prefix from video ones: p_pub_url~v1.… against v_pub_url~v1.…. Pass it as publish_id, not share_id, to GET /business/publish/status/ with business_id.
The documented status enum has four values: PROCESSING_DOWNLOAD, PUBLISH_COMPLETE, FAILED and SEND_TO_USER_INBOX. Our own TikTokStatus type also carries PUBLISHING, INBOX, UNDER_REVIEW, IN_REVIEW and REJECTED, all observed or internal, not part of the published enum.
post_ids can take up to three minutes to appear. PUBLISH_COMPLETE with no post_ids is neither an error nor a finished job; keep polling. Of the four webhook events, post.publish.no_longer_publicly_available is the one teams skip and should not: a post that went public can stop being public later.
reason | Meaning | Retryable |
|---|---|---|
photo_pull_failed | Connection error fetching your image, or the download passed the one-hour timeout | Yes |
internal | TikTok-side failure | Yes |
picture_size_check_failed | Below the 360 px minimum | No |
file_format_check_failed | Format outside JPG / JPEG / WebP | No |
auth_removed | Creator revoked access mid-publish | No |
spam_risk, spam_risk_text, spam_risk_too_many_posts, spam_risk_user_banned_from_posting | Anti-spam rejection based on creator behaviour | No |
Two reasons are worth a retry, and both concern TikTok's side of the connection, not your file. Everything else is terminal: retrying picture_size_check_failed re-downloads the same too-small image, and retrying the spam_risk family makes the trigger worse. Full classification: social media API error handling.
What this looks like through one API
Through bundle.social's TikTok API a photo post is type: "IMAGE" with a list of upload ids and an optional photoCoverIndex. You do not choose between two TikTok APIs, because we already did: the host, the business_id mapping and the is_draft naming sit on our side of the line. Domain verification is ours, so there is no portal step and no grandfathered-domain surprise. Oversized images are rescaled and re-encoded to JPEG when autoScale is on rather than the post being rejected; PNG and mixed selections fail in validation with a readable message instead of at TikTok; and one publish call covers status polling and webhook references.
The image cap is TikTok's own: 35 URLs per post, the same ceiling you get integrating directly. Where this is the wrong fit: if you need the Content Posting API's MEDIA_UPLOAD inbox flow, or byte-level control over post_info, an aggregator is a layer you will work around.
Frequently asked questions
How many photos can I post in one TikTok API call?
Up to 35. The Business API takes up to 35 publicly accessible URLs in photo_images, and that is the ceiling through bundle.social as well. The cover is not an extra file: photo_cover_index points at one of those 35.
Does TikTok accept PNG images through the API?
No. The photo endpoint lists JPG, JPEG and WebP. Our validator rejects PNG on both MIME type and file extension before the request is built, because the alternative is a file_format_check_failed webhook minutes later that cannot be retried.
Can I upload photos as files instead of URLs?
Not on /business/photo/publish/. It takes photo_images, a list of URLs, with no multipart variant, and the Content Posting API reference states that photo posts support PULL_FROM_URL only. Either way TikTok fetches the bytes, which is why domain verification is mandatory.
Why did my TikTok photo post come out private?
Three candidates. The app has not passed the content audit, in which case every post lands private regardless of what you send; see TikTok API approval. Or privacy_level was not PUBLIC_TO_EVERYONE. Or is_draft was true, which routes the post to the creator's inbox.
Is the 15-per-day limit shared between photos and videos?
Not documented. Both endpoints carry the same sentence (six posts per minute, 15 per day per TikTok account), and neither states whether they share one budget. Plan capacity as if the pools are shared; the failure mode if you guess wrong is a rejected publish.