Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

PropertyValue
Identifierpinterest
Display namePinterest
Connection modeoauth (Authorization Code grant)
Editornormal
Channel kindsocial
Comments / threadsNo. supports_comments = false; only the first PostDetails is published and Pinterest has no pin-comment API
Pending / async publishingYes. supports_pending = true; video uploads are polled, then the pin is created behind an arm → confirm → publish handshake
AnalyticsYes. supports_analytics = true; account analytics
Post analyticsYes. supports_post_analytics = true; pin analytics
MentionsNo. supports_mentions = false
is_between_stepsfalse — no setup step; the board is chosen in each post’s settings
convert_to_jpegfalse
max_concurrent_job10 (“Pinterest limits are per user”)
Character limit (max_length)500 characters (the pin description; Pinterest itself allows 800)
API basehttps://api.pinterest.com (PINTEREST_BASE_URL)
Web basehttps://www.pinterest.com (PINTEREST_WEB_BASE_URL)
Token refreshReactive 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.

VariableRequiredDefaultPurpose
PINTEREST_CLIENT_IDYes—App ID from My apps. Doubles as the Basic-auth username for /v5/oauth/token.
PINTEREST_CLIENT_SECRETYes—App secret key. Doubles as the Basic-auth password.
PINTEREST_BASE_URLNohttps://api.pinterest.comAPI origin. Set to https://api-sandbox.pinterest.com only for sandbox testing.
PINTEREST_WEB_BASE_URLNohttps://www.pinterest.comOrigin of /oauth/ and of https://www.pinterest.com/pin/{id} release URLs.
FRONTEND_URLYes in practicehttp://localhost:5000Base of the redirect URI.
JWT_SECRETYes in practiceemptyEncrypts pending OAuth state.
RESTRICT_UPLOAD_DOMAINSNounsetGlobal 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

  1. Log in to Pinterest with a business account (or convert the account). Verify the account email.
  2. Go to https://developers.pinterest.com/apps/ and accept the Developer Terms of Service.
  3. Select Connect app, fill in the app details (name, description, use case, privacy policy URL) and submit the request for Trial access.
  4. Requests are reviewed by Pinterest (usually each business day). You receive an email when approved or denied.
  5. 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.

ScopeWhy Rustiz needs it
boards:readList boards for the board picker
boards:writeRequired by POST /v5/pins even for an existing board
pins:readRead media upload status and pin analytics
pins:writeCreate pins and register video media
user_accounts:readRead /v5/user_account after connect and before create

4. Token flow performed by Rustiz

  1. Authorize at https://www.pinterest.com/oauth/ with response_type=code.
  2. POST https://api.pinterest.com/v5/oauth/token with Authorization: Basic base64(client_id:client_secret) and Content-Type: application/x-www-form-urlencoded: grant_type=authorization_code, code, redirect_uri.
  3. Read access_token, refresh_token, expires_in, scope.
  4. GET /v5/user_account with Authorization: Bearer <token>; store id, 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

CapabilityTrial accessStandard access
Default for a new appYes, after reviewRequires an upgrade request
Pin analyticsYesYes
Reading boards/pinsYesYes
Creating boards/pinsYes, visible only to the creator (“Sandbox entities”)Yes, public according to the board
Rate limits1,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 useNot recommendedRecommended

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:

  1. The app must already be approved for Trial access and comply with the Developer Guidelines.
  2. 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.
  3. My apps → Upgrade, verify the app details and privacy policy, upload the video, submit.
  4. 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

  • customFields routes to connect_custom() and is treated as an OAuth code. Never send it to pinterest.
  • externalUrl is 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
}
FieldRequiredBehaviour
boardYesNumeric board id. The provider rejects a board name with a helpful message.
titleNoPin title, max 100 characters. Sent only when non-empty.
linkNoDestination URL. Sent only when non-empty. Must be http(s).
dominant_colorNoHex 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()

  1. board must 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”.
  2. title ≤ 100 characters (Unicode scalar count).
  3. link, when non-empty, must parse as http/https.
  4. dominant_color, when non-null, must be a string.
  5. No check on the message length here; max_length is 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:

  1. At least one media: “Requires at least one media”.
  2. At most 5 media items: “You can only have up to 5 media items”.
  3. If a video (.mp4 in 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”.
  4. 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 a video_id source without cover_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

MediaHow Rustiz sends itNotes
Single imagemedia_source: {source_type: "image_url", url}Public URL; Pinterest cURLs it
Multi-imagemedia_source: {source_type: "multiple_image_urls", items:[{url}, …]}2–5 items, equal dimensions (local check)
VideoPOST /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 file part has no explicit MIME type and no filename; S3 usually accepts it. If Pinterest rejects the upload, add a filename and video/mp4 content type.
  • The cover image is sent as cover_image_url and must be publicly reachable. Rustiz does not support cover_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_steps is false; there is no board setup step. settings.board is required on every post and is copied into PendingData.settings.board before the async flow.
  • Board list is only the first page. boards() calls GET /v5/boards?page_size=250 and never follows the bookmark. An account with more than 250 boards will not see the rest in triggerTool. Add pagination if that matters.
  • No board sections. board_section_id is 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 unless attempting=true and confirmed=true. The first finalize arms; the next check_post_status witnesses; only the following finalize sends POST /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 calls fetch_user(access_token) before setting attempting=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 stores media_id; check_post_status() polls GET /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; a RefreshToken error is propagated.
  • Image-only pins skip processing. media_id is empty, so check_post_status() goes straight to witness(). There is still an arm → confirm cycle; the first check returns Ready with confirmed=true, and the next finalize creates the pin. The blocking post() path sleeps 20 s between checks.
  • refresh_token rotation bug. Pinterest returns a new continuous refresh token on every refresh, but refresh_token() stores Some(refresh_token.to_string()) — the old token — instead of token["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 store token["refresh_token"] when present.
  • Refresh sends redirect_uri. Pinterest’s refresh grant does not require redirect_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_cron is false; the hourly sweep will not refresh Pinterest before expiry. Refresh is triggered reactively by a 401 from 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_in is stored as-is. Pinterest returns 2592000 (30 days). The engine uses this for tokenExpiration; it does not add a safety margin.
  • Analytics clamp to 90 days. analytics() clamps days to 0..=89 and sends start_date/end_date as YYYY-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 when PIN_CLICK_RATE is present in that day’s metrics, so sparse accounts get gaps.
  • Post analytics ignore from_date. post_analytics() always requests the last 89 days with metric_types=IMPRESSION,PIN_CLICK,OUTBOUND_CLICK,SAVE and reads all.summary_metrics. It emits one point per metric dated today; percentage change is 0.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. The message is sent verbatim as description; no HTML stripping is applied (the editor is Normal).
  • No HTML. Editor::Normal means 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_validity even 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 bare 401 triggers 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_write 300/day. Standard: 100 requests/second per user per app, org_write 100/min, org_read 1,000/min. Responses include x-ratelimit-* headers; the provider does not read them or back off. max_concurrent_job = 10 is 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