Pinterest (pinterest)
Pinterest uses API v5 at api.pinterest.com with OAuth 2.0. Rustiz
publishes image pins, multi-image pins and video pins to a board the user
selects per post (the board is a post setting, not a channel setting).
Video pins are uploaded to Pinterest’s S3 upload URL first, then attached by
media_id with a cover image.
- Rust source:
crates/rustiz-providers/src/pinterest.rs - Upstream port:
pinterest.provider.ts - Editor: Normal
- Connection mode:
oauth - Related provider: none
Overview
| Property | Value |
|---|---|
| Identifier | pinterest |
| Display name | |
| Connection mode | oauth (Authorization Code grant) |
| Editor | normal |
| Channel kind | social |
| Comments / threads | No. supports_comments = false; only the first PostDetails is published and Pinterest has no pin-comment API |
| Pending / async publishing | Yes. supports_pending = true; video uploads are polled, then the pin is created behind an arm → confirm → publish handshake |
| Analytics | Yes. supports_analytics = true; account analytics |
| Post analytics | Yes. supports_post_analytics = true; pin analytics |
| Mentions | No. supports_mentions = false |
is_between_steps | false — no setup step; the board is chosen in each post’s settings |
convert_to_jpeg | false |
max_concurrent_job | 10 (“Pinterest limits are per user”) |
Character limit (max_length) | 500 characters (the pin description; Pinterest itself allows 800) |
| API base | https://api.pinterest.com (PINTEREST_BASE_URL) |
| Web base | https://www.pinterest.com (PINTEREST_WEB_BASE_URL) |
| Token refresh | Reactive on 401; refresh_cron is false |
Server Environment Variables
required_env() returns exactly:
#![allow(unused)]
fn main() {
fn required_env(&self) -> &'static [&'static str] {
&["PINTEREST_CLIENT_ID", "PINTEREST_CLIENT_SECRET"]
}
}
required_env_alternatives() is not overridden.
| Variable | Required | Default | Purpose |
|---|---|---|---|
PINTEREST_CLIENT_ID | Yes | — | App ID from My apps. Doubles as the Basic-auth username for /v5/oauth/token. |
PINTEREST_CLIENT_SECRET | Yes | — | App secret key. Doubles as the Basic-auth password. |
PINTEREST_BASE_URL | No | https://api.pinterest.com | API origin. Set to https://api-sandbox.pinterest.com only for sandbox testing. |
PINTEREST_WEB_BASE_URL | No | https://www.pinterest.com | Origin of /oauth/ and of https://www.pinterest.com/pin/{id} release URLs. |
FRONTEND_URL | Yes in practice | http://localhost:5000 | Base of the redirect URI. |
JWT_SECRET | Yes in practice | empty | Encrypts pending OAuth state. |
RESTRICT_UPLOAD_DOMAINS | No | unset | Global upload guard. Pinterest fetches image URLs; video bytes are uploaded directly by Rustiz. |
rustiz providers --status checks only PINTEREST_CLIENT_ID and
PINTEREST_CLIENT_SECRET. It cannot tell whether the app is approved for
Trial or Standard access, which changes rate limits and pin visibility.
Step-by-Step Developer App Setup
1. Create a Pinterest business account and app
- Log in to Pinterest with a business account (or convert the account). Verify the account email.
- Go to https://developers.pinterest.com/apps/ and accept the Developer Terms of Service.
- Select Connect app, fill in the app details (name, description, use case, privacy policy URL) and submit the request for Trial access.
- Requests are reviewed by Pinterest (usually each business day). You receive an email when approved or denied.
- Once approved, open My apps → Manage → Configure and copy the App ID and App secret key.
App name rules: at least three Latin letters or numbers, and it must not contain the word “Pinterest”.
2. Redirect URI (exact)
Pinterest::redirect_uri() is:
#![allow(unused)]
fn main() {
format!("{}/integrations/social/pinterest", self.ctx.config.frontend_url())
}
Examples:
https://social.example.com/integrations/social/pinterest # production
http://localhost:5000/integrations/social/pinterest # Docker / rustiz serve
http://localhost:4200/integrations/social/pinterest # upstream Postiz dev UI
There is no redirectmeto.com wrapper and no trailing slash. Pinterest
accepts multiple redirect URIs and requires the redirect_uri sent to the
authorize and token endpoints to match exactly one registered value.
3. Scopes requested and checked
Pinterest::scopes() returns:
#![allow(unused)]
fn main() {
vec![
"boards:read",
"boards:write",
"pins:read",
"pins:write",
"user_accounts:read",
]
}
The authorize URL joins them with commas:
https://www.pinterest.com/oauth/
?client_id={PINTEREST_CLIENT_ID}
&redirect_uri={redirect_uri}
&response_type=code
&scope=boards:read,boards:write,pins:read,pins:write,user_accounts:read
&state={state}
After the token exchange, authenticate() calls:
#![allow(unused)]
fn main() {
check_scopes(&self.info.scopes, token["scope"].as_str().unwrap_or_default())
}
All five must be granted. check_scopes() splits the returned scope string
on comma or space and requires exact matches.
| Scope | Why Rustiz needs it |
|---|---|
boards:read | List boards for the board picker |
boards:write | Required by POST /v5/pins even for an existing board |
pins:read | Read media upload status and pin analytics |
pins:write | Create pins and register video media |
user_accounts:read | Read /v5/user_account after connect and before create |
4. Token flow performed by Rustiz
- Authorize at
https://www.pinterest.com/oauth/withresponse_type=code. POST https://api.pinterest.com/v5/oauth/tokenwithAuthorization: Basic base64(client_id:client_secret)andContent-Type: application/x-www-form-urlencoded:grant_type=authorization_code,code,redirect_uri.- Read
access_token,refresh_token,expires_in,scope. GET /v5/user_accountwithAuthorization: Bearer <token>; storeid,username,profile_image, token and refresh token.
Pinterest access tokens last about 30 days; the continuous refresh token
lasts 60 days and is refreshable indefinitely. The token response includes
refresh_token_expires_in/refresh_token_expires_at, but Rustiz only stores
the generic expiry.
5. Trial access vs Standard access
| Capability | Trial access | Standard access |
|---|---|---|
| Default for a new app | Yes, after review | Requires an upgrade request |
| Pin analytics | Yes | Yes |
| Reading boards/pins | Yes | Yes |
| Creating boards/pins | Yes, visible only to the creator (“Sandbox entities”) | Yes, public according to the board |
| Rate limits | 1,000 requests/day per app; category daily limits (e.g. org_write 300/day) | 100 requests/second per user per app; category per-minute limits (e.g. org_write 100/min) |
| Production use | Not recommended | Recommended |
The Trial trap: a pin created with Trial access is visible only to the account that created it. It looks like a successful post in Rustiz but nobody else can see it. If your posts “work” but never appear publicly, check the app’s access tier before debugging the provider.
Upgrade to Standard:
- The app must already be approved for Trial access and comply with the Developer Guidelines.
- Prepare a video recording of the app completing the OAuth flow and a real pin creation. Terminal/Postman recordings are accepted if you are the only user.
- My apps → Upgrade, verify the app details and privacy policy, upload the video, submit.
- Standard requests are reviewed regularly; you receive an email decision.
Common denial reasons: privacy policy not publicly accessible, vague app description, demo does not show the OAuth flow, demo does not show a live Pinterest integration.
6. Pinterest Sandbox
Pinterest has a real sandbox at api-sandbox.pinterest.com:
- Sandbox tokens are separate from production tokens. You can generate a
30-day sandbox token on My apps → Manage → Configure → Generate Access
Token → Sandbox, or run the OAuth flow against
https://api-sandbox.pinterest.com/v5/oauth/token. - Sandbox entities (boards/pins/ad accounts) are separate from production.
- Video pins are not supported in Sandbox.
- The sandbox supports pins, boards, media, user accounts, analytics and ads endpoints listed in Pinterest’s Sandbox docs.
Rustiz has no built-in sandbox switch. You can set
PINTEREST_BASE_URL=https://api-sandbox.pinterest.com, but the OAuth flow then
also uses the sandbox host while PINTEREST_WEB_BASE_URL remains the production
login page. The clean way to test is to obtain a sandbox token out-of-band and
use a test deployment; the provider itself expects a production OAuth token.
For most self-hosted installs, Trial access against production with a test
account is simpler.
7. App review / production checklist
- Trial access is itself reviewed; there is no fully self-serve production access.
- Standard access requires the video demo described above.
- A privacy policy hosted on a domain associated with the app is mandatory.
- Pinterest participates in GitHub secret scanning: a leaked token is revoked within 24 hours and the account must be reconnected.
Connecting a Channel (CLI & MCP)
CLI
rustiz channels connect pinterest
rustiz channels connect pinterest --open
rustiz channels connect pinterest --timeout 600
The login URL goes to stderr. With a local FRONTEND_URL the CLI serves the
callback and waits; with a public HTTPS FRONTEND_URL it prints the URL and
exits.
is_between_steps is false, so the channel is complete after the callback.
There is no channelSetupTool step; board selection happens per post.
MCP connectChannelTool
{ "provider": "pinterest" }
Response:
{
"connected": false,
"pending": true,
"connectionId": "…",
"url": "https://www.pinterest.com/oauth/?client_id=…&redirect_uri=…&response_type=code&scope=boards:read,boards:write,pins:read,pins:write,user_accounts:read&state=…",
"message": "A human must open this URL and log in. Then poll with connectionId or check integrationList."
}
Poll with { "connectionId": "…" }. integrationSchema reports oauth,
headless: false, requiresHuman: true, requiresExternalUrl: false.
Listing boards: triggerTool
After the channel is connected, list boards:
{
"integrationId": "<pinterest channel id>",
"methodName": "boards",
"dataSchema": []
}
pinterest exposes one function:
{
"name": "boards",
"description": "List of boards",
"input_schema": { "type": "object", "properties": {} }
}
Result:
[
{ "name": "Weeknight dinners", "id": "987654321012345678" },
{ "name": "Studio", "id": "123456789012345678" }
]
The id is numeric and is what settings.board expects. Pinterest board ids
are snowflake-like 18–19 digit strings; keep them as strings to avoid
JavaScript precision loss.
Then publish with settings.board set to that id.
What not to send
customFieldsroutes toconnect_custom()and is treated as an OAuth code. Never send it topinterest.externalUrlis unused.
Post Settings & Media Validation
settings_schema()
{
"type": "object",
"properties": {
"board": {
"type": "string",
"description": "The numeric id of the board (from the boards list of the channel), not the board name"
},
"title": {
"type": "string",
"maxLength": 100,
"description": "Optional pin title, at most 100 characters."
},
"link": {
"type": "string",
"description": "Optional destination URL the pin links to."
},
"dominant_color": {
"type": "string",
"description": "Optional dominant color of the pin (hex color)."
}
},
"required": ["board"],
"additionalProperties": true
}
| Field | Required | Behaviour |
|---|---|---|
board | Yes | Numeric board id. The provider rejects a board name with a helpful message. |
title | No | Pin title, max 100 characters. Sent only when non-empty. |
link | No | Destination URL. Sent only when non-empty. Must be http(s). |
dominant_color | No | Hex colour string, sent only when non-empty. No hex validation. |
Not exposed: board_section_id, alt_text, per-item carousel titles/links,
parent_pin_id, is_removable, AI disclosures, and cover_image_key_frame_time.
The description is the post message.
validate_settings()
boardmust be present, non-empty, and all ASCII digits. A board name fails with “Board must be the numeric board id (use the boards list of the channel to find it), not the board name”.title≤ 100 characters (Unicode scalar count).link, when non-empty, must parse ashttp/https.dominant_color, when non-null, must be a string.- No check on the message length here;
max_lengthis 500.
Pinterest’s own limits are: title 100, description 800, link 2,048,
alt text 500. Rustiz caps the description at 500, so a valid 600-character
Pinterest description is rejected locally. Raise max_length() if you need the
full 800.
check_validity()
The validator is opinionated about video and multi-image pins:
- At least one media: “Requires at least one media”.
- At most 5 media items: “You can only have up to 5 media items”.
- If a video (
.mp4in the path) is present:- the set must include a non-video item, otherwise “If posting a video you have to also include a cover image as second media”;
- more than 2 items is rejected: “If posting a video you can only have two media items”.
- If there is more than one item and all are images, every image must have
exactly the same width and height:
“Requires all images to have the same width and height”. Dimensions are read
with
MediaContext::image_dimensions, which may read local files or fetch remote URLs.
Two-video edge case: the first video check only fails when there is no non-video item and the count is not 2. Two videos (no image) pass the validity check, then
post_pending()finds no cover image, sends avideo_idsource withoutcover_image_url, and Pinterest returns 400. Always include exactly one image cover with a video.
Rustiz does not validate:
- image format (Pinterest accepts BMP/JPEG/PNG/TIFF/WebP up to 20 MB);
- recommended 2:3 ratio / 1000×1500;
- video duration (Pinterest organic video is commonly 4 s–15 min, some docs say 5 min), codec, or 2 GB size;
- title/description against Pinterest’s actual API limits beyond the local checks above.
Media requirements
| Media | How Rustiz sends it | Notes |
|---|---|---|
| Single image | media_source: {source_type: "image_url", url} | Public URL; Pinterest cURLs it |
| Multi-image | media_source: {source_type: "multiple_image_urls", items:[{url}, …]} | 2–5 items, equal dimensions (local check) |
| Video | POST /v5/media {media_type:"video"} → POST the returned upload_url as multipart with the returned upload_parameters plus a file part → poll /v5/media/{id} until succeeded → POST /v5/pins with media_source: {source_type:"video_id", media_id, cover_image_url} | Requires an image cover as a second media item. The file is streamed from Rustiz, unlike images |
- Pinterest image pins accept BMP, JPEG, PNG, TIFF and WebP up to 20 MB; 2:3 (1000×1500) is the recommended ratio.
- Organic video pins: MP4/MOV/M4V, H.264/H.265, roughly 4 s–15 min, up to 2 GB; 9:16 or 1:1/2:3/4:5 recommended.
- The video upload
filepart has no explicit MIME type and no filename; S3 usually accepts it. If Pinterest rejects the upload, add a filename andvideo/mp4content type. - The cover image is sent as
cover_image_urland must be publicly reachable. Rustiz does not supportcover_image_key_frame_time, so a video without a cover image cannot be published.
Threads / comments
supports_comments = false. Only the first PostDetails is published. A
multi-entry post does not create multiple pins, and Pinterest has no
comment-creation API. Extra entries are ignored.
Plugs
No @Plug or @PostPlug methods. run_plug() / run_post_plug() return
ProviderError::Unsupported. The boards helper is a dashboard function, not
a plug.
Implementation Notes & Gotchas
- The board is per post, not per channel.
is_between_stepsisfalse; there is no board setup step.settings.boardis required on every post and is copied intoPendingData.settings.boardbefore the async flow. - Board list is only the first page.
boards()callsGET /v5/boards?page_size=250and never follows thebookmark. An account with more than 250 boards will not see the rest intriggerTool. Add pagination if that matters. - No board sections.
board_section_idis never sent. Pins always go to the board root. - Arm → confirm → publish. Pinterest has no idempotency key, so
finalize_post()refuses to create a pin unlessattempting=trueandconfirmed=true. The firstfinalizearms; the nextcheck_post_statuswitnesses; only the followingfinalizesendsPOST /v5/pins. If a run dies after the create, the next check returns “Pinterest may have already published this pin” rather than risk a duplicate. - Token probe before arming. For image-only pins there is no API call
between arming and the create, so an expired token would 401 at the create
and the retry would see an armed state and report a false duplicate.
finalize_post()therefore callsfetch_user(access_token)before settingattempting=true. A refresh can happen at that point and the armed state is not yet persisted. - Video flow is separate from the pin flow.
post_pending()uploads the video immediately and storesmedia_id;check_post_status()pollsGET /v5/media/{id}:failed→ “The file is corrupted and cannot be uploaded” (bad body);- not
succeeded→ Pending; succeeded→ witness and Ready. A transient status-read error is ignored and retried; aRefreshTokenerror is propagated.
- Image-only pins skip processing.
media_idis empty, socheck_post_status()goes straight towitness(). There is still an arm → confirm cycle; the first check returns Ready withconfirmed=true, and the next finalize creates the pin. The blockingpost()path sleeps 20 s between checks. refresh_tokenrotation bug. Pinterest returns a new continuous refresh token on every refresh, butrefresh_token()storesSome(refresh_token.to_string())— the old token — instead oftoken["refresh_token"]. The new access token works, but the rotated refresh token is discarded. Once the old refresh token expires (or is invalidated by rotation), the next refresh fails and the channel must be reconnected. The fix is to storetoken["refresh_token"]when present.- Refresh sends
redirect_uri. Pinterest’s refresh grant does not requireredirect_uri, but Rustiz includes it (and the full scope list). Pinterest currently ignores the extra field; if a future version rejects unknown parameters, remove it. - No proactive refresh.
refresh_cronisfalse; the hourly sweep will not refresh Pinterest before expiry. Refresh is triggered reactively by a401from any call, through the shared HTTP layer. If a channel is idle for more than 30 days, the first post after the gap triggers the refresh. expires_inis stored as-is. Pinterest returns2592000(30 days). The engine uses this fortokenExpiration; it does not add a safety margin.- Analytics clamp to 90 days.
analytics()clampsdaysto0..=89and sendsstart_date/end_dateasYYYY-MM-DD. Pinterest only serves the last 90 days. It builds five series: Pin click rate, Impressions, Pin Clicks, Engagement, Saves. A day is only emitted whenPIN_CLICK_RATEis present in that day’s metrics, so sparse accounts get gaps. - Post analytics ignore
from_date.post_analytics()always requests the last 89 days withmetric_types=IMPRESSION,PIN_CLICK,OUTBOUND_CLICK,SAVEand readsall.summary_metrics. It emits one point per metric dated today; percentage change is0.0. Errors are logged and return[]. - Description vs
max_length. The engine caps the post body at 500, below Pinterest’s 800-character description limit. Themessageis sent verbatim asdescription; no HTML stripping is applied (the editor is Normal). - No HTML.
Editor::Normalmeans the engine sends plain text. Pinterest does not render HTML. - Video cover must be a separate media. There is no setting for a cover;
the second media item is used. A video pin with 3+ media is rejected by
check_validityeven though the API could ignore the extras. - Error mapping.
constraint: maxItems=5→ max 5 images; “Something went wrong on our end” and “Unable to reach the URL” → retry; board-id regex failure → “board ID must be a numeric string”; “Board not found” → check the id; “You are not permitted to access that resource” → the account cannot write to that board; “cover_image_url or cover_image_content_type” → a video needs a cover image. A bare401triggers the generic refresh path. - Trial pin invisibility. The most confusing failure mode: Trial access creates pins visible only to the creator. Rustiz returns success because the API returned success. Check the app tier and the logged-in account before investigating the provider.
- Rate limits. Trial: 1,000 requests/day per app,
org_write300/day. Standard: 100 requests/second per user per app,org_write100/min,org_read1,000/min. Responses includex-ratelimit-*headers; the provider does not read them or back off.max_concurrent_job = 10is still enough to hit the trial daily cap quickly when each post costs a board read, an upload and a create. - Sandbox has no video pins. A sandbox test of the video flow cannot succeed; test video against production with a Trial/Standard app.
- Secret scanning. Pinterest revokes leaked tokens found by GitHub secret
scanning within 24 hours. Do not commit
.env; rotate the app secret from My apps → Configure → Reset app secret if exposed. Existing access tokens keep working after a secret reset, but new tokens cannot be minted with the old secret.
References
- Pinterest developer apps: https://developers.pinterest.com/apps/
- Connect app / trial access: https://developers.pinterest.com/docs/getting-started/connect-app/
- Access tiers (Trial vs Standard): https://developers.pinterest.com/docs/key-concepts/access-tiers/
- Authentication and scopes: https://developers.pinterest.com/docs/getting-started/set-up-authentication-and-authorization/
- Sandbox: https://developers.pinterest.com/docs/developer-tools/sandbox/
- Create pin: https://developers.pinterest.com/docs/api/v5/pins-create/
- Create boards and pins (video flow): https://developers.pinterest.com/docs/work-with-organic-content-and-users/create-boards-and-pins/
- Rate limits: https://developers.pinterest.com/docs/reference/rate-limits/
- Pin specs: https://help.pinterest.com/en/article/review-pin-specs
- Postiz Pinterest setup: https://docs.postiz.com/providers/pinterest
- Postiz Pinterest settings: https://docs.postiz.com/public-api/providers/pinterest