Skip to content

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.

Uploading your own media is a three-step flow:

  1. Request an upload slot
  2. PUT the file bytes to the returned URL
  3. Create the Pin with the returned uploadId
  • A Tailwind account with at least one Pinterest account connected
  • Your API key
  • A local file to upload (image or video)

Call POST /v1/media/uploads with the file’s extension. Accepted extensions: jpg, jpeg, png, svg, gif, webp, heic, mp4, mov, qt.

Terminal window
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):

Terminal window
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')

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.

Terminal window
curl -X PUT "$UPLOAD_URL" \
-H "Content-Type: $CONTENT_TYPE" \
--data-binary @/path/to/my-photo.jpg

No Authorization header here — uploadUrl is itself a signed, single-purpose credential. A successful PUT returns an empty 200.

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.

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

The flow is identical — just request the upload with a video extension (mp4, mov, or qt):

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

Omit sendAt to save the Pin as a draft, exactly as with mediaUrl:

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

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.

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