Pinterest Pin API: Creating Image and Video Pins with v5
Most Pinterest guides stop at "find your board ID". This one starts there: resolving a board by name, the four-call video path with its S3 upload parameters, all six media source types from spec 5.23.0, and why a 201 Created can still leave your Pin visible to nobody but you.
The Pinterest Pin API creates a Pin with a single POST /v5/pins call, but that call never runs alone. Every Pin belongs to a board, and board_id sits in the request body, so publishing is always at least two operations: resolve the board, then create the Pin. Video takes four. This page covers both paths, all six media source types, and why a 201 Created does not mean anyone can see your Pin.
Verified 04.08.2026 against Pinterest's OpenAPI description 5.23.0, which Pinterest publishes as a versioned repository on GitHub, and against our own production integration.

A Pin cannot exist without a board
board_id is a string matching ^\d+$. Spec 5.23.0 does not mark it required (PinCreate has no required array at all), but there is no route to a Pin without one. Treat it as mandatory.
Three situations follow. Only the first is what most guides describe.
You have the id. Send it.
You have a board name. GET /v5/boards first, match on name, read the id. Separate call, separate rate-limit bucket, and a paginated response: list boards returns a bookmark alongside the page, so an account with a few hundred boards needs a loop, not one request.
The board does not exist. Create it, then wait.
Our publish path runs all three in order: check the cached channel list, refresh it from Pinterest if the name is missing, and only then POST /v5/boards with privacy: PUBLIC. The full body that endpoint accepts is in Pinterest's create board reference. The refresh exists because the first version without it failed on accounts where the user had made the board minutes earlier. After creating one we sleep six seconds before re-reading the list: the id is in the create response immediately, but the list endpoint is not instantly consistent.
curl -s -X POST 'https://api.pinterest.com/v5/boards' \ -H "Authorization: Bearer $PINTEREST_TOKEN" \ -H 'Content-Type: application/json' \ -d '{"name": "Autumn recipes", "privacy": "PUBLIC"}'
Every call here carries the same bearer token, and Pinterest's tokens expire. Our client assumes one hour when expires_in is missing, a guess worth removing; see OAuth token refresh for social APIs.
Creating an image Pin
With an id, the image path is one call. Every field below is from PinCreate 5.23.0, and the operation itself is Pinterest's create Pin reference.
| Field | Limit / format | Required | Note |
|---|---|---|---|
board_id | string, ^\d+$ | Not per spec, yes in practice | PinCreate has no required array |
media_source | one of six shapes | Not per spec, yes in practice | Discriminated on source_type |
title | ≤ 100 chars, nullable | No | |
description | ≤ 800 chars, nullable | No | |
link | ≤ 2048 chars, nullable | No | Destination URL, not the image |
alt_text | ≤ 500 chars, nullable | No | |
dominant_color | hex, e.g. #6E7874, nullable | No | Placeholder while the image loads |
board_section_id | ^\d+$, nullable | No | |
parent_pin_id | ^\d+$, nullable | No | Set when saved from another Pin |
curl -s -X POST 'https://api.pinterest.com/v5/pins' \ -H "Authorization: Bearer $PINTEREST_TOKEN" \ -H 'Content-Type: application/json' \ -d '{ "board_id": "1234567890123456789", "title": "Roast pumpkin, four ways", "description": "The weeknight version, 40 minutes.", "link": "https://example.com/pumpkin", "alt_text": "Sliced roast pumpkin on a sheet pan", "dominant_color": "#6E7874", "media_source": { "source_type": "image_url", "url": "https://example.com/pumpkin.jpg" } }'
The response has no permalink field. Pin has seventeen properties and none links to the Pin; the string permalink appears nowhere in the 1.9 MB spec. Build it from the id: https://www.pinterest.com/pin/${pin.id}. pins/create also takes an optional ad_account_id query parameter, for Pinterest Business Access.
On maximum image size, nobody agrees. Our validator rejects images above 5 MB; one published guide states 20 MB; spec 5.23.0 gives no image size limit anywhere in PinCreate or the media source schemas. We have not established whether 5 MB is Pinterest's ceiling or ours, so do not hardcode a number from a blog post, including this one. That ambiguity is why we keep media requirements across platforms in one table.

Video Pins: register, upload, poll, publish
Video is not a different parameter. It is a different API, on two hosts, with an asynchronous state in the middle.
1. Register the upload. POST /v5/media with {"media_type": "video"}, and video is the only value the enum accepts. The response carries everything for step two, and the field names are in Pinterest's register media upload reference:
{ "media_id": "203014033110991560", "media_type": "video", "upload_url": "https://pinterest-media-upload.s3-accelerate.amazonaws.com/", "upload_parameters": { "key": "uploads/11/aa/22/3:video:203014033110991560:5212123920968240771", "policy": "eyJleHBpcmF0aW9uIjoiMj..==", "x-amz-algorithm": "AWS4-HMAC-SHA256", "x-amz-credential": "ASIA6QZJ64OPIKV7FRVX/20220127/us-east-1/s3/aws4_request", "x-amz-date": "20220127T185143Z", "x-amz-security-token": "IQoJb3JpZ2luX2VjEJr...==", "x-amz-signature": "fcd6309a...", "Content-Type": "multipart/form-data" } }
2. POST the file to S3, with every one of those parameters. This request does not go to Pinterest and carries no bearer token. upload_parameters is a presigned POST policy: each key becomes a form field, and the file goes in a field literally named file. Drop one and S3 rejects the whole thing with an error that never mentions Pinterest.
curl -s -X POST "$UPLOAD_URL" \ -F "key=$KEY" \ -F "policy=$POLICY" \ -F "x-amz-algorithm=AWS4-HMAC-SHA256" \ -F "x-amz-credential=$CREDENTIAL" \ -F "x-amz-date=$AMZ_DATE" \ -F "x-amz-security-token=$SECURITY_TOKEN" \ -F "x-amz-signature=$SIGNATURE" \ -F "[email protected]"
3. Poll GET /v5/media/{media_id} until it settles. Status is one of registered, processing, succeeded, failed, and the media status reference is the only place Pinterest states what those values are. Our budget is eight checks, 20 seconds apart, roughly two and a half minutes. failed is terminal and stops the post; running out of checks is retryable, because "no answer yet" and "answered no" deserve different handling.
4. Create the Pin with video_id. media_id plus a cover: cover_image_url, base64 via cover_image_data, or a keyframe timestamp with cover_image_key_frame_time.
"media_source": { "source_type": "video_id", "media_id": "203014033110991560", "cover_image_url": "https://example.com/cover.jpg" }
Video limits are where the documentation gets thin. The numbers in openapi.json (2 GB, 4 seconds to 15 minutes, .mp4/.mov/.m4v, 75 × 75 to 9450 × 9450 pixels) sit on the ad_video_*_link catalog attributes and in a catalog feed error example. Not on pins/create, not on any media schema. Our validator uses 2048 MB, 4 to 900 seconds, mp4 and quicktime, plus an aspect ratio band of 0.5 to 1.91 with no basis in the spec. Treat it as something to test against, not documented behaviour, and check whether the create Pin reference has grown a media schema since.
bundle.social
One request instead of four, across fifteen platforms.
Board resolution by name, the presigned S3 upload and the status poll, behind one call.

Six media source types, not two
PinMediaSource is a oneOf with a discriminator on source_type, and it has six branches.
source_type | Shape | When to use | Trap |
|---|---|---|---|
image_url | url | Anything hosted | Pinterest fetches it server-side; unreachable is a terminal failure, not a retry |
image_base64 | content_type + data | Image not publicly reachable | content_type takes only image/jpeg and image/png; data must match ^[a-zA-Z0-9+/=]+$, so strip the data:image/png;base64, prefix |
video_id | media_id + cover | Every video Pin | Needs the register/upload/poll cycle first |
multiple_image_urls | items, 2–5 entries | Carousel | minItems: 2, so a one-item array is a validation error |
multiple_image_base64 | items, 2–5 entries | Carousel without hosting | Same jpeg/png and base64 rules, per item |
pin_url | source_type only | Product Pins | Beta, restricted to a list of accounts |
The base64 prefix costs an afternoon. data:image/png;base64,iVBOR... fails the pattern on the first colon, and the error does not name the offending character.
One more gap: we send note, plus ai_disclosures for AI content. Neither appears anywhere in spec 5.23.0. Pinterest does not reject the unknown fields, but we have no evidence either is stored. The way to settle it is to read the Pin back through get Pin and see which properties survive.
Why your Pin is invisible: Trial vs Standard access
Nothing in the response looks wrong: 201 Created, a valid numeric id, a permalink you built from it, and no Pin on Pinterest.
Apps start on Trial access, and everything created there is a sandbox entity visible only to the account that made it. Not delayed, not under review. Invisible. Standard access makes created Pins real, and getting it means submitting the app for review.
| Trial access | Standard access | |
|---|---|---|
| Who sees created Pins | Only the creating account | Everyone |
org_write budget | 300 calls per day, per app | 100 calls per minute, per user, per app |
| Overall request budget | 1000 per day | 100 per second |
| How you get it | Default on app creation | Submit the app for review |
Those numbers come from Pinterest's access tier documentation, read in August 2026, not from openapi.json, which tags every operation with an x-ratelimit-category but never a number. Pinterest changes them without notice.
The tags still price a publish. POST /pins, POST /media and POST /boards are org_write; GET /boards and GET /media/{id} are org_read. One video Pin burns two org_write calls, three if the board must be created, plus up to nine org_read. At 300 a day, Trial is not many videos; see social media API rate limits.
Errors worth retrying and errors that are final
Pinterest returns {"code": <int>, "message": <string>} and publishes no dictionary of those codes. The spec's generic error example is {"code": 2, "message": "AdAccount not found."}: one example, for the whole numeric namespace. Everything below is our production classification, from observation, not a Pinterest table.
| Signal | We treat it as | Behaviour |
|---|---|---|
Codes 8, 12, 30, 2787 | Retryable | Re-queue with backoff |
Codes 2, 58, 2786 | Terminal | Fail the post, no retry |
Message already have a board with this name | Terminal | Board creation raced with itself |
Message unable to reach the url | Terminal | Your image_url is not fetchable from Pinterest |
Message authentication failed | Terminal | Token problem, retrying wastes budget |
Message matching sits alongside code matching because the same code turns up with different meanings. Splitting retryable from final is covered in social media API error handling.
What this looks like through bundle.social
Everything above is one platform. Our Pinterest API takes a boardName string, not a board_id, and the resolve-refresh-create sequence happens on our side, including the six-second wait after board creation. Video is a single request too: you hand over a file, and the registration, the presigned S3 POST, the status polling and the final POST /pins with video_id run as one durable job. Errors come back already classified retryable or terminal.
What it does not do: carousels are not exposed yet, and we cannot make your Trial-access app visible. That approval is between you and Pinterest, and no intermediary changes it. If you publish to Pinterest only, and you are comfortable owning a four-call video pipeline and a board cache, the direct API is a reasonable choice. The argument for an intermediary starts at the second platform, where the same video needs a different upload sequence for TikTok and a third for YouTube.
Frequently asked questions
Is board_id required to create a Pin?
In practice yes, though the spec does not say so. PinCreate in 5.23.0 has no required array, so nothing is formally mandatory, but there is no way to create a Pin outside a board. Resolve or create the board first, then send it as a string matching ^\d+$.
Why is my Pin not visible on Pinterest after a 201 response?
Almost always Trial access. Pins created by an app without Standard access are sandbox entities, visible only to the creating account. The API returns a real id and a real 201, which is why it reads as a bug rather than a permissions state.
How do I get the URL of a Pin I just created?
You build it. The Pin object in spec 5.23.0 has no permalink field, and the word appears nowhere in the spec. Concatenate the id yourself: https://www.pinterest.com/pin/{pin_id}. That is what we store as the permalink for every Pin we publish.
How long does Pinterest video processing take?
Pinterest publishes no figure; the media status endpoint is the only source of truth. Our budget is eight checks 20 seconds apart, about two and a half minutes, after which we treat it as retryable rather than failed. A failed status is terminal.
Can I create a carousel Pin through the Pinterest API?
Yes, with multiple_image_urls or multiple_image_base64, each taking an items array of 2 to 5 entries. Note the minimum: one item is a validation error, not a single-image Pin. bundle.social does not expose Pinterest carousels yet, so this is a direct-API call today.