Uploading Media
Pinning used to require a mediaUrl that was already publicly hosted somewhere. This guide covers the alternative: upload your own file bytes — a Canva export, a photo, a video straight off disk — directly to Tailwind’s storage, then attach them to a Pin without ever hosting them yourself.
Overview
Section titled “Overview”Uploading your own media is a three-step flow:
- Request an upload slot
- PUT the file bytes to the returned URL
- Create the Pin with the returned
uploadId
Prerequisites
Section titled “Prerequisites”- A Tailwind account with at least one Pinterest account connected
- Your API key
- A local file to upload (image or video)
Step 1: Request an upload slot
Section titled “Step 1: Request an upload slot”Call POST /v1/media/uploads with the file’s extension. Accepted extensions: jpg, jpeg, png, svg, gif, webp, heic, mp4, mov, qt.
curl -s -X POST https://api-v1.tailwind.ai/v1/media/uploads \ -H "Authorization: Bearer $TAILWIND_API_KEY" \ -H "Content-Type: application/json" \ -d '{"extension": "jpg"}'Response:
{ "data": { "upload": { "uploadId": "up_9f8c9b2e51a4", "uploadUrl": "https://pablo-uploads.example.s3.amazonaws.com/temp/week/9f8c9b2e?X-Amz-Signature=...", "uploadUrlExpiresAt": 1705315500, "expiresAt": 1705919400, "mediaType": "image", "contentType": "image/jpeg" } }, "meta": { "requestId": "req_media001" }}Save uploadId, uploadUrl, and contentType — you need all three for the next steps. Chaining with jq (see cURL examples):
RESPONSE=$(curl -s -X POST https://api-v1.tailwind.ai/v1/media/uploads \ -H "Authorization: Bearer $TAILWIND_API_KEY" \ -H "Content-Type: application/json" \ -d '{"extension": "jpg"}')
UPLOAD_ID=$(echo "$RESPONSE" | jq -r '.data.upload.uploadId')UPLOAD_URL=$(echo "$RESPONSE" | jq -r '.data.upload.uploadUrl')CONTENT_TYPE=$(echo "$RESPONSE" | jq -r '.data.upload.contentType')Step 2: PUT the file bytes
Section titled “Step 2: PUT the file bytes”PUT the raw bytes to uploadUrl. The Content-Type header must match contentType from the previous response exactly — the URL’s signature covers that header, so a different value (or a missing one) is rejected as a signature mismatch before your bytes are even inspected. This is the most common way to get this flow wrong.
curl -X PUT "$UPLOAD_URL" \ -H "Content-Type: $CONTENT_TYPE" \ --data-binary @/path/to/my-photo.jpgNo Authorization header here — uploadUrl is itself a signed, single-purpose credential. A successful PUT returns an empty 200.
Step 3: Create the Pin with the upload
Section titled “Step 3: Create the Pin with the upload”Pass uploadId instead of mediaUrl on POST /v1/accounts/{accountId}/posts. Everything else about creating a post — title, description, url, boardId, sendAt — is unchanged.
The example uses a date in 2099 so the request remains valid when copied. Replace it with the future date and time you actually want.
curl -X POST https://api-v1.tailwind.ai/v1/accounts/123456/posts \ -H "Authorization: Bearer $TAILWIND_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "uploadId": "'"$UPLOAD_ID"'", "title": "Amazing Recipe You Must Try", "description": "This delicious recipe will become your new favorite! #recipes #cooking", "url": "https://myblog.com/amazing-recipe", "boardId": "1106196864631757445", "altText": "A colorful plate of pasta with fresh vegetables", "sendAt": "2099-01-20T14:00:00Z" }'Response:
{ "data": { "post": { "id": "post_abc123", "status": "queued", "mediaUrl": "https://img.tailwindapp.net/images/a1b2/c3d4/e5f6/7890abcdef1234567890.jpg", "mediaType": "image", "title": "Amazing Recipe You Must Try", "description": "This delicious recipe will become your new favorite! #recipes #cooking", "url": "https://myblog.com/amazing-recipe", "boardId": "1106196864631757445", "altText": "A colorful plate of pasta with fresh vegetables", "isSimplifiedPin": true, "sendAt": 4072600800, "sentAt": null, "createdAt": 1705320000, "pinId": null } }, "meta": { "requestId": "req_ghi789" }}Don’t send mediaType alongside uploadId — it’s ignored. The upload’s own extension already determined its media type back in step 1, and the response’s mediaUrl now points at the promoted, durable copy rather than the temporary upload location.
mediaUrl and uploadId are mutually exclusive. Provide exactly one.
The two deadlines
Section titled “The two deadlines”The upload response carries two different expirations, and confusing them is the second most common way to get this flow wrong:
| Field | Governs | Typical window |
|---|---|---|
uploadUrlExpiresAt |
How long you have to complete the PUT in step 2 | Minutes |
expiresAt |
How long uploadId stays valid to attach to a Pin in step 3 |
A week |
uploadUrl dies long before uploadId does. Upload promptly after step 1, but you can hold on to a completed upload’s uploadId and attach it to a Pin any time before expiresAt — including well after uploadUrl itself has expired.
Uploading a video
Section titled “Uploading a video”The flow is identical — just request the upload with a video extension (mp4, mov, or qt):
curl -s -X POST https://api-v1.tailwind.ai/v1/media/uploads \ -H "Authorization: Bearer $TAILWIND_API_KEY" \ -H "Content-Type: application/json" \ -d '{"extension": "mp4"}'The response’s mediaType is "video" and contentType is the matching video MIME type (for example video/mp4). PUT the bytes and create the Pin exactly as above — again, don’t set mediaType on the create call.
Creating a draft instead of scheduling
Section titled “Creating a draft instead of scheduling”Omit sendAt to save the Pin as a draft, exactly as with mediaUrl:
curl -X POST https://api-v1.tailwind.ai/v1/accounts/123456/posts \ -H "Authorization: Bearer $TAILWIND_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "uploadId": "'"$UPLOAD_ID"'", "title": "Draft Post", "description": "I will schedule this later", "boardId": "1106196864631757445" }'Staging bytes is free — steps 1 and 2 never touch your plan credits, no matter how many uploads you create or leave unused.
Scheduling is the charged step: a create call with sendAt set, or a schedule call that moves an existing draft to queued. What it costs depends on the organization’s billing system — modular-billing accounts with Publishing access, whether subscribed or granted, spend one credit per Pin, except on winback plans, which schedule without charge; modular accounts without Publishing draw on a limited free-trial allowance; and legacy and Shopify accounts spend no credits at all, bounded by their plan’s post limit for the billing period instead. Rescheduling a Pin that is already queued — changing its sendAt, or moving it to another board — costs nothing on any of them. This matches the mediaUrl flow exactly; the upload path adds no billing of its own.
Uploads are single-use and org-scoped
Section titled “Uploads are single-use and org-scoped”An uploadId can be attached to exactly one Pin. Once a create call successfully claims it, it’s spent — reuse it and you get a 409 (see Troubleshooting). Request a new upload for every Pin.
An uploadId is also scoped to the organization that created it. It resolves for any account under that org, but never for another org’s credentials.
Troubleshooting
Section titled “Troubleshooting”| Status | Code | Cause | Fix |
|---|---|---|---|
| 400 | BAD_REQUEST |
extension in step 1 isn’t one of the accepted values |
Use one of jpg, jpeg, png, svg, gif, webp, heic, mp4, mov, qt |
| 404 | NOT_FOUND |
uploadId is unknown, or belongs to a different organization |
Request a new upload with POST /v1/media/uploads; double-check you’re using the id from your own response |
| 409 | CONFLICT |
uploadId has already been attached to a Pin |
Uploads are single-use — request a new one for each Pin |
| 410 | GONE |
uploadId outlived its expiresAt (a week) before being attached |
Request a new upload and attach it sooner |
| 422 | UNPROCESSABLE_ENTITY |
The uploaded file exceeds the size limit for its media type | Upload a smaller file |
| 422 | UNPROCESSABLE_ENTITY |
The create call ran before the PUT finished, or the PUT never completed | Confirm step 2 returned 200 before calling step 3 |
| 422 | UNPROCESSABLE_ENTITY |
Storage reported no usable size for the uploaded object, so the size check couldn’t run | Request a new upload slot and PUT the file again; if it repeats, contact support — this isn’t something your request headers control |
| 422 | UNPROCESSABLE_ENTITY |
The uploaded bytes don’t match the extension the upload was created for | Request a new upload with the extension that actually matches your file |
The four 422 rows are different situations, but they are not distinguishable programmatically: every one returns the same error.code of UNPROCESSABLE_ENTITY, and only the human-readable error.message differs. Branch on the status and code; treat the message as text for a human or a log, not as a value to match on.
Example error response:
{ "error": { "code": "CONFLICT", "message": "This upload has already been used. An uploadId can only be attached to one Pin — request a new one with POST /v1/media/uploads." }, "meta": { "requestId": "req_xyz789" }}Next steps
Section titled “Next steps”- REST reference: Create a media upload
- REST reference: Create post
- Read the scheduling guide for the rest of the create-post fields
- View cURL examples
