# PinBridge documentation (full) > PinBridge is a hosted Pinterest publishing API: managed OAuth, queueing, retries, status tracking, and signed webhooks. Use it through the REST API, the n8n community node, the MCP server, or the Python SDK. ================================================================================ # PinBridge docs & quickstart URL: https://www.pinbridge.io/docs/ ================================================================================ # Ship your first pin in five minutes Authenticate, connect Pinterest, publish. Retries and webhooks are handled for you. - **Base URL:** `https://api.pinbridge.io/v1` - **Authentication:** send your key in the `X-API-Key` header on every request. - **Idempotency:** pass an `idempotency_key` so a retried publish is safe by default. ## Quickstart ### 1. Authenticate Create an API key in your dashboard, then send it as `X-API-Key` on every call. Each key is scoped to a single project (production or sandbox) and revokable from the dashboard. See the [authentication guide](/docs/authentication/) for tokens, API keys, and password flows. ```bash # Verify your key by listing connected accounts curl https://api.pinbridge.io/v1/pinterest/accounts \ -H "X-API-Key: $PINBRIDGE_API_KEY" ``` ### 2. Connect a Pinterest account Send the user through managed OAuth. PinBridge stores and rotates their tokens. You never touch Pinterest credentials. ```bash # Returns a Pinterest consent URL to redirect the user to curl https://api.pinbridge.io/v1/pinterest/oauth/start \ -H "X-API-Key: $PINBRIDGE_API_KEY" ``` ### 3. Publish a pin POST a pin. PinBridge publishes it asynchronously with per-account rate handling and automatic retries. `account_id`, `board_id`, `title`, `idempotency_key`, and exactly one of `image_url` / `asset_id` are required. **curl** ```bash curl -X POST https://api.pinbridge.io/v1/pins \ -H "X-API-Key: $PINBRIDGE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "account_id": "6f1c2e4a-3b7d-4c9e-8a21-5d0f9b3e7c41", "board_id": "987654321098765432", "image_url": "https://cdn.shop/img/1.jpg", "title": "Summer styles 2026", "idempotency_key": "launch-123" }' ``` **Python SDK** ```python from pinbridge_sdk import PinbridgeClient from pinbridge_sdk.models import PinCreate with PinbridgeClient(api_key="$PINBRIDGE_API_KEY") as client: pin = client.pins.create( PinCreate( account_id="6f1c2e4a-3b7d-4c9e-8a21-5d0f9b3e7c41", board_id="987654321098765432", image_url="https://cdn.shop/img/1.jpg", title="Summer styles 2026", idempotency_key="launch-123", # required ) ) print(pin.status) # queued ``` Publishing from n8n instead? See [PinBridge for n8n](/docs/n8n/). ### 4. Get notified with webhooks Register a webhook once. PinBridge sends signed `pin.published` and `pin.failed` events. Delivery retries with exponential backoff until your endpoint returns a 2xx. The event type travels in the `X-PinBridge-Event` header, and the signature is in `X-PinBridge-Signature`. ```json // pin.published body { "pin_id": "8a3f6c12-4d5e-4b7a-9c01-2e6f8d4b7a93", "pinterest_pin_id": "1234567890", "title": "Summer styles 2026", "published_at": "2026-09-11T12:00:00Z" } ``` ## Explore the docs - **[Getting started](/docs/authentication/)** — authenticate, connect Pinterest, and publish your first pin. - **[PinBridge for n8n](/docs/n8n/)** — install the community node and build publishing flows, no code required. - **[Guides](/docs/guides/)** — task-focused walkthroughs for publishing, scheduling, and bulk imports. - **[MCP](/docs/mcp/)** — publish from Claude, ChatGPT, Codex, and any MCP client, plus the usage policy. - **[Python SDK](/docs/python-sdk/)** — a typed client with sync and async support. - **[Limits & billing](/docs/billing/)** — plans, quota, credits, and [rate limits](/docs/rate-limits/). - **[Reference](/docs/api/)** — the full interactive OpenAPI reference for every endpoint. - **[Troubleshooting](/docs/troubleshooting/)** — fixes for auth, publish, and delivery errors. ## The endpoints you will use most **API keys** - `POST /v1/api-keys` — create a key - `GET /v1/api-keys` — list keys - `DELETE /v1/api-keys/{key_id}` — revoke a key **Pinterest** - `GET /v1/pinterest/oauth/start` — begin OAuth - `GET /v1/pinterest/accounts` — list connected accounts - `GET /v1/pinterest/boards` — list boards **Pins** - `POST /v1/pins` — publish a pin - `GET /v1/pins/{pin_id}` — retrieve a pin (check its status) - `GET /v1/pins` — list pins - `POST /v1/pins/{pin_id}/retry` — retry a failed pin **Assets & imports** - `POST /v1/assets/images` — upload an image, then publish by `asset_id` - `POST /v1/assets/videos` — upload a video - `POST /v1/pins/imports/json` — bulk-import pins from JSON - `POST /v1/pins/imports/csv` — bulk-import pins from CSV - `GET /v1/pins/imports/{job_id}` — check an import job **Schedules** - `POST /v1/schedules` — schedule a pin - `POST /v1/schedules/{schedule_id}/cancel` — cancel a schedule - `GET /v1/schedules` — list schedules **Webhooks & usage** - `POST /v1/webhooks` — register a webhook - `GET /v1/webhooks` — list webhooks - `GET /v1/rate-meter` — live rate-limit status Open the [full interactive API reference](/docs/api/) for every endpoint. ## Call it however you build - **Python SDK** — an official, typed client. `pip install pinbridge-sdk`. See the [Python SDK guide](/docs/python-sdk/). - **n8n nodes** — install the `n8n-nodes-pinbridge` community node and drop PinBridge into any n8n canvas next to your existing HTTP Request and IF nodes. See [PinBridge for n8n](/docs/n8n/). - **MCP server** — let Claude, ChatGPT, Codex, or any agentic workflow publish pins through the Model Context Protocol. See the [MCP setup guide](/docs/mcp/setup/). ## Grab a key. Start building. Playground is free forever. Enough headroom to wire publishing end to end before you spend a cent. See [pricing](/pricing/) when you are ready to scale. ================================================================================ # API reference URL: https://www.pinbridge.io/docs/api/ ================================================================================ # API reference Generated from the OpenAPI spec (v1.65.1). The interactive version is at [api.pinbridge.io/docs](https://api.pinbridge.io/docs). All endpoints are under `https://api.pinbridge.io`. Authenticate with `X-API-Key: $PINBRIDGE_API_KEY`, except the Auth, Projects, Team, and Email Preferences sections, which require a user session token (`Authorization: Bearer $PINBRIDGE_JWT`). See [Authentication](/docs/authentication/). ## Sections - [API Keys](/docs/api/api-keys/) — 4 endpoints - [Activity Logs](/docs/api/activity-logs/) — 1 endpoint - [Analytics](/docs/api/analytics/) — 3 endpoints - [Assets](/docs/api/assets/) — 7 endpoints - [Auth](/docs/api/auth/) — 12 endpoints - [Billing](/docs/api/billing/) — 1 endpoint - [Dashboard](/docs/api/dashboard/) — 1 endpoint - [Email Preferences](/docs/api/email-preferences/) — 3 endpoints - [Jobs](/docs/api/jobs/) — 1 endpoint - [Pins](/docs/api/pins/) — 15 endpoints - [Pinterest](/docs/api/pinterest/) — 11 endpoints - [Projects](/docs/api/projects/) — 4 endpoints - [Rate Limiting](/docs/api/rate-limiting/) — 1 endpoint - [Schedules](/docs/api/schedules/) — 11 endpoints - [Team](/docs/api/team/) — 9 endpoints - [Webhooks](/docs/api/webhooks/) — 5 endpoints ================================================================================ # Activity Logs API URL: https://www.pinbridge.io/docs/api/activity-logs/ ================================================================================ # Activity Logs API ### `GET /v1/activity-logs` **List Activity Logs** List activity logs for the currently authenticated workspace. Auth: `ApiKeyAuth`, `BearerAuth` **Parameters** | Name | In | Required | Type | Description | |---|---|---|---|---| | `limit` | query | no | integer | | | `cursor` | query | no | string? | | | `category` | query | no | ActivityLogCategory? | | | `action` | query | no | string? | | | `status` | query | no | ActivityLogStatus? | | | `resource_type` | query | no | string? | | | `since` | query | no | string? | | **Response `200`** (`ActivityLogListResponse`) ```bash curl -X GET "https://api.pinbridge.io/v1/activity-logs" \ -H "X-API-Key: $PINBRIDGE_API_KEY" ``` --- ================================================================================ # Analytics API URL: https://www.pinbridge.io/docs/api/analytics/ ================================================================================ # Analytics API ### `POST /v1/analytics/accounts/{account_id}/collect` **Collect Account Analytics** Collect an account's analytics now instead of waiting for the nightly run. This is a user action, so PinBridge refreshes the account's Pinterest token if needed (the nightly run never does) and queues a sync. A paused account resumes, and any missed days (up to 90) are filled in. Limited to once per account every 15 minutes. Auth: `ApiKeyAuth`, `BearerAuth` **Parameters** | Name | In | Required | Type | Description | |---|---|---|---|---| | `account_id` | path | yes | string | | **Response `202`** (`AnalyticsCollectResponse`) ```bash curl -X POST "https://api.pinbridge.io/v1/analytics/accounts/$ACCOUNT_ID/collect" \ -H "X-API-Key: $PINBRIDGE_API_KEY" ``` --- ### `GET /v1/analytics/overview` **Get Analytics Overview** Account-level Pinterest metrics summed across connected accounts, from stored history. Returns totals, the same-length previous period's totals (for deltas), a daily series, and each account's collection status. Ranges can span up to 366 days and reach back past Pinterest's own 90-day window. Never calls Pinterest. Auth: `ApiKeyAuth`, `BearerAuth` **Parameters** | Name | In | Required | Type | Description | |---|---|---|---|---| | `start_date` | query | no | string? | Inclusive start (YYYY-MM-DD); default 30 days ago | | `end_date` | query | no | string? | Inclusive end (YYYY-MM-DD); default today | | `account_id` | query | no | string? | Limit to one Pinterest account; default every connected account | **Response `200`** (`AnalyticsOverviewResponse`) ```bash curl -X GET "https://api.pinbridge.io/v1/analytics/overview" \ -H "X-API-Key: $PINBRIDGE_API_KEY" ``` --- ### `GET /v1/analytics/pins` **List Pin Analytics** Published pins ranked by a metric summed over the range, from stored history. Only pins with at least one non-zero stored day in the range are listed. Never calls Pinterest. Auth: `ApiKeyAuth`, `BearerAuth` **Parameters** | Name | In | Required | Type | Description | |---|---|---|---|---| | `start_date` | query | no | string? | Inclusive start (YYYY-MM-DD); default 30 days ago | | `end_date` | query | no | string? | Inclusive end (YYYY-MM-DD); default today | | `account_id` | query | no | string? | Limit to one Pinterest account; default every connected account | | `sort_by` | query | no | enum(impression, save, pin_click, outbound_click, total_comments, total_reactions) | Metric to rank pins by | | `limit` | query | no | integer | | | `offset` | query | no | integer | | **Response `200`** (`TopPinsResponse`) ```bash curl -X GET "https://api.pinbridge.io/v1/analytics/pins" \ -H "X-API-Key: $PINBRIDGE_API_KEY" ``` --- ================================================================================ # API Keys API URL: https://www.pinbridge.io/docs/api/api-keys/ ================================================================================ # API Keys API ### `GET /v1/api-keys` **List Api Keys** List all API keys for the workspace. Auth: `ApiKeyAuth`, `BearerAuth` **Response `200`** ```bash curl -X GET "https://api.pinbridge.io/v1/api-keys" \ -H "X-API-Key: $PINBRIDGE_API_KEY" ``` --- ### `POST /v1/api-keys` **Create Api Key** Create a new API key. Returns the API key once - save it, it won't be shown again. Keys can be restricted to a subset of scopes (`read`, `write`, `destructive`) and to specific Pinterest accounts; an unrestricted key gets every scope. Auth: `ApiKeyAuth`, `BearerAuth` **Request body** (`APIKeyCreate`) | Field | Type | Required | Description | |---|---|---|---| | `name` | string | yes | Human-readable name for this API key (max 255) | | `scopes` | array? | no | Scopes granted to this key. `read` covers list/get endpoints, `write` covers creating and updating pins, schedules, boards, assets and webhooks, and `destructive` covers deletes and revocations. Omit to grant every scope. | | `pinterest_account_ids` | array? | no | Optional allow-list of Pinterest account IDs this key may act on. Omit or send null to allow every account in the workspace. | **Response `201`** (`APIKeyCreateResponse`) ```bash curl -X POST "https://api.pinbridge.io/v1/api-keys" \ -H "X-API-Key: $PINBRIDGE_API_KEY" \ -H "Content-Type: application/json" \ -d '{"name": "Production key"}' ``` --- ### `DELETE /v1/api-keys/{key_id}` **Revoke Api Key** Revoke an API key. Auth: `ApiKeyAuth`, `BearerAuth` **Parameters** | Name | In | Required | Type | Description | |---|---|---|---|---| | `key_id` | path | yes | string | | **Response `204`** ```bash curl -X DELETE "https://api.pinbridge.io/v1/api-keys/$KEY_ID" \ -H "X-API-Key: $PINBRIDGE_API_KEY" ``` --- ### `PATCH /v1/api-keys/{key_id}` **Update Api Key** Update API key name, scopes, or Pinterest account allow-list. Auth: `ApiKeyAuth`, `BearerAuth` **Parameters** | Name | In | Required | Type | Description | |---|---|---|---|---| | `key_id` | path | yes | string | | **Request body** (`APIKeyUpdate`) | Field | Type | Required | Description | |---|---|---|---| | `name` | string? | no | Human-readable name for this API key | | `scopes` | array? | no | Scopes granted to this key. `read` covers list/get endpoints, `write` covers creating and updating pins, schedules, boards, assets and webhooks, and `destructive` covers deletes and revocations. Omit to grant every scope. | | `pinterest_account_ids` | array? | no | Optional allow-list of Pinterest account IDs this key may act on. Omit or send null to allow every account in the workspace. | **Response `200`** (`APIKeyResponse`) ```bash curl -X PATCH "https://api.pinbridge.io/v1/api-keys/$KEY_ID" \ -H "X-API-Key: $PINBRIDGE_API_KEY" \ -H "Content-Type: application/json" \ -d '{"name": "Production key", "scopes": ["read", "write"]}' ``` --- ================================================================================ # Assets API URL: https://www.pinbridge.io/docs/api/assets/ ================================================================================ # Assets API ### `DELETE /v1/assets` **Bulk Delete Assets** Bulk delete assets. Returns split of deleted vs those requiring confirmation. Auth: `ApiKeyAuth`, `BearerAuth` **Request body** (`BulkDeleteRequest`) | Field | Type | Required | Description | |---|---|---|---| | `asset_ids` | array | yes | | | `confirm` | boolean | no | | **Response `200`** (`BulkDeleteResponse`) ```bash curl -X DELETE "https://api.pinbridge.io/v1/assets" \ -H "X-API-Key: $PINBRIDGE_API_KEY" \ -H "Content-Type: application/json" \ -d '{"asset_ids": ["3fa85f64-5717-4562-b3fc-2c963f66afa6"]}' ``` --- ### `GET /v1/assets` **List Assets** List all assets for the organization, with optional filters. `total` in the body (and the `X-Total-Count` header) counts the assets matching the filters and search, ignoring `limit` and `offset`. Auth: `ApiKeyAuth`, `BearerAuth` **Parameters** | Name | In | Required | Type | Description | |---|---|---|---|---| | `workspace_id` | query | no | string? | | | `sort` | query | no | enum(created_at_desc, created_at_asc, name_asc, name_desc, size_desc, size_asc) | Sort order | | `limit` | query | no | integer | | | `offset` | query | no | integer | | | `q` | query | no | string? | Case-insensitive search on the original file name | | `asset_type` | query | no | AssetType? | Only images or videos | | `in_use` | query | no | boolean? | true: only assets used by a pin or a pending scheduled pin; false: only unused assets | | `since` | query | no | string? | Only assets uploaded at or after this ISO 8601 timestamp | | `until` | query | no | string? | Only assets uploaded before this ISO 8601 timestamp | **Response `200`** (`AssetListResponse`) ```bash curl -X GET "https://api.pinbridge.io/v1/assets" \ -H "X-API-Key: $PINBRIDGE_API_KEY" ``` --- ### `POST /v1/assets/images` **Upload Image Asset** Upload an image asset for later pin publishing. Auth: `ApiKeyAuth`, `BearerAuth` **Request body**: `multipart/form-data` with a `file` field. **Response `201`** (`AssetResponse`) ```bash curl -X POST "https://api.pinbridge.io/v1/assets/images" \ -H "X-API-Key: $PINBRIDGE_API_KEY" \ -F "file=@/path/to/file" ``` --- ### `POST /v1/assets/videos` **Upload Video Asset** Upload a video asset for later pin publishing. Auth: `ApiKeyAuth`, `BearerAuth` **Request body**: `multipart/form-data` with a `file` field. **Response `201`** (`AssetResponse`) ```bash curl -X POST "https://api.pinbridge.io/v1/assets/videos" \ -H "X-API-Key: $PINBRIDGE_API_KEY" \ -F "file=@/path/to/file" ``` --- ### `DELETE /v1/assets/{asset_id}` **Delete Asset** Delete an asset. Returns requires_confirmation=True while it is in use. In use means referenced by a pin or by a scheduled pin that has not run yet; `referenced_pin_count` counts both. With `confirm=true` the asset is deleted anyway: pins keep their record without it, and pending schedules lose it, so an unpublished pin or schedule that used it fails to publish. Auth: `ApiKeyAuth`, `BearerAuth` **Parameters** | Name | In | Required | Type | Description | |---|---|---|---|---| | `asset_id` | path | yes | string | | | `confirm` | query | no | boolean | | **Response `200`** (`AssetDeleteResponse`) ```bash curl -X DELETE "https://api.pinbridge.io/v1/assets/$ASSET_ID" \ -H "X-API-Key: $PINBRIDGE_API_KEY" ``` --- ### `GET /v1/assets/{asset_id}` **Get Asset** Return metadata for an accessible asset. Auth: `ApiKeyAuth`, `BearerAuth` **Parameters** | Name | In | Required | Type | Description | |---|---|---|---|---| | `asset_id` | path | yes | string | | **Response `200`** (`AssetResponse`) ```bash curl -X GET "https://api.pinbridge.io/v1/assets/$ASSET_ID" \ -H "X-API-Key: $PINBRIDGE_API_KEY" ``` --- ### `GET /v1/assets/{asset_id}/content` **Get Asset Content** Serve the uploaded asset content. **Parameters** | Name | In | Required | Type | Description | |---|---|---|---|---| | `asset_id` | path | yes | string | | **Response `200`** ```bash curl -X GET "https://api.pinbridge.io/v1/assets/$ASSET_ID/content" ``` --- ================================================================================ # Auth API URL: https://www.pinbridge.io/docs/api/auth/ ================================================================================ # Auth API ### `POST /v1/auth/change-password` **Change Password** Change password for an authenticated user without invalidating the current session. Auth: `BearerAuth` with a user session token (JWT). API keys are rejected. **Request body** (`ChangePasswordRequest`) | Field | Type | Required | Description | |---|---|---|---| | `current_password` | string | yes | (max 255, min 8) | | `new_password` | string | yes | (max 255, min 8) | **Response `200`** (`PasswordResetActionResponse`) ```bash curl -X POST "https://api.pinbridge.io/v1/auth/change-password" \ -H "Authorization: Bearer $PINBRIDGE_JWT" \ -H "Content-Type: application/json" \ -d '{"current_password": "correct-horse-battery-staple", "new_password": "a-new-long-passphrase"}' ``` --- ### `GET /v1/auth/email/change/confirm` **Confirm Primary Email Change** Consume an old-email approval link and switch the primary email. **Parameters** | Name | In | Required | Type | Description | |---|---|---|---|---| | `token` | query | yes | string | | **Response `200`** (`PrimaryEmailChangeActionResponse`) ```bash curl -X GET "https://api.pinbridge.io/v1/auth/email/change/confirm?token=$TOKEN" ``` --- ### `POST /v1/auth/email/change/request` **Request Primary Email Change** Send an old-email approval link for changing the primary login email. Auth: `BearerAuth` with a user session token (JWT). API keys are rejected. **Request body** (`PrimaryEmailChangeRequest`) | Field | Type | Required | Description | |---|---|---|---| | `new_email` | string | yes | | **Response `200`** (`PrimaryEmailChangeRequestResponse`) ```bash curl -X POST "https://api.pinbridge.io/v1/auth/email/change/request" \ -H "Authorization: Bearer $PINBRIDGE_JWT" \ -H "Content-Type: application/json" \ -d '{"new_email": "jane@example.com"}' ``` --- ### `GET /v1/auth/email/verify` **Verify Email** Consume an email verification token. **Parameters** | Name | In | Required | Type | Description | |---|---|---|---|---| | `token` | query | yes | string | | **Response `200`** (`EmailVerificationActionResponse`) ```bash curl -X GET "https://api.pinbridge.io/v1/auth/email/verify?token=$TOKEN" ``` --- ### `POST /v1/auth/email/verify/request` **Request Email Verification** Send a fresh verification email for the authenticated user. Auth: `BearerAuth` with a user session token (JWT). API keys are rejected. **Response `200`** (`EmailVerificationActionResponse`) ```bash curl -X POST "https://api.pinbridge.io/v1/auth/email/verify/request" \ -H "Authorization: Bearer $PINBRIDGE_JWT" ``` --- ### `POST /v1/auth/forgot-password` **Forgot Password** Issue a short-lived password reset token and send reset email. **Request body** (`ForgotPasswordRequest`) | Field | Type | Required | Description | |---|---|---|---| | `email` | string | yes | | **Response `200`** (`PasswordResetActionResponse`) ```bash curl -X POST "https://api.pinbridge.io/v1/auth/forgot-password" \ -H "Content-Type: application/json" \ -d '{"email": "jane@example.com"}' ``` --- ### `POST /v1/auth/login` **Login** Authenticate with email/password and return bearer token. **Request body** (`LoginRequest`) | Field | Type | Required | Description | |---|---|---|---| | `email` | string | yes | | | `password` | string | yes | (max 255, min 8) | | `timezone` | string? | no | Browser IANA timezone used for first-login timezone backfill | | `invite_token` | string? | no | Optional organization invitation token to accept during login. | **Response `200`** (`AuthResponse`) ```bash curl -X POST "https://api.pinbridge.io/v1/auth/login" \ -H "Content-Type: application/json" \ -d '{"email": "jane@example.com", "password": "correct-horse-battery-staple"}' ``` --- ### `GET /v1/auth/me` **Me** Return current user and workspace for JWT-authenticated sessions. Auth: `BearerAuth` with a user session token (JWT). API keys are rejected. **Response `200`** (`MeResponse`) ```bash curl -X GET "https://api.pinbridge.io/v1/auth/me" \ -H "Authorization: Bearer $PINBRIDGE_JWT" ``` --- ### `GET /v1/auth/profile` **Get Profile** Get billing/invoice profile fields for the current authenticated user. Auth: `BearerAuth` with a user session token (JWT). API keys are rejected. **Response `200`** (`ProfileResponse`) ```bash curl -X GET "https://api.pinbridge.io/v1/auth/profile" \ -H "Authorization: Bearer $PINBRIDGE_JWT" ``` --- ### `PUT /v1/auth/profile` **Update Profile** Update billing/invoice profile fields for the current authenticated user. Auth: `BearerAuth` with a user session token (JWT). API keys are rejected. **Request body** (`ProfileUpdateRequest`) | Field | Type | Required | Description | |---|---|---|---| | `full_name` | string? | no | | | `workspace_name` | string? | no | | | `company_name` | string? | no | | | `company_website` | string? | no | | | `billing_email` | string? | no | | | `billing_phone` | string? | no | | | `tax_id` | string? | no | | | `address_line1` | string? | no | | | `address_line2` | string? | no | | | `address_city` | string? | no | | | `address_state` | string? | no | | | `address_postal_code` | string? | no | | | `address_country` | string? | no | | **Response `200`** (`ProfileResponse`) ```bash curl -X PUT "https://api.pinbridge.io/v1/auth/profile" \ -H "Authorization: Bearer $PINBRIDGE_JWT" \ -H "Content-Type: application/json" \ -d '{"full_name": "Jane Doe"}' ``` --- ### `POST /v1/auth/register` **Register** Register a new user and create production project for their organization. **Request body** (`RegisterRequest`) | Field | Type | Required | Description | |---|---|---|---| | `full_name` | string | yes | (max 255, min 1) | | `email` | string | yes | | | `password` | string | yes | (max 255, min 8) | | `workspace_name` | string? | no | | | `timezone` | string? | no | Default IANA timezone for the user/workspace (e.g. America/New_York) | | `next_path` | string? | no | Optional relative path to preserve through signup and email verification flows. | | `marketing_attribution` | RegisterMarketingAttribution? | no | Optional marketing attribution metadata captured at signup. | | `invite_token` | string? | no | Optional organization invitation token to accept during registration. | **Response `201`** (`AuthResponse`) ```bash curl -X POST "https://api.pinbridge.io/v1/auth/register" \ -H "Content-Type: application/json" \ -d '{"full_name": "Jane Doe", "email": "jane@example.com", "password": "correct-horse-battery-staple"}' ``` --- ### `POST /v1/auth/reset-password` **Reset Password** Reset a user's password from a valid single-use token. **Request body** (`ResetPasswordRequest`) | Field | Type | Required | Description | |---|---|---|---| | `token` | string | yes | (max 512, min 20) | | `password` | string | yes | (max 255, min 8) | **Response `200`** (`PasswordResetActionResponse`) ```bash curl -X POST "https://api.pinbridge.io/v1/auth/reset-password" \ -H "Content-Type: application/json" \ -d '{"token": "token-from-the-email-link", "password": "correct-horse-battery-staple"}' ``` --- ================================================================================ # Billing API URL: https://www.pinbridge.io/docs/api/billing/ ================================================================================ # Billing API ### `GET /v1/billing/status` **Get Billing Status** Current plan, billing status and quota usage for the workspace. `calls_used` counts the pins published successfully this month (calendar month, UTC) against `quota_calls_monthly`, and `quota_reset_at` is when it resets. Only successful new pins count, scheduled pins included when they publish; reads, failures and retries don't. Once the quota is used up, credits are spent one per pin if `credits_enabled`; with no quota or credits left, publishing returns `402 quota_exceeded` (or `credits_exhausted`) while every other endpoint keeps working. It also reports usage against the other plan limits (Pinterest accounts, API keys, weekly AI assistant requests) and, below the top self-serve plan, the next plan up with its `upgrade_url`. The `plan` value `pro` is the Agency plan (`plan_name`). Works with any API key that has the read scope; a dashboard session needs a role that can manage billing. Auth: `ApiKeyAuth`, `BearerAuth` **Response `200`** (`BillingStatusResponse`) ```bash curl -X GET "https://api.pinbridge.io/v1/billing/status" \ -H "X-API-Key: $PINBRIDGE_API_KEY" ``` --- ================================================================================ # Dashboard API URL: https://www.pinbridge.io/docs/api/dashboard/ ================================================================================ # Dashboard API ### `GET /v1/dashboard/summary` **Get Dashboard Summary** Publishing activity for a time range: pin outcomes, an hourly or daily series, published pins per account, the current queue, schedules and import jobs. Outcomes count by when they happened: a pin is published in the period its `published_at` falls in, and failed in the period it failed in (while still failed). `total` counts the pins submitted in the period. The range can span up to 366 days; ranges up to 48 hours get an hourly series. The same figures for the preceding period of equal length come back as `previous_pins`, for deltas. Auth: `ApiKeyAuth`, `BearerAuth` **Parameters** | Name | In | Required | Type | Description | |---|---|---|---|---| | `start` | query | no | string? | Range start, inclusive (ISO 8601). Without an offset it is read in `tz`. Default: 30 days before `end`. | | `end` | query | no | string? | Range end, exclusive (ISO 8601). Default: now. | | `tz` | query | no | string | IANA time zone for the series buckets and offset-less timestamps | | `account_id` | query | no | string? | Limit to one Pinterest account; default every account | **Response `200`** (`DashboardSummaryResponse`) ```bash curl -X GET "https://api.pinbridge.io/v1/dashboard/summary" \ -H "X-API-Key: $PINBRIDGE_API_KEY" ``` --- ================================================================================ # Email Preferences API URL: https://www.pinbridge.io/docs/api/email-preferences/ ================================================================================ # Email Preferences API ### `GET /v1/email/preferences` **Get Preferences** Return current email preferences for the authenticated user and workspace. Auth: `BearerAuth` with a user session token (JWT). API keys are rejected. **Response `200`** (`EmailPreferencesResponse`) ```bash curl -X GET "https://api.pinbridge.io/v1/email/preferences" \ -H "Authorization: Bearer $PINBRIDGE_JWT" ``` --- ### `PATCH /v1/email/preferences` **Update Preferences** Update user-scoped email preferences. Auth: `BearerAuth` with a user session token (JWT). API keys are rejected. **Request body** (`EmailPreferencesUpdateRequest`) | Field | Type | Required | Description | |---|---|---|---| | `transactional_enabled` | boolean | yes | | | `alerts_enabled` | boolean | yes | | | `verification_enabled` | boolean | yes | | **Response `200`** (`EmailPreferencesResponse`) ```bash curl -X PATCH "https://api.pinbridge.io/v1/email/preferences" \ -H "Authorization: Bearer $PINBRIDGE_JWT" \ -H "Content-Type: application/json" \ -d '{"transactional_enabled": true, "alerts_enabled": true, "verification_enabled": true}' ``` --- ### `GET /v1/email/unsubscribe` **Unsubscribe** Public endpoint: validate token and disable emails for a user. **Parameters** | Name | In | Required | Type | Description | |---|---|---|---|---| | `workspace_id` | query | yes | string | | | `token` | query | yes | string | | | `user_id` | query | no | string? | | **Response `200`** ```bash curl -X GET "https://api.pinbridge.io/v1/email/unsubscribe?workspace_id=$WORKSPACE_ID&token=$TOKEN" ``` --- ================================================================================ # Jobs API URL: https://www.pinbridge.io/docs/api/jobs/ ================================================================================ # Jobs API ### `GET /v1/jobs/{job_id}` **Get Job Status** Get job status by ID (alias of pin status). Auth: `ApiKeyAuth`, `BearerAuth` **Parameters** | Name | In | Required | Type | Description | |---|---|---|---|---| | `job_id` | path | yes | string | | **Response `200`** (`JobStatusResponse`) ```bash curl -X GET "https://api.pinbridge.io/v1/jobs/$JOB_ID" \ -H "X-API-Key: $PINBRIDGE_API_KEY" ``` --- ================================================================================ # Pins API URL: https://www.pinbridge.io/docs/api/pins/ ================================================================================ # Pins API ### `GET /v1/pins` **List Pins** List pins for the workspace, newest first by default, with optional filters. The `X-Total-Count` response header carries the number of pins matching the filters and search, so clients can page with `limit` and `offset`. Auth: `ApiKeyAuth`, `BearerAuth` **Parameters** | Name | In | Required | Type | Description | |---|---|---|---|---| | `limit` | query | no | integer | | | `offset` | query | no | integer | | | `q` | query | no | string? | Case-insensitive search across title, description and link URL | | `sort` | query | no | enum(created_at_desc, created_at_asc, published_at_desc, published_at_asc, title_asc, title_desc, status_asc, status_desc) | Sort order | | `account_id` | query | no | string? | Only pins for this Pinterest account | | `board_id` | query | no | string? | Only pins targeting this board | | `status` | query | no | PinStatus? | Only pins in this status | | `error_code` | query | no | string? | Only failed pins with this error code | | `removed` | query | no | boolean? | true: only published pins that no longer exist on Pinterest; false: exclude them | | `since` | query | no | string? | Only pins created at or after this ISO 8601 timestamp | | `until` | query | no | string? | Only pins created before this ISO 8601 timestamp | **Response `200`** ```bash curl -X GET "https://api.pinbridge.io/v1/pins" \ -H "X-API-Key: $PINBRIDGE_API_KEY" ``` --- ### `POST /v1/pins` **Create Pin** Create and publish a pin. The pin is queued for publishing and processed asynchronously. Use idempotency_key to prevent duplicate pins. Before the pin is accepted PinBridge checks that the connected account can publish to the board. Failures return `422` with a stable `error.code` (`board_not_found`, `board_not_owned`, `board_deleted`, `scope_missing`, `token_expired`, `token_revoked`, `board_access_denied`, ...) and a remediation hint. Every response carries `X-PinBridge-Quota-*` and `X-Pinterest-RateLimit-*` headers describing the remaining headroom. To check a request without publishing, use `POST /v1/pins/validate`. Auth: `ApiKeyAuth`, `BearerAuth` **Request body** (`PinCreate`) | Field | Type | Required | Description | |---|---|---|---| | `account_id` | string | yes | Pinterest account ID to publish with | | `board_id` | string | yes | Pinterest board ID | | `title` | string | yes | Pin title (max 100) | | `description` | string? | no | Pin description | | `alt_text` | string? | no | Alternative text used for accessibility | | `dominant_color` | string? | no | Dominant hex color of the pin media (for example #6E7874) | | `cover_image_url` | string? | no | Optional public cover image URL for video pins | | `cover_image_asset_id` | string? | no | Optional uploaded image asset ID used as a video cover image | | `link_url` | string? | no | Destination URL for the pin | | `image_url` | string? | no | Public URL of the image to pin | | `asset_id` | string? | no | Uploaded PinBridge asset ID to use for the pin image or video | | `idempotency_key` | string | yes | Idempotency key to prevent duplicate pins (max 255) | **Response `201`** (`PinResponse`) ```bash curl -X POST "https://api.pinbridge.io/v1/pins" \ -H "X-API-Key: $PINBRIDGE_API_KEY" \ -H "Content-Type: application/json" \ -d '{"account_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "board_id": "987654321098765432", "title": "Autumn table setting", "idempotency_key": "pin-2026-001", "image_url": "https://example.com/image.jpg"}' ``` --- ### `POST /v1/pins/batch` **Create Pins Batch** Create and publish several pins in one call. Each entry is processed independently and reported with its own outcome (`created`, `existing` for an idempotency-key hit, or `failed` with an error envelope). Requires the bulk publishing plan feature and enough monthly quota for every entry that would be created. Quota headers describe the workspace; Pinterest rate headers are only included when every entry targets one account. Auth: `ApiKeyAuth`, `BearerAuth` **Request body** (`PinBatchRequest`) | Field | Type | Required | Description | |---|---|---|---| | `pins` | array | yes | Pins to create, in order | **Response `200`** (`PinBatchResponse`) ```bash curl -X POST "https://api.pinbridge.io/v1/pins/batch" \ -H "X-API-Key: $PINBRIDGE_API_KEY" \ -H "Content-Type: application/json" \ -d '{"pins": [{"account_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "board_id": "987654321098765432", "title": "Autumn table setting", "idempotency_key": "pin-2026-001", "image_url": "https://example.com/image.jpg"}]}' ``` --- ### `POST /v1/pins/bulk-delete` **Bulk Delete Pins** Soft-delete multiple pins in one call (or hard-delete in sandbox). Per-item outcomes: * succeeded — pin was deleted. * failed (error_code=not_found) — pin not found in workspace or already deleted. Auth: `ApiKeyAuth`, `BearerAuth` **Request body** (`BulkIdsRequest`) | Field | Type | Required | Description | |---|---|---|---| | `ids` | array | yes | List of resource IDs to act on (up to 500 per request). | **Response `200`** (`BulkOperationResponse`) ```bash curl -X POST "https://api.pinbridge.io/v1/pins/bulk-delete" \ -H "X-API-Key: $PINBRIDGE_API_KEY" \ -H "Content-Type: application/json" \ -d '{"ids": ["3fa85f64-5717-4562-b3fc-2c963f66afa6"]}' ``` --- ### `POST /v1/pins/bulk-retry` **Bulk Retry Pins** Retry multiple failed pins in one call. Per-item outcomes: * succeeded — pin was reset to QUEUED and publish task enqueued. * skipped (error_code=ineligible_status) — pin status is not FAILED. * failed (error_code=not_found) — pin not found in workspace or deleted. Auth: `ApiKeyAuth`, `BearerAuth` **Request body** (`BulkIdsRequest`) | Field | Type | Required | Description | |---|---|---|---| | `ids` | array | yes | List of resource IDs to act on (up to 500 per request). | **Response `200`** (`BulkOperationResponse`) ```bash curl -X POST "https://api.pinbridge.io/v1/pins/bulk-retry" \ -H "X-API-Key: $PINBRIDGE_API_KEY" \ -H "Content-Type: application/json" \ -d '{"ids": ["3fa85f64-5717-4562-b3fc-2c963f66afa6"]}' ``` --- ### `GET /v1/pins/imports` **List Import Jobs** List bulk import jobs for a workspace. Auth: `ApiKeyAuth`, `BearerAuth` **Parameters** | Name | In | Required | Type | Description | |---|---|---|---|---| | `limit` | query | no | integer | | | `offset` | query | no | integer | | | `status` | query | no | ImportJobStatus? | Filter import jobs by processing status | | `source_type` | query | no | ImportSourceType? | Filter import jobs by source type | **Response `200`** ```bash curl -X GET "https://api.pinbridge.io/v1/pins/imports" \ -H "X-API-Key: $PINBRIDGE_API_KEY" ``` --- ### `POST /v1/pins/imports/csv` **Create Csv Import Job** Queue a bulk pin import from an uploaded CSV file. Auth: `ApiKeyAuth`, `BearerAuth` **Request body**: `multipart/form-data` with a `file` field. **Response `202`** (`ImportJobResponse`) ```bash curl -X POST "https://api.pinbridge.io/v1/pins/imports/csv" \ -H "X-API-Key: $PINBRIDGE_API_KEY" \ -F "file=@/path/to/file" ``` --- ### `POST /v1/pins/imports/json` **Create Json Import Job** Queue a bulk pin import from a JSON array. Auth: `ApiKeyAuth`, `BearerAuth` **Request body** (`BulkPinImportJsonRequest`) _See schema._ **Response `202`** (`ImportJobResponse`) ```bash curl -X POST "https://api.pinbridge.io/v1/pins/imports/json" \ -H "X-API-Key: $PINBRIDGE_API_KEY" \ -H "Content-Type: application/json" \ -d '[{"account_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "board_id": "987654321098765432", "title": "Autumn table setting", "image_url": "https://example.com/image.jpg", "idempotency_key": "import-2026-001"}]' ``` --- ### `GET /v1/pins/imports/{job_id}` **Get Import Job** Get a bulk import job and row results. Auth: `ApiKeyAuth`, `BearerAuth` **Parameters** | Name | In | Required | Type | Description | |---|---|---|---|---| | `job_id` | path | yes | string | | **Response `200`** (`ImportJobResponse`) ```bash curl -X GET "https://api.pinbridge.io/v1/pins/imports/$JOB_ID" \ -H "X-API-Key: $PINBRIDGE_API_KEY" ``` --- ### `POST /v1/pins/validate` **Validate Pin** Dry-run a pin request. Runs the full acceptance path — account, media, cover image, board access, idempotency, billing, quota and Pinterest rate headroom — and reports each step. Returns the exact payload that would be published. Nothing is queued. Auth: `ApiKeyAuth`, `BearerAuth` **Request body** (`PinCreate`) | Field | Type | Required | Description | |---|---|---|---| | `account_id` | string | yes | Pinterest account ID to publish with | | `board_id` | string | yes | Pinterest board ID | | `title` | string | yes | Pin title (max 100) | | `description` | string? | no | Pin description | | `alt_text` | string? | no | Alternative text used for accessibility | | `dominant_color` | string? | no | Dominant hex color of the pin media (for example #6E7874) | | `cover_image_url` | string? | no | Optional public cover image URL for video pins | | `cover_image_asset_id` | string? | no | Optional uploaded image asset ID used as a video cover image | | `link_url` | string? | no | Destination URL for the pin | | `image_url` | string? | no | Public URL of the image to pin | | `asset_id` | string? | no | Uploaded PinBridge asset ID to use for the pin image or video | | `idempotency_key` | string | yes | Idempotency key to prevent duplicate pins (max 255) | **Response `200`** (`PinValidationResponse`) ```bash curl -X POST "https://api.pinbridge.io/v1/pins/validate" \ -H "X-API-Key: $PINBRIDGE_API_KEY" \ -H "Content-Type: application/json" \ -d '{"account_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "board_id": "987654321098765432", "title": "Autumn table setting", "idempotency_key": "pin-2026-001", "image_url": "https://example.com/image.jpg"}' ``` --- ### `DELETE /v1/pins/{pin_id}` **Delete Pin** Delete a pin. By default this removes the PinBridge record only and returns `204`. Pass `delete_from_pinterest=true` to also delete the published pin on Pinterest, the proportionate fix for one wrong pin (deleting the board is not); that variant returns `200` with `removed_from_pinterest` so the outcome is visible. Auth: `ApiKeyAuth`, `BearerAuth` **Parameters** | Name | In | Required | Type | Description | |---|---|---|---|---| | `pin_id` | path | yes | string | | | `delete_from_pinterest` | query | no | boolean | Also remove the published pin from Pinterest. Without this flag only the PinBridge record is deleted and the pin stays live on Pinterest. | **Response `204`** ```bash curl -X DELETE "https://api.pinbridge.io/v1/pins/$PIN_ID" \ -H "X-API-Key: $PINBRIDGE_API_KEY" ``` --- ### `GET /v1/pins/{pin_id}` **Get Pin** Get pin status and details. Auth: `ApiKeyAuth`, `BearerAuth` **Parameters** | Name | In | Required | Type | Description | |---|---|---|---|---| | `pin_id` | path | yes | string | | **Response `200`** (`PinResponse`) ```bash curl -X GET "https://api.pinbridge.io/v1/pins/$PIN_ID" \ -H "X-API-Key: $PINBRIDGE_API_KEY" ``` --- ### `PATCH /v1/pins/{pin_id}` **Update Pin** Edit a pin's title, description, link, alt text, or board before it publishes. Queued, deferred and failed pins are edited in place and publish with the new values. A published pin cannot be edited: Pinterest's API does not let PinBridge change a pin once it is live, so the call returns `409 pin_already_published`; delete the pin and publish a corrected one instead. Pins currently publishing return `409 pin_publishing`. Both refusals are recorded in the activity log as `pin.update_rejected`. In a simulated sandbox `board_id` cannot change. Auth: `ApiKeyAuth`, `BearerAuth` **Parameters** | Name | In | Required | Type | Description | |---|---|---|---|---| | `pin_id` | path | yes | string | | **Request body** (`PinUpdate`) | Field | Type | Required | Description | |---|---|---|---| | `title` | string? | no | Pin title | | `description` | string? | no | Pin description | | `link_url` | string? | no | Destination URL for the pin | | `alt_text` | string? | no | Alternative text used for accessibility | | `board_id` | string? | no | Move the pin to another board | **Response `200`** (`PinResponse`) ```bash curl -X PATCH "https://api.pinbridge.io/v1/pins/$PIN_ID" \ -H "X-API-Key: $PINBRIDGE_API_KEY" \ -H "Content-Type: application/json" \ -d '{"title": "Autumn table setting", "alt_text": "A dinner table set with candles and autumn leaves"}' ``` --- ### `GET /v1/pins/{pin_id}/analytics` **Get Pin Analytics** Pin-level analytics (impressions, saves, clicks, outbound clicks) over a date range. PinBridge copies every published pin's analytics nightly, so most reads are answered from that stored history (`source: stored` in the response, with `data_as_of`) without calling Pinterest. Live ranges are capped at 90 days, stored ranges at 366. Sandbox-simulated pins return zeroed metrics. A pin deleted on Pinterest (`removed_from_pinterest_at` set) is answered from the stored history. Without stored history it fails with `409 pin_removed_on_pinterest`, which is also what a live read returns when Pinterest no longer has the pin. Auth: `ApiKeyAuth`, `BearerAuth` **Parameters** | Name | In | Required | Type | Description | |---|---|---|---|---| | `pin_id` | path | yes | string | | | `start_date` | query | no | string? | Inclusive start (YYYY-MM-DD); default 30 days ago | | `end_date` | query | no | string? | Inclusive end (YYYY-MM-DD); default today | | `metrics` | query | no | string? | Comma-separated Pinterest metric types, e.g. IMPRESSION,SAVE,PIN_CLICK,OUTBOUND_CLICK. Defaults to the organic engagement set. | | `source` | query | no | enum(auto, stored, live) | auto (default): PinBridge's stored history when it covers the range, else Pinterest. stored: stored history only (up to 366 days, beyond Pinterest's 90). live: always ask Pinterest (counts against the read limit). | | `include_daily` | query | no | boolean | false: return only the range totals, with an empty daily list. Use it when the totals are all you need, e.g. over 90 days. | **Response `200`** (`PinAnalyticsResponse`) ```bash curl -X GET "https://api.pinbridge.io/v1/pins/$PIN_ID/analytics" \ -H "X-API-Key: $PINBRIDGE_API_KEY" ``` --- ### `POST /v1/pins/{pin_id}/retry` **Retry Failed Pin** Retry a failed pin. Re-queues a pin that previously failed for publishing. Optionally override the board or Pinterest account to fix the root cause of the failure before retrying. The target board is preflighted the same way as on creation. Auth: `ApiKeyAuth`, `BearerAuth` **Parameters** | Name | In | Required | Type | Description | |---|---|---|---|---| | `pin_id` | path | yes | string | | **Request body** _See schema._ **Response `200`** (`PinResponse`) ```bash curl -X POST "https://api.pinbridge.io/v1/pins/$PIN_ID/retry" \ -H "X-API-Key: $PINBRIDGE_API_KEY" \ -H "Content-Type: application/json" \ -d '{}' ``` --- ================================================================================ # Pinterest API URL: https://www.pinbridge.io/docs/api/pinterest/ ================================================================================ # Pinterest API ### `GET /v1/pinterest/accounts` **List Pinterest Accounts** List connected Pinterest accounts (limited to the API key's allow-list, if any). Auth: `ApiKeyAuth`, `BearerAuth` **Response `200`** ```bash curl -X GET "https://api.pinbridge.io/v1/pinterest/accounts" \ -H "X-API-Key: $PINBRIDGE_API_KEY" ``` --- ### `DELETE /v1/pinterest/accounts/{account_id}` **Revoke Pinterest Account** Revoke a Pinterest account connection. Auth: `ApiKeyAuth`, `BearerAuth` **Parameters** | Name | In | Required | Type | Description | |---|---|---|---|---| | `account_id` | path | yes | string | | **Response `204`** ```bash curl -X DELETE "https://api.pinbridge.io/v1/pinterest/accounts/$ACCOUNT_ID" \ -H "X-API-Key: $PINBRIDGE_API_KEY" ``` --- ### `GET /v1/pinterest/accounts/{account_id}/analytics` **Get Account Analytics** Account-level analytics (impressions, engagement, saves, clicks) over a date range. PinBridge copies every connected account's analytics nightly, so most reads are answered from that stored history (`source: stored` in the response, with `data_as_of`) without calling Pinterest. Live ranges are capped at 90 days, stored ranges at 366. Sandbox-simulated workspaces return zeroed metrics. Auth: `ApiKeyAuth`, `BearerAuth` **Parameters** | Name | In | Required | Type | Description | |---|---|---|---|---| | `account_id` | path | yes | string | | | `start_date` | query | no | string? | Inclusive start (YYYY-MM-DD); default 30 days ago | | `end_date` | query | no | string? | Inclusive end (YYYY-MM-DD); default today | | `metrics` | query | no | string? | Comma-separated Pinterest metric types, e.g. IMPRESSION,ENGAGEMENT,SAVE,PIN_CLICK,OUTBOUND_CLICK. Defaults to the organic engagement set. | | `source` | query | no | enum(auto, stored, live) | auto (default): PinBridge's stored history when it covers the range, else Pinterest. stored: stored history only (up to 366 days, beyond Pinterest's 90). live: always ask Pinterest (counts against the read limit). | | `include_daily` | query | no | boolean | false: return only the range totals, with an empty daily list. Use it when the totals are all you need, e.g. over 90 days. | **Response `200`** (`AccountAnalyticsResponse`) ```bash curl -X GET "https://api.pinbridge.io/v1/pinterest/accounts/$ACCOUNT_ID/analytics" \ -H "X-API-Key: $PINBRIDGE_API_KEY" ``` --- ### `GET /v1/pinterest/boards` **List Boards** List boards for a Pinterest account. Returns a synthetic local board in sandbox simulation mode, otherwise proxies Pinterest. Auth: `ApiKeyAuth`, `BearerAuth` **Parameters** | Name | In | Required | Type | Description | |---|---|---|---|---| | `account_id` | query | yes | string | Pinterest account ID | **Response `200`** ```bash curl -X GET "https://api.pinbridge.io/v1/pinterest/boards?account_id=$ACCOUNT_ID" \ -H "X-API-Key: $PINBRIDGE_API_KEY" ``` --- ### `POST /v1/pinterest/boards` **Create Board** Create a board for a connected Pinterest account. `SECRET` needs Pinterest's `boards:write_secret` scope. Connections made before API 1.37 lack it and get `409 scope_missing` until they reconnect. Auth: `ApiKeyAuth`, `BearerAuth` **Request body** (`BoardCreateRequest`) | Field | Type | Required | Description | |---|---|---|---| | `account_id` | string | yes | Pinterest account ID | | `name` | string | yes | Board name (max 180, min 1) | | `description` | string? | no | Board description | | `privacy` | string? | no | Board privacy (for example PUBLIC, PROTECTED, SECRET) | **Response `201`** (`BoardResponse`) ```bash curl -X POST "https://api.pinbridge.io/v1/pinterest/boards" \ -H "X-API-Key: $PINBRIDGE_API_KEY" \ -H "Content-Type: application/json" \ -d '{"account_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "name": "Home decor ideas"}' ``` --- ### `DELETE /v1/pinterest/boards/{board_id}` **Delete Board** Delete a board on Pinterest, with every pin on it. Irreversible. PinBridge's published pins on the board keep their records and stored analytics and are marked `removed_from_pinterest_at`. Deleting a secret board needs the `boards:write_secret` scope (connections made before API 1.37 lack it). Auth: `ApiKeyAuth`, `BearerAuth` **Parameters** | Name | In | Required | Type | Description | |---|---|---|---|---| | `board_id` | path | yes | string | | | `account_id` | query | yes | string | Pinterest account ID | **Response `204`** ```bash curl -X DELETE "https://api.pinbridge.io/v1/pinterest/boards/$BOARD_ID?account_id=$ACCOUNT_ID" \ -H "X-API-Key: $PINBRIDGE_API_KEY" ``` --- ### `PATCH /v1/pinterest/boards/{board_id}` **Update Board** Rename a board or change its description or privacy. The change is made on Pinterest; fields left out are unchanged. Pins on the board are unaffected. Auth: `ApiKeyAuth`, `BearerAuth` **Parameters** | Name | In | Required | Type | Description | |---|---|---|---|---| | `board_id` | path | yes | string | | **Request body** (`BoardUpdateRequest`) | Field | Type | Required | Description | |---|---|---|---| | `account_id` | string | yes | Pinterest account that owns the board | | `name` | string? | no | New board name | | `description` | string? | no | New board description | | `privacy` | string? | no | New privacy (for example PUBLIC, SECRET) | **Response `200`** (`BoardResponse`) ```bash curl -X PATCH "https://api.pinbridge.io/v1/pinterest/boards/$BOARD_ID" \ -H "X-API-Key: $PINBRIDGE_API_KEY" \ -H "Content-Type: application/json" \ -d '{"account_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "name": "Autumn decor"}' ``` --- ### `GET /v1/pinterest/boards/{board_id}/access` **Get Board Access** Check whether an account can publish to a board right now. Runs the same preflight `POST /v1/pins` uses and reports the outcome instead of failing: `publishable` plus a stable `code` (`board_not_found`, `board_not_owned`, `board_deleted`, `scope_missing`, `token_expired`, `board_access_denied`, `sandbox_board_not_allowed`, ...) and a remediation hint. `status: skipped` means Pinterest was unavailable and only local checks ran. Auth: `ApiKeyAuth`, `BearerAuth` **Parameters** | Name | In | Required | Type | Description | |---|---|---|---|---| | `board_id` | path | yes | string | | | `account_id` | query | yes | string | Pinterest account ID | | `fresh` | query | no | boolean | Bypass the cached verdict and ask Pinterest again | **Response `200`** (`BoardAccessResponse`) ```bash curl -X GET "https://api.pinbridge.io/v1/pinterest/boards/$BOARD_ID/access?account_id=$ACCOUNT_ID" \ -H "X-API-Key: $PINBRIDGE_API_KEY" ``` --- ### `GET /v1/pinterest/oauth/callback` **Oauth Callback** Handle Pinterest OAuth callback. Exchanges code for access token and stores account. **Parameters** | Name | In | Required | Type | Description | |---|---|---|---|---| | `code` | query | no | string? | Authorization code from Pinterest | | `state` | query | yes | string | State parameter for CSRF protection | | `error` | query | no | string? | Set by Pinterest when access was refused | **Response `200`** ```bash curl -X GET "https://api.pinbridge.io/v1/pinterest/oauth/callback?state=$STATE" ``` --- ### `GET /v1/pinterest/oauth/start` **Start Oauth** Start Pinterest OAuth flow. Returns authorization URL to redirect user to. Pass `account_id` when reconnecting an account, so a workspace at its plan's account limit can still reconnect it. Auth: `ApiKeyAuth`, `BearerAuth` **Parameters** | Name | In | Required | Type | Description | |---|---|---|---|---| | `account_id` | query | no | string? | When reconnecting, the connected account being reconnected. It does not count against the plan's Pinterest account limit. | **Response `200`** (`OAuthStartResponse`) ```bash curl -X GET "https://api.pinbridge.io/v1/pinterest/oauth/start" \ -H "X-API-Key: $PINBRIDGE_API_KEY" ``` --- ### `GET /v1/pinterest/terms/related` **List Related Terms** List Pinterest related terms for a connected account. Auth: `ApiKeyAuth`, `BearerAuth` **Parameters** | Name | In | Required | Type | Description | |---|---|---|---|---| | `account_id` | query | yes | string | Pinterest account ID | | `terms` | query | yes | array | Requested term(s). Supports repeated query params or a comma-separated value. | | `exact_match` | query | no | boolean | When true, keep only related-term groups whose `term` exactly matches one of the requested `terms` values after trim/casefold normalization. | **Response `200`** (`RelatedTermsResponse`) ```bash curl -X GET "https://api.pinbridge.io/v1/pinterest/terms/related?account_id=$ACCOUNT_ID&terms=$TERMS" \ -H "X-API-Key: $PINBRIDGE_API_KEY" ``` --- ================================================================================ # Projects API URL: https://www.pinbridge.io/docs/api/projects/ ================================================================================ # Projects API ### `GET /v1/projects` **List Projects** List projects for the current organization. Auth: `BearerAuth` with a user session token (JWT). API keys are rejected. **Response `200`** (`ProjectsContextResponse`) ```bash curl -X GET "https://api.pinbridge.io/v1/projects" \ -H "Authorization: Bearer $PINBRIDGE_JWT" ``` --- ### `POST /v1/projects/sandbox` **Create Sandbox Project** Create sandbox project for the current organization (idempotent). Auth: `BearerAuth` with a user session token (JWT). API keys are rejected. **Request body** (`CreateSandboxProjectRequest`) | Field | Type | Required | Description | |---|---|---|---| | `name` | string? | no | | **Response `201`** (`ProjectsContextResponse`) ```bash curl -X POST "https://api.pinbridge.io/v1/projects/sandbox" \ -H "Authorization: Bearer $PINBRIDGE_JWT" \ -H "Content-Type: application/json" \ -d '{}' ``` --- ### `POST /v1/projects/sandbox/reset` **Reset Sandbox Project Data** Delete all sandbox-scoped resources so users can start fresh for testing. Auth: `BearerAuth` with a user session token (JWT). API keys are rejected. **Response `200`** (`ProjectsContextResponse`) ```bash curl -X POST "https://api.pinbridge.io/v1/projects/sandbox/reset" \ -H "Authorization: Bearer $PINBRIDGE_JWT" ``` --- ### `POST /v1/projects/switch` **Switch Project** Switch active project for the current session and issue a new JWT. Auth: `BearerAuth` with a user session token (JWT). API keys are rejected. **Request body** (`SwitchProjectRequest`) | Field | Type | Required | Description | |---|---|---|---| | `project_id` | string | yes | | **Response `200`** (`ProjectSwitchResponse`) ```bash curl -X POST "https://api.pinbridge.io/v1/projects/switch" \ -H "Authorization: Bearer $PINBRIDGE_JWT" \ -H "Content-Type: application/json" \ -d '{"project_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"}' ``` --- ================================================================================ # Rate Limiting API URL: https://www.pinbridge.io/docs/api/rate-limiting/ ================================================================================ # Rate Limiting API ### `GET /v1/rate-meter` **Get Rate Meter** Get current rate limit status for an account. Returns token bucket state and usage estimates. Auth: `ApiKeyAuth`, `BearerAuth` **Parameters** | Name | In | Required | Type | Description | |---|---|---|---|---| | `account_id` | query | yes | string | Pinterest account ID | **Response `200`** ```bash curl -X GET "https://api.pinbridge.io/v1/rate-meter?account_id=$ACCOUNT_ID" \ -H "X-API-Key: $PINBRIDGE_API_KEY" ``` --- ================================================================================ # Schedules API URL: https://www.pinbridge.io/docs/api/schedules/ ================================================================================ # Schedules API ### `GET /v1/schedules` **List Schedules** List schedules for the workspace, latest run_at first by default, with optional filters. The `X-Total-Count` response header carries the number of schedules matching the filters and search, so clients can page with `limit` and `offset`. Auth: `ApiKeyAuth`, `BearerAuth` **Parameters** | Name | In | Required | Type | Description | |---|---|---|---|---| | `limit` | query | no | integer | | | `offset` | query | no | integer | | | `q` | query | no | string? | Case-insensitive search across the pin title, description and link URL | | `sort` | query | no | enum(run_at_desc, run_at_asc, created_at_desc, created_at_asc, title_asc, title_desc, status_asc, status_desc) | Sort order; run_at_asc lists the next run first | | `account_id` | query | no | string? | Only schedules for this Pinterest account | | `board_id` | query | no | string? | Only schedules targeting this board | | `status` | query | no | ScheduleStatus? | Only schedules in this status | | `since` | query | no | string? | Only schedules running at or after this ISO 8601 timestamp | | `until` | query | no | string? | Only schedules running before this ISO 8601 timestamp | **Response `200`** ```bash curl -X GET "https://api.pinbridge.io/v1/schedules" \ -H "X-API-Key: $PINBRIDGE_API_KEY" ``` --- ### `POST /v1/schedules` **Create Schedule** Schedule a pin for future publishing. The pin will be published at the specified run_at time. The target board is preflighted at scheduling time so a board the account cannot publish to is rejected now rather than when the schedule fires. Auth: `ApiKeyAuth`, `BearerAuth` **Request body** (`ScheduleCreate`) | Field | Type | Required | Description | |---|---|---|---| | `account_id` | string | yes | Pinterest account ID to publish with | | `run_at` | string | yes | When to publish this pin (ISO 8601 timestamp) | | `board_id` | string | yes | Pinterest board ID | | `title` | string | yes | Pin title (max 100) | | `description` | string? | no | Pin description | | `alt_text` | string? | no | Alternative text used for accessibility | | `cover_image_url` | string? | no | Optional public cover image URL for scheduled video pins | | `cover_image_asset_id` | string? | no | Optional uploaded image asset ID used as a video cover image | | `link_url` | string? | no | Destination URL for the pin | | `image_url` | string? | no | Public URL of the image to pin | | `asset_id` | string? | no | Uploaded PinBridge asset ID to use for the scheduled image or video | **Response `201`** (`ScheduleResponse`) ```bash curl -X POST "https://api.pinbridge.io/v1/schedules" \ -H "X-API-Key: $PINBRIDGE_API_KEY" \ -H "Content-Type: application/json" \ -d '{"account_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "run_at": "2026-12-01T09:00:00Z", "board_id": "987654321098765432", "title": "Autumn table setting", "image_url": "https://example.com/image.jpg"}' ``` --- ### `POST /v1/schedules/bulk-cancel` **Bulk Cancel Schedules** Cancel multiple schedules in one call. Per-item outcomes: * succeeded — schedule was canceled. * skipped (error_code=ineligible_status) — schedule status is not SCHEDULED/QUEUED/DEFERRED. * failed (error_code=not_found) — schedule not found in workspace. Auth: `ApiKeyAuth`, `BearerAuth` **Request body** (`BulkIdsRequest`) | Field | Type | Required | Description | |---|---|---|---| | `ids` | array | yes | List of resource IDs to act on (up to 500 per request). | **Response `200`** (`BulkOperationResponse`) ```bash curl -X POST "https://api.pinbridge.io/v1/schedules/bulk-cancel" \ -H "X-API-Key: $PINBRIDGE_API_KEY" \ -H "Content-Type: application/json" \ -d '{"ids": ["3fa85f64-5717-4562-b3fc-2c963f66afa6"]}' ``` --- ### `POST /v1/schedules/bulk-delete` **Bulk Delete Schedules** Delete multiple terminal-state schedules in one call. Per-item outcomes: * succeeded — schedule was deleted. * skipped (error_code=ineligible_status) — schedule status is not done, failed, or canceled. * failed (error_code=not_found) — schedule not found in workspace. Auth: `ApiKeyAuth`, `BearerAuth` **Request body** (`BulkIdsRequest`) | Field | Type | Required | Description | |---|---|---|---| | `ids` | array | yes | List of resource IDs to act on (up to 500 per request). | **Response `200`** (`BulkOperationResponse`) ```bash curl -X POST "https://api.pinbridge.io/v1/schedules/bulk-delete" \ -H "X-API-Key: $PINBRIDGE_API_KEY" \ -H "Content-Type: application/json" \ -d '{"ids": ["3fa85f64-5717-4562-b3fc-2c963f66afa6"]}' ``` --- ### `POST /v1/schedules/bulk-retry` **Bulk Retry Schedules** Retry multiple failed schedules in one call. Per-item outcomes: * succeeded — schedule was reset to a runnable state. * skipped (error_code=ineligible_status) — schedule status is not FAILED. * failed (error_code=not_found) — schedule not found in workspace. Auth: `ApiKeyAuth`, `BearerAuth` **Request body** (`BulkIdsRequest`) | Field | Type | Required | Description | |---|---|---|---| | `ids` | array | yes | List of resource IDs to act on (up to 500 per request). | **Response `200`** (`BulkOperationResponse`) ```bash curl -X POST "https://api.pinbridge.io/v1/schedules/bulk-retry" \ -H "X-API-Key: $PINBRIDGE_API_KEY" \ -H "Content-Type: application/json" \ -d '{"ids": ["3fa85f64-5717-4562-b3fc-2c963f66afa6"]}' ``` --- ### `POST /v1/schedules/validate` **Validate Schedule** Dry-run a schedule request. Same checks as `POST /v1/pins/validate` plus `run_at`. Nothing is stored. Auth: `ApiKeyAuth`, `BearerAuth` **Request body** (`ScheduleCreate`) | Field | Type | Required | Description | |---|---|---|---| | `account_id` | string | yes | Pinterest account ID to publish with | | `run_at` | string | yes | When to publish this pin (ISO 8601 timestamp) | | `board_id` | string | yes | Pinterest board ID | | `title` | string | yes | Pin title (max 100) | | `description` | string? | no | Pin description | | `alt_text` | string? | no | Alternative text used for accessibility | | `cover_image_url` | string? | no | Optional public cover image URL for scheduled video pins | | `cover_image_asset_id` | string? | no | Optional uploaded image asset ID used as a video cover image | | `link_url` | string? | no | Destination URL for the pin | | `image_url` | string? | no | Public URL of the image to pin | | `asset_id` | string? | no | Uploaded PinBridge asset ID to use for the scheduled image or video | **Response `200`** (`PinValidationResponse`) ```bash curl -X POST "https://api.pinbridge.io/v1/schedules/validate" \ -H "X-API-Key: $PINBRIDGE_API_KEY" \ -H "Content-Type: application/json" \ -d '{"account_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "run_at": "2026-12-01T09:00:00Z", "board_id": "987654321098765432", "title": "Autumn table setting", "image_url": "https://example.com/image.jpg"}' ``` --- ### `DELETE /v1/schedules/{schedule_id}` **Delete Schedule** Delete a schedule once it is in a terminal state (done, failed, or canceled). Auth: `ApiKeyAuth`, `BearerAuth` **Parameters** | Name | In | Required | Type | Description | |---|---|---|---|---| | `schedule_id` | path | yes | string | | **Response `204`** ```bash curl -X DELETE "https://api.pinbridge.io/v1/schedules/$SCHEDULE_ID" \ -H "X-API-Key: $PINBRIDGE_API_KEY" ``` --- ### `GET /v1/schedules/{schedule_id}` **Get Schedule** Get schedule details. Auth: `ApiKeyAuth`, `BearerAuth` **Parameters** | Name | In | Required | Type | Description | |---|---|---|---|---| | `schedule_id` | path | yes | string | | **Response `200`** (`ScheduleResponse`) ```bash curl -X GET "https://api.pinbridge.io/v1/schedules/$SCHEDULE_ID" \ -H "X-API-Key: $PINBRIDGE_API_KEY" ``` --- ### `PATCH /v1/schedules/{schedule_id}` **Update Schedule** Edit a pending schedule in place. Change the publish time, board, title, description, alt text, link or media of a schedule that has not started publishing, keeping its id and history. The same checks as creating a schedule apply to the changed fields: `run_at` must be in the future, a new board is preflighted, and new media is resolved. Schedules that already started return `409`; cancel and recreate, or edit the resulting pin instead. Auth: `ApiKeyAuth`, `BearerAuth` **Parameters** | Name | In | Required | Type | Description | |---|---|---|---|---| | `schedule_id` | path | yes | string | | **Request body** (`ScheduleUpdate`) | Field | Type | Required | Description | |---|---|---|---| | `run_at` | string? | no | New publish time (ISO 8601, with tz) | | `board_id` | string? | no | Move the schedule to another board | | `title` | string? | no | Pin title | | `description` | string? | no | Pin description | | `alt_text` | string? | no | Alternative text used for accessibility | | `link_url` | string? | no | Destination URL for the pin | | `image_url` | string? | no | Replace the media with a public URL | | `asset_id` | string? | no | Replace the media with an uploaded asset | | `cover_image_url` | string? | no | Video cover image URL | | `cover_image_asset_id` | string? | no | Video cover image asset ID | **Response `200`** (`ScheduleResponse`) ```bash curl -X PATCH "https://api.pinbridge.io/v1/schedules/$SCHEDULE_ID" \ -H "X-API-Key: $PINBRIDGE_API_KEY" \ -H "Content-Type: application/json" \ -d '{"run_at": "2026-12-01T09:00:00Z", "alt_text": "A dinner table set with candles and autumn leaves"}' ``` --- ### `POST /v1/schedules/{schedule_id}/cancel` **Cancel Schedule** Cancel a scheduled pin. Auth: `ApiKeyAuth`, `BearerAuth` **Parameters** | Name | In | Required | Type | Description | |---|---|---|---|---| | `schedule_id` | path | yes | string | | **Response `200`** (`ScheduleResponse`) ```bash curl -X POST "https://api.pinbridge.io/v1/schedules/$SCHEDULE_ID/cancel" \ -H "X-API-Key: $PINBRIDGE_API_KEY" ``` --- ### `POST /v1/schedules/{schedule_id}/retry` **Retry Schedule** Retry a previously failed schedule. Resets the schedule (and any linked, non-deleted pin) to a runnable state. Auth: `ApiKeyAuth`, `BearerAuth` **Parameters** | Name | In | Required | Type | Description | |---|---|---|---|---| | `schedule_id` | path | yes | string | | **Response `200`** (`ScheduleResponse`) ```bash curl -X POST "https://api.pinbridge.io/v1/schedules/$SCHEDULE_ID/retry" \ -H "X-API-Key: $PINBRIDGE_API_KEY" ``` --- ================================================================================ # Team API URL: https://www.pinbridge.io/docs/api/team/ ================================================================================ # Team API ### `GET /v1/team/invitations` **List Team Invitations** List pending organization invitations. Auth: `BearerAuth` with a user session token (JWT). API keys are rejected. **Response `200`** (`TeamInvitationsListResponse`) ```bash curl -X GET "https://api.pinbridge.io/v1/team/invitations" \ -H "Authorization: Bearer $PINBRIDGE_JWT" ``` --- ### `POST /v1/team/invitations` **Create Team Invitation Route** Create a new invitation for an organization member. Auth: `BearerAuth` with a user session token (JWT). API keys are rejected. **Request body** (`TeamInvitationCreateRequest`) | Field | Type | Required | Description | |---|---|---|---| | `email` | string | yes | | | `role` | string | yes | (max 32, min 4) | **Response `201`** (`TeamInvitationResponse`) ```bash curl -X POST "https://api.pinbridge.io/v1/team/invitations" \ -H "Authorization: Bearer $PINBRIDGE_JWT" \ -H "Content-Type: application/json" \ -d '{"email": "jane@example.com", "role": "operator"}' ``` --- ### `POST /v1/team/invitations/accept` **Accept Team Invitation Route** Accept an invitation for the authenticated user and switch the session context. Auth: `BearerAuth` with a user session token (JWT). API keys are rejected. **Request body** (`TeamInvitationAcceptRequest`) | Field | Type | Required | Description | |---|---|---|---| | `token` | string | yes | (max 512, min 20) | **Response `200`** (`TeamInvitationAcceptResponse`) ```bash curl -X POST "https://api.pinbridge.io/v1/team/invitations/accept" \ -H "Authorization: Bearer $PINBRIDGE_JWT" \ -H "Content-Type: application/json" \ -d '{"token": "token-from-the-email-link"}' ``` --- ### `GET /v1/team/invitations/preview` **Preview Team Invitation** Return a public preview of an invitation token. **Parameters** | Name | In | Required | Type | Description | |---|---|---|---|---| | `token` | query | yes | string | | **Response `200`** (`TeamInvitationPreviewResponse`) ```bash curl -X GET "https://api.pinbridge.io/v1/team/invitations/preview?token=$TOKEN" ``` --- ### `DELETE /v1/team/invitations/{invitation_id}` **Revoke Team Invitation Route** Revoke a pending invitation. Auth: `BearerAuth` with a user session token (JWT). API keys are rejected. **Parameters** | Name | In | Required | Type | Description | |---|---|---|---|---| | `invitation_id` | path | yes | string | | **Response `200`** (`TeamActionResponse`) ```bash curl -X DELETE "https://api.pinbridge.io/v1/team/invitations/$INVITATION_ID" \ -H "Authorization: Bearer $PINBRIDGE_JWT" ``` --- ### `POST /v1/team/invitations/{invitation_id}/resend` **Resend Team Invitation Route** Rotate an invitation token and send a fresh email. Auth: `BearerAuth` with a user session token (JWT). API keys are rejected. **Parameters** | Name | In | Required | Type | Description | |---|---|---|---|---| | `invitation_id` | path | yes | string | | **Response `200`** (`TeamInvitationResponse`) ```bash curl -X POST "https://api.pinbridge.io/v1/team/invitations/$INVITATION_ID/resend" \ -H "Authorization: Bearer $PINBRIDGE_JWT" ``` --- ### `GET /v1/team/members` **List Team Members** List accepted organization members. Auth: `BearerAuth` with a user session token (JWT). API keys are rejected. **Response `200`** (`TeamMembersListResponse`) ```bash curl -X GET "https://api.pinbridge.io/v1/team/members" \ -H "Authorization: Bearer $PINBRIDGE_JWT" ``` --- ### `DELETE /v1/team/members/{member_id}` **Remove Team Member** Remove a member or allow a non-owner to remove themselves. Auth: `BearerAuth` with a user session token (JWT). API keys are rejected. **Parameters** | Name | In | Required | Type | Description | |---|---|---|---|---| | `member_id` | path | yes | string | | **Response `200`** (`TeamActionResponse`) ```bash curl -X DELETE "https://api.pinbridge.io/v1/team/members/$MEMBER_ID" \ -H "Authorization: Bearer $PINBRIDGE_JWT" ``` --- ### `PATCH /v1/team/members/{member_id}` **Update Team Member** Update a member role or transfer ownership. Auth: `BearerAuth` with a user session token (JWT). API keys are rejected. **Parameters** | Name | In | Required | Type | Description | |---|---|---|---|---| | `member_id` | path | yes | string | | **Request body** (`TeamMemberUpdateRequest`) | Field | Type | Required | Description | |---|---|---|---| | `role` | string | yes | (max 32, min 4) | **Response `200`** (`TeamMemberResponse`) ```bash curl -X PATCH "https://api.pinbridge.io/v1/team/members/$MEMBER_ID" \ -H "Authorization: Bearer $PINBRIDGE_JWT" \ -H "Content-Type: application/json" \ -d '{"role": "operator"}' ``` --- ================================================================================ # Webhooks API URL: https://www.pinbridge.io/docs/api/webhooks/ ================================================================================ # Webhooks API ### `GET /v1/webhooks` **List Webhooks** List all webhooks for workspace. Auth: `ApiKeyAuth`, `BearerAuth` **Response `200`** ```bash curl -X GET "https://api.pinbridge.io/v1/webhooks" \ -H "X-API-Key: $PINBRIDGE_API_KEY" ``` --- ### `POST /v1/webhooks` **Create Webhook** Create a new webhook endpoint. Webhooks receive events like pin.published and pin.failed. Auth: `ApiKeyAuth`, `BearerAuth` **Request body** (`WebhookCreate`) | Field | Type | Required | Description | |---|---|---|---| | `url` | string | yes | Webhook endpoint URL (max 2083, min 1) | | `secret` | string | yes | Secret for HMAC signing (min 16 chars) (max 255, min 16) | | `events` | array | no | Event types to subscribe to | | `is_enabled` | boolean | no | Whether this webhook is active | **Response `201`** (`WebhookResponse`) ```bash curl -X POST "https://api.pinbridge.io/v1/webhooks" \ -H "X-API-Key: $PINBRIDGE_API_KEY" \ -H "Content-Type: application/json" \ -d '{"url": "https://example.com/webhooks/pinbridge", "secret": "a-random-secret-of-32-characters"}' ``` --- ### `DELETE /v1/webhooks/{webhook_id}` **Delete Webhook** Delete a webhook. Auth: `ApiKeyAuth`, `BearerAuth` **Parameters** | Name | In | Required | Type | Description | |---|---|---|---|---| | `webhook_id` | path | yes | string | | **Response `204`** ```bash curl -X DELETE "https://api.pinbridge.io/v1/webhooks/$WEBHOOK_ID" \ -H "X-API-Key: $PINBRIDGE_API_KEY" ``` --- ### `GET /v1/webhooks/{webhook_id}` **Get Webhook** Get webhook details. Auth: `ApiKeyAuth`, `BearerAuth` **Parameters** | Name | In | Required | Type | Description | |---|---|---|---|---| | `webhook_id` | path | yes | string | | **Response `200`** (`WebhookResponse`) ```bash curl -X GET "https://api.pinbridge.io/v1/webhooks/$WEBHOOK_ID" \ -H "X-API-Key: $PINBRIDGE_API_KEY" ``` --- ### `PATCH /v1/webhooks/{webhook_id}` **Update Webhook** Update webhook fields. Auth: `ApiKeyAuth`, `BearerAuth` **Parameters** | Name | In | Required | Type | Description | |---|---|---|---|---| | `webhook_id` | path | yes | string | | **Request body** (`WebhookUpdate`) | Field | Type | Required | Description | |---|---|---|---| | `url` | string? | no | Webhook endpoint URL | | `secret` | string? | no | Secret for HMAC signing (min 16 chars) | | `events` | array? | no | Event types to subscribe to | | `is_enabled` | boolean? | no | Whether this webhook is active | **Response `200`** (`WebhookResponse`) ```bash curl -X PATCH "https://api.pinbridge.io/v1/webhooks/$WEBHOOK_ID" \ -H "X-API-Key: $PINBRIDGE_API_KEY" \ -H "Content-Type: application/json" \ -d '{"is_enabled": false}' ``` --- ================================================================================ # Authentication URL: https://www.pinbridge.io/docs/authentication/ ================================================================================ # Authentication Use a JWT for user sessions and an API key for production server-to-server calls. ## Authentication methods PinBridge supports two auth methods: - A JWT bearer token from `POST /v1/auth/register` or `POST /v1/auth/login`. - An API key from `POST /v1/api-keys`. An API key is sent in the `X-API-Key` header and works for all publishing and read endpoints. A user-session JWT is sent as `Authorization: Bearer `. A few endpoints, `/v1/auth/me` and `/v1/projects`, require a user-session JWT and reject API keys. You can use the PinBridge sandbox for integration testing before you pay for live pin creations. See [Rate limits & quotas](/docs/rate-limits/) for what counts against your quota. ## 1. Register a user Use this once per account: ```bash curl -X POST https://api.pinbridge.io/v1/auth/register \ -H "Content-Type: application/json" \ -d '{ "full_name": "Jane Doe", "email": "you@example.com", "password": "your-strong-password" }' ``` The response includes `access_token`, `expires_in`, `user`, `organization`, `active_project`, `projects`, and `workspace` (a legacy compatibility field). ## 2. Log in (returning users) ```bash curl -X POST https://api.pinbridge.io/v1/auth/login \ -H "Content-Type: application/json" \ -d '{ "email": "you@example.com", "password": "your-strong-password" }' ``` Save `access_token` from the response. ## 3. Verify a session token ```bash curl https://api.pinbridge.io/v1/auth/me \ -H "Authorization: Bearer $PINBRIDGE_JWT" ``` If the token is valid, you get `user`, `organization`, `active_project`, `projects`, and `workspace`. This endpoint requires a user-session JWT and rejects API keys. ## 4. Recover a password Request a reset email: ```bash curl -X POST https://api.pinbridge.io/v1/auth/forgot-password \ -H "Content-Type: application/json" \ -d '{ "email": "you@example.com" }' ``` Complete the reset with the token from the emailed link: ```bash curl -X POST https://api.pinbridge.io/v1/auth/reset-password \ -H "Content-Type: application/json" \ -d "{ \"token\": \"$RESET_TOKEN\", \"password\": \"your-new-strong-password\" }" ``` - `forgot-password` always returns the same success message whether the email exists or not. - Reset links are single-use and expire automatically. - The web app exposes the same flow at `/forgot-password` and `/reset-password`. ## 5. Create an API key (recommended for backend jobs) Use your JWT to create a long-lived API key for server-to-server usage: ```bash curl -X POST https://api.pinbridge.io/v1/api-keys \ -H "Content-Type: application/json" \ -H "Authorization: Bearer $PINBRIDGE_JWT" \ -d '{ "name": "Production Publisher" }' ``` The plaintext `api_key` is returned once. Store it in a secrets manager, and never expose it in browser code. ## 6. Use the API key `X-API-Key` style (recommended): ```bash curl https://api.pinbridge.io/v1/pinterest/accounts \ -H "X-API-Key: $PINBRIDGE_API_KEY" ``` `Authorization` style: ```bash curl https://api.pinbridge.io/v1/pinterest/accounts \ -H "Authorization: Bearer $PINBRIDGE_API_KEY" ``` A first call to `GET /v1/pinterest/accounts` is the simplest way to confirm a new API key works. ## 7. Additional auth endpoints Authenticated password change: ```bash curl -X POST https://api.pinbridge.io/v1/auth/change-password \ -H "Content-Type: application/json" \ -H "Authorization: Bearer $PINBRIDGE_JWT" \ -d '{ "current_password": "your-current-password", "new_password": "your-new-strong-password" }' ``` Request email verification, then verify the token: ```bash curl -X POST https://api.pinbridge.io/v1/auth/email/verify/request \ -H "Authorization: Bearer $PINBRIDGE_JWT" curl "https://api.pinbridge.io/v1/auth/email/verify?token=$VERIFY_TOKEN" ``` Fetch or update the billing profile: ```bash curl https://api.pinbridge.io/v1/auth/profile \ -H "Authorization: Bearer $PINBRIDGE_JWT" curl -X PUT https://api.pinbridge.io/v1/auth/profile \ -H "Content-Type: application/json" \ -H "Authorization: Bearer $PINBRIDGE_JWT" \ -d '{ "workspace_name": "Acme Publishing", "billing_email": "billing@acme.com" }' ``` ## Common auth errors - `401 Missing API key` — no auth header provided. - `401 Invalid API key` — key is wrong or revoked. - `401 Invalid access token` — JWT is malformed, expired, or the workspace is missing. - `400 Invalid or expired password reset token` — reset token is missing, expired, or already used. - `409 Account already exists` — trying to register an existing email. ## Security checklist - Use JWTs for end-user login sessions. - Use API keys only on trusted backend services. - Rotate API keys periodically. - Revoke compromised keys with `DELETE /v1/api-keys/{key_id}`. - Never commit tokens or API keys to source control. ## Next steps - [Quickstart](/docs/) — connect Pinterest and publish your first pin. - [Rate limits & quotas](/docs/rate-limits/) — token buckets, monthly pin quotas, and billing errors. - [Billing & credits](/docs/billing/) — plans, quota, and credit packs. - [Python SDK](/docs/python-sdk/) — a typed client for the same endpoints. ================================================================================ # Billing & credits URL: https://www.pinbridge.io/docs/billing/ ================================================================================ # Billing & credits How plans, the monthly pin quota, and credits fit together, and where each is managed. ## Plans PinBridge has five plans: Playground, Starter, Growth, Agency, and Enterprise. Each includes the full publishing pipeline; higher plans raise the monthly pin quota, the number of connected Pinterest accounts, storage, and features like uploaded assets, bulk imports, and team roles. For current prices and the per-plan feature list, see [/pricing/](/pricing/). The per-plan pin quotas and account limits are summarized on [Rate limits & quotas](/docs/rate-limits/). The API `plan` enum value for the Agency plan is `pro`. Enterprise is sold as "Custom" and is not part of the self-serve catalog. Check your current plan, usage, and quota at any time: ```bash curl https://api.pinbridge.io/v1/billing/status \ -H "X-API-Key: $PINBRIDGE_API_KEY" ``` ## What counts against your quota Each plan has a monthly pin-creation quota. Only **successful new pin creations** count against it. That includes direct `POST /v1/pins`, successful import rows, and successful scheduled publishes. Reads, auth, board sync, webhooks, invalid rows, and idempotent retries do **not** count. When the monthly quota is exhausted, `POST /v1/pins` returns `402` with error code `quota_exceeded` (or `credits_exhausted` when credits are enabled but the balance is 0). ## Credits Credits are top-up packs that let you keep publishing after the monthly quota is used up. They are one-time purchases (Stripe Checkout), not a subscription. | Pack | Pins | Price | |---|---|---| | Small | 200 | $8.00 | | Medium | 500 | $17.50 | | Large | 1,500 | $45.00 | | XLarge | 3,000 | $75.00 | **Consumption order:** the monthly plan quota is always consumed first. Only after the monthly quota is exhausted does PinBridge debit 1 credit per successful publish. If the quota is exhausted and there are no credits, the publish is rejected with `402 credits_exhausted`. **Eligibility:** credits are available only on paid, non-trial workspaces. Free (Playground) and trial workspaces cannot buy or consume credits. ## Upgrading Move to a higher plan through Stripe Checkout, or manage an existing subscription and payment method through the billing portal: ```bash # Start a checkout session for a plan change curl -X POST https://api.pinbridge.io/v1/billing/checkout \ -H "X-API-Key: $PINBRIDGE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "plan": "growth" }' # Open the customer billing portal curl -X POST https://api.pinbridge.io/v1/billing/portal \ -H "X-API-Key: $PINBRIDGE_API_KEY" ``` The Python SDK covers these as `client.billing.checkout(...)`, `client.billing.portal()`, `client.billing.pricing()`, and `client.billing.status()`. See the [Python SDK](/docs/python-sdk/). ## Cancel and resume You can cancel a paid subscription and later resume it: - Cancel: `POST /v1/billing/cancel` - Resume: `POST /v1/billing/resume` Manage both from the billing portal (`POST /v1/billing/portal`), which opens Stripe's customer portal. While a paid plan is not active or trialing, publishing is blocked with `402 billing_inactive` until the payment method or subscription is restored. ## API-only endpoints Some billing operations exist in the API but have **no Python SDK method**, so call them over HTTP directly: - Credits: `GET` / `POST /v1/billing/credits/*` (view balance and buy packs). - Cancel: `POST /v1/billing/cancel`. - Resume: `POST /v1/billing/resume`. `client.billing` in the SDK exposes only `pricing`, `checkout`, `portal`, and `status`. ## Next steps - [Rate limits & quotas](/docs/rate-limits/) — token buckets and per-plan quotas. - [Pricing](/pricing/) — current prices and per-plan features. - [Python SDK](/docs/python-sdk/) — read billing status and start checkout from code. - [Authentication](/docs/authentication/) — get an API key first. ================================================================================ # Connect Pinterest URL: https://www.pinbridge.io/docs/connect-pinterest/ ================================================================================ # Connect Pinterest Before you can publish, PinBridge needs a Pinterest account to publish with. You connect one through Pinterest's OAuth flow. This page walks the full flow, shows how to reconnect an account whose token has expired, and how to revoke access. ## Prerequisites - An API key (send it in the `X-API-Key` header). See [Core concepts](/docs/core-concepts/) for how projects and API keys fit together. - A Pinterest account you can log in to and approve access for. - Your plan's Pinterest account limit has room. Playground allows 1 connected account; paid plans allow more. Most people connect an account from the PinBridge dashboard, which runs this flow for you. The steps below are the same flow done through the API, which is useful for automation. ## The OAuth flow There are three steps: get a consent URL, have the user approve on Pinterest, then let PinBridge complete the exchange. ### 1. Start OAuth Call the start endpoint. It returns a Pinterest consent URL. curl: ```bash curl -sS https://api.pinbridge.io/v1/pinterest/oauth/start \ -H "X-API-Key: $PINBRIDGE_API_KEY" ``` Expected response (trimmed): ```json { "authorization_url": "https://www.pinterest.com/oauth/?client_id=...&state=..." } ``` ### 2. Approve on Pinterest Open `authorization_url` in a browser and approve access. Pinterest then redirects back to PinBridge's callback URL with two query parameters, `code` and `state`. In the dashboard flow this redirect is handled for you and the account shows up connected. For a programmatic flow, capture `code` and `state` from the redirect. ### 3. Complete the callback Complete the exchange by calling the callback with the `code` and `state` you received. curl: ```bash curl -sS "https://api.pinbridge.io/v1/pinterest/oauth/callback?code=$CODE&state=$STATE" \ -H "X-API-Key: $PINBRIDGE_API_KEY" ``` Once the callback succeeds, the account is connected. Confirm it by listing your accounts. ### 4. List connected accounts ```bash curl -sS https://api.pinbridge.io/v1/pinterest/accounts \ -H "X-API-Key: $PINBRIDGE_API_KEY" ``` Expected response (trimmed): ```json [ { "id": "6f1c2e4a-3b7d-4c9e-8a21-5d0f9b3e7c41", "username": "your_pinterest_handle", "display_name": "Your Brand", "scopes": "boards:read,pins:read,pins:write", "health_status": "healthy", "reconnect_required": false, "token_expires_at": "2026-12-01T00:00:00Z", "missing_scopes": [] } ] ``` The `id` field is the `account_id` you pass when publishing a pin. See [Core concepts](/docs/core-concepts/) for how accounts, boards, and pins relate. ### Python SDK ```python from pinbridge_sdk import PinbridgeClient with PinbridgeClient(api_key="$PINBRIDGE_API_KEY") as client: # 1) Get the consent URL start = client.pinterest.start_oauth() print("Open this URL to approve:", start.authorization_url) # 2) After the user approves, complete the callback with code + state client.pinterest.oauth_callback(code="$CODE", state="$STATE") # 3) Confirm the account is connected accounts = client.pinterest.list_accounts() for a in accounts: print(a.id, a.username, a.health_status) ``` Boards and accounts both live under `client.pinterest`. There is no `client.boards` resource. ### n8n The n8n node (`n8n-nodes-pinbridge`) exposes these under the **Connection** resource: - **Start OAuth** returns the consent URL. - **Complete OAuth Callback** takes **OAuth Code** and **OAuth State** and finishes the exchange. - **List** returns your connected accounts (this is also what the account dropdowns load). - **Revoke** disconnects an account by its **Connection** (connection ID). The credential type is `pinBridgeApi` (`baseUrl` defaults to `https://api.pinbridge.io`, plus your `apiKey`), and it authenticates with the `X-API-Key` header. See [Publish your first pin](/docs/n8n/publish-your-first-pin/) for using the connected account in a workflow. ## Reconnecting an expired account Pinterest tokens do not last forever. PinBridge tracks each account's health so you can spot one that needs attention before a publish fails. On each account in `GET /v1/pinterest/accounts`, watch these fields: - `health_status` is `healthy` when the account is good to publish. It becomes `reconnect_required` when the account needs to be reconnected. - `reconnect_required` is a boolean shortcut for the same thing. - `token_expires_at` is when the current access token expires. - `health_message` explains what is wrong in plain language, and `missing_scopes` lists any permissions Pinterest did not grant. An account whose token has expired and cannot be refreshed, whose access was revoked on Pinterest, or that is missing a required scope will show `health_status: "reconnect_required"`. To reconnect, run the same OAuth flow again (start, approve, callback) with the same Pinterest account. Approving again issues a fresh token and clears the reconnect flag. If `missing_scopes` was populated, make sure you approve all the requested permissions this time. If you try to publish with an account that needs reconnecting, the pin fails with an `error_code` of `token_expired`, `token_revoked`, or `scope_missing`. See the [pin state machine](/docs/core-concepts/) for how those failures surface. ## Revoking an account To disconnect an account, delete it by its `account_id`. curl: ```bash curl -sS -X DELETE https://api.pinbridge.io/v1/pinterest/accounts/6f1c2e4a-3b7d-4c9e-8a21-5d0f9b3e7c41 \ -H "X-API-Key: $PINBRIDGE_API_KEY" ``` A successful revoke returns `204 No Content`. Python SDK: ```python from pinbridge_sdk import PinbridgeClient with PinbridgeClient(api_key="$PINBRIDGE_API_KEY") as client: client.pinterest.revoke_account("6f1c2e4a-3b7d-4c9e-8a21-5d0f9b3e7c41") ``` In n8n, use **Connection → Revoke** and pick the connection to remove. ## Why PinBridge uses shared IPs (and why that's fine) A common worry is that PinBridge publishes from shared server IP addresses rather than your home or office IP, and that this might look suspicious to Pinterest. It does not, and here is why. Pinterest identifies your account by the **OAuth token** you approved during the flow above, not by the IP address a request comes from. That token is what proves the request is authorized to act on your account. The IP is not part of how Pinterest ties an API request to an account. PinBridge is a registered Pinterest API application. Traffic from a registered app arriving over shared server IPs is exactly what Pinterest expects from an app that serves many users. Shared IPs are the normal, expected setup for a registered API integration and are not a risk to your account. So there is nothing to configure here and no reason to avoid publishing through PinBridge on account of IPs. What actually matters is keeping your connection healthy (the `health_status` above) and staying within your rate and quota limits. ## Common errors and troubleshooting - **`Invalid OAuth state` (400)** on the callback. The `state` value did not match. Start the flow again from step 1 so a fresh `state` is issued, and complete it in the same session. - **Account shows `health_status: "reconnect_required"`.** The token expired, was revoked, or a scope is missing. Reconnect the account (see above) and approve all requested permissions. - **Publishes fail with `board_access_denied`.** The connected account cannot post to that board. Confirm the board belongs to the connected account. - **`Pinterest account not found` (404)** when publishing or revoking. The `account_id` is wrong or the account was already revoked. List accounts to get the current `id`. ## Next steps - [Core concepts](/docs/core-concepts/) for the object model and pin states. - [Publish your first pin](/docs/n8n/publish-your-first-pin/) using your connected account. - [Try the sandbox](/docs/sandbox/) to test publishing without touching real Pinterest. ================================================================================ # Core concepts URL: https://www.pinbridge.io/docs/core-concepts/ ================================================================================ # Core concepts This page explains how PinBridge is structured and the words we use for each part of it. It is the terminology anchor for the rest of these docs, so if a term shows up elsewhere and you are not sure what it means, look it up here. ## The object model PinBridge nests a small set of objects. From the outside in: organization → project → Pinterest account → board → asset → pin → schedule → import job → job - An **organization** is your account and billing boundary. It owns projects and holds your plan, disk quota, and team members. - A **project** is an isolated environment inside an organization. It holds its own Pinterest accounts, pins, schedules, API keys, and webhooks. A project is either `production` or `sandbox` (see [Sandbox](/docs/sandbox/)). You may see the field name `workspace` in older payloads. That is a legacy compatibility alias for `project`. Use "project" everywhere. - A **Pinterest account** is a Pinterest profile you have connected through OAuth. It is the identity PinBridge publishes with. See [Connect Pinterest](/docs/connect-pinterest/). - A **board** is a Pinterest board that belongs to a connected account. You publish pins onto a board. - An **asset** is an image or video you upload to PinBridge (paid plans). A pin can reference an uploaded asset by `asset_id` instead of a public `image_url`. - A **pin** is a single publish request. It moves through a state machine (below) and, on success, gets a real `pinterest_pin_id`. - A **schedule** is a pin queued to publish at a future `run_at` time. - An **import job** is a bulk request (JSON or CSV) that creates many pins or schedules at once. - A **job** is a generic async task record you can poll for status. Two more objects sit alongside these: - A **webhook** is an HTTPS endpoint PinBridge calls when a pin publishes or fails. - An **API key** authenticates your requests. Send it in the `X-API-Key` header. (User-session endpoints use `Authorization: Bearer ` instead. API keys always go in `X-API-Key`.) ## Terminology table | Term | What it is | Key relationship | |---|---|---| | Organization | Account and billing boundary | Owns projects, plan, disk quota, team | | Project | Isolated environment (`production` or `sandbox`) | Belongs to an organization; owns everything below (legacy alias: `workspace`) | | Pinterest account | A connected Pinterest profile | Belongs to a project; publishes pins | | Board | A Pinterest board | Belongs to a Pinterest account | | Asset | An uploaded image or video | Belongs to a project; referenced by a pin via `asset_id` | | Pin | One publish request | Targets an account + board; carries an `idempotency_key` | | Schedule | A pin queued for a future `run_at` | Materializes into a pin when due | | Import job | A bulk JSON or CSV request | Creates many pins or schedules | | Job | A generic async task record | Poll it for status | | Webhook | An HTTPS callback endpoint | Fires on `pin.published` and `pin.failed` | | API key | Request credential | Sent in `X-API-Key`; scoped to one project | ## The pin state machine Every pin has a `status`. There are five values: `queued`, `publishing`, `published`, `failed`, and `deferred`. ``` created ──▶ queued ──▶ publishing ──▶ published (success) │ │ │ └──▶ failed (non-retryable error) │ └──▶ (retryable error) ──▶ queued ──▶ ... │ └──▶ deferred ──▶ queued (auto-retry, up to 8 deferrals) │ └──▶ failed (deferrals exhausted) ``` - A new pin starts in `queued`. - A worker picks it up, sets `publishing`, and calls Pinterest. - On success the pin becomes `published`, gets `pinterest_pin_id` and `published_at`, and (if you have one configured) fires a `pin.published` webhook. - A **non-retryable** error (for example a bad payload or a revoked token) sends the pin straight to `failed` and fires a `pin.failed` webhook. - A **retryable** error (rate limits, transient upstream errors, temporarily unreachable media) requeues the pin. After a few quick retries it is parked as `deferred` and retried about an hour later. PinBridge repeats this up to 8 deferrals before marking the pin `failed`. When a pin ends in `failed`, `PinResponse.error_code` tells you why. Common values: | `error_code` | Meaning | Retryable | |---|---|---| | `rate_limited` | Pinterest rate limit hit | Yes | | `transient_upstream` | Pinterest 5xx or temporary upstream error | Yes | | `media_url_temporarily_unavailable` | PinBridge-hosted media was briefly unreachable | Yes | | `media_url_unreachable` | The media URL could not be fetched | No | | `token_expired` / `token_revoked` | The Pinterest token needs reconnecting | No | | `scope_missing` | The connection is missing a required scope | No | | `board_access_denied` | The account cannot publish to that board | No | | `resource_not_found` | The board or account no longer exists | No | | `invalid_payload` | Pinterest rejected the pin data | No | | `publish_timeout` | The pin was stuck too long and was failed by a backstop | No | | `api_error` | Fallback for an unclassified Pinterest error | Varies | You can retry a `failed` pin yourself with `POST /v1/pins/{pin_id}/retry` (it returns `409` unless the pin is `failed`), or in bulk with `POST /v1/pins/bulk-retry`. ## Idempotency Every pin you create needs an `idempotency_key`. It is **required**. PinBridge never generates one for you when you call the API or SDK directly. The key makes create requests safe to repeat. PinBridge deduplicates on the pair **(project, `idempotency_key`)**. If you send the same key twice within the same project, the second call returns the existing pin instead of creating a duplicate. This is what lets you retry a network failure without publishing the same pin twice. Pick keys that are stable for a given piece of content. A common pattern is a content ID or a hash of the row you are publishing. ### See it in action Send the same request twice with the same `idempotency_key` and you get the same pin back. curl: ```bash curl -sS -X POST https://api.pinbridge.io/v1/pins \ -H "X-API-Key: $PINBRIDGE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "account_id": "6f1c2e4a-3b7d-4c9e-8a21-5d0f9b3e7c41", "board_id": "987654321098765432", "title": "Autumn table setting", "image_url": "https://placehold.co/1000x1500.png", "idempotency_key": "recipe-2026-0187" }' ``` Expected response (trimmed), same both times: ```json { "id": "8a3f6c12-4d5e-4b7a-9c01-2e6f8d4b7a93", "status": "queued", "idempotency_key": "recipe-2026-0187" } ``` Python SDK: ```python from pinbridge_sdk import PinbridgeClient from pinbridge_sdk.models import PinCreate with PinbridgeClient(api_key="$PINBRIDGE_API_KEY") as client: payload = PinCreate( account_id="6f1c2e4a-3b7d-4c9e-8a21-5d0f9b3e7c41", board_id="987654321098765432", title="Autumn table setting", image_url="https://placehold.co/1000x1500.png", idempotency_key="recipe-2026-0187", # required ) first = client.pins.create(payload) again = client.pins.create(payload) assert first.id == again.id # same pin, no duplicate ``` Note the class name casing: `PinbridgeClient` (lowercase "b"). The SDK does not read environment variables, so pass `api_key` explicitly. There is no `client.boards` resource; boards live under `client.pinterest`. ## Where terms map to auth - Publishing and read endpoints accept an API key in the `X-API-Key` header. - A few endpoints that act on the organization or on projects (for example `GET /v1/auth/me` and `GET /v1/projects`) require a user-session JWT in `Authorization: Bearer ` and reject API keys. - A quick way to test an API key is `GET /v1/pinterest/accounts`. It works on any plan and returns `[]` if you have not connected an account yet. ## Next steps - [Connect Pinterest](/docs/connect-pinterest/) to add the account you will publish with. - [Try the sandbox](/docs/sandbox/) to publish without touching real Pinterest. - [Publish your first pin](/docs/n8n/publish-your-first-pin/) end to end. - [Bulk imports](/docs/guides/bulk-imports/) for publishing many pins at once. ================================================================================ # Error reference URL: https://www.pinbridge.io/docs/errors/ ================================================================================ # Error reference This page lists every user-facing error the PinBridge API returns, grouped by HTTP status. Each row gives the `error_code` or detail string, what causes it, and how to fix it. Use it alongside [Troubleshooting](/docs/troubleshooting/) when you hit a problem, and [My pin failed](/docs/troubleshooting/my-pin-failed/) for pin-level `error_code`s that show up on a pin instead of an HTTP response. ## Error response shapes PinBridge returns two response shapes depending on the error. **Billing errors (402)** use a nested envelope: ```json { "error": { "code": "quota_exceeded", "message": "Monthly pin creation quota (1000) exceeded. Current usage: 1000.", "upgrade_url": "/v1/billing/checkout" } } ``` `upgrade_url` points to where you resolve the problem. It can be `null` (for example, on Agency and Enterprise plans, where there is no self-serve upgrade). All 402 responses use this shape. **Everything else** uses FastAPI's standard shape. Most errors carry a plain `detail`: ```json { "detail": "Pin not found" } ``` Some 403 errors add structured fields (for example `permission`, `current_count`, or `required_plan`) alongside the message. Validation errors (422) return FastAPI's array of field errors under `detail`. Uncaught server errors (500) also include a `request_id`: ```json { "detail": "Internal server error", "request_id": "..." } ``` Placeholders below: `$PINBRIDGE_API_KEY` for your key, `6f1c2e4a-3b7d-4c9e-8a21-5d0f9b3e7c41` / `987654321098765432` for IDs. ## 400 Bad Request The request was understood but a value is not acceptable. Fix the value and retry. | Detail string | Cause | Fix | |---|---|---| | `run_at must be in the future` | A schedule (or an import row with `run_at`) is set to a past time. | Send a future ISO 8601 timestamp with a timezone offset. | | `cover_image_url is only supported for video pins` | You set a cover image on an image pin. | Remove the cover field, or reference a video `asset_id` so the pin is a video pin. | | `Cannot cancel schedule with status ` | The schedule is not in a cancelable state. | Only pending schedules can be canceled. Check the current status first. | | `Cannot retry schedule with status ` | The schedule is not in a retryable state. | Only failed schedules can be retried. | | `Cannot delete schedule with status ; cancel it first.` | You tried to delete a schedule that must be canceled first. | Cancel the schedule, then delete it. | | `Unsupported image content type` | The image is not gif, jpeg, png, or webp. | Upload a supported image format. | | `Unsupported video content type` | The video is not mp4 or quicktime. | Upload an mp4 or quicktime (`.mov`) file. | | `Uploaded file is empty` | The uploaded file has zero bytes. | Re-upload a valid, non-empty file. | | `Uploaded exceeds the maximum allowed size` | The file is over the size limit (global path). | See the plan-based per-file limits and reduce file size. See [My video upload failed](/docs/troubleshooting/video-upload-failed/). | | `file_too_large` — `File exceeds the maximum allowed size of MB for your plan.` | The file is over your plan's per-file limit. | Compress the file or upgrade your plan. See [My video upload failed](/docs/troubleshooting/video-upload-failed/). | | `Invalid OAuth state` | The Pinterest connect callback state did not match. | Restart the connect flow from PinBridge. See [My Pinterest account got disconnected](/docs/troubleshooting/account-disconnected/). | | `CSV is missing required columns: ...` | The CSV lacks `account_id`, `board_id`, `title`, or `idempotency_key`. | Add the required columns. | | `CSV contains unsupported columns: ...` | The CSV has a column outside the allowed set. | Remove the extra columns. | | `Import exceeds the maximum of 500 rows` | The import has more than 500 rows. | Split the import into batches of 500 or fewer. | | `Invalid invitation link.` | The team invite token is malformed. | Request a fresh invitation. | | `This invitation has been revoked.` | The invite was revoked before you accepted. | Ask the org owner or admin to re-invite you. | | `This invitation has expired.` | The invite passed its expiry window. | Ask for a new invitation. | | `Invalid or expired password reset token` | The reset link is used, expired, or wrong. | Request a new password reset. | | `Current password is incorrect` | The password change supplied the wrong current password. | Re-enter your current password. | | `New password must be different from current password` | The new password matches the old one. | Choose a different password. | | `New email must be different from your current email.` | The email change used the same email. | Enter a different email address. | | `Invalid or expired verification token` | The email verification link is used, expired, or wrong. | Resend the verification email. | | `Invalid or expired email change confirmation link` | The email-change confirmation link is used, expired, or wrong. | Restart the email change. | | `Workspace name cannot be empty` | A blank project (workspace) name was submitted. | Provide a non-empty name. | | `Country must be a 2-letter ISO code` | The country field is not a valid ISO 3166 code. | Send a two-letter code, for example `US`. | ## 401 Unauthorized Authentication failed. Fix your credentials and retry. | Detail string | Cause | Fix | |---|---|---| | `Missing API key. Provide X-API-Key or Authorization: Bearer .` | No credential was sent. | Send your key in the `X-API-Key` header. See [401 Invalid API key](/docs/troubleshooting/invalid-api-key-401/). | | `Invalid API key` | The key is unknown, revoked, or mistyped. | Recheck the key value and that it belongs to this project. See [401 Invalid API key](/docs/troubleshooting/invalid-api-key-401/). | | `Invalid or missing access token` | A user-session (bearer) call had an invalid or missing token. | Sign in again to refresh the session token. | | `Invalid access token` | The bearer token on an account endpoint (for example `/v1/auth/me`) is invalid. | Sign in again. | | `Invalid email or password` | Login credentials are wrong. | Recheck email and password, or reset your password. | | `Team access requires a user session.` | A team endpoint was called with an API key instead of a bearer session. | Call team endpoints with a user-session JWT. | | `Team access requires an organization session.` | The session is not scoped to an organization. | Switch into the organization, then retry. | | `Current project is not linked to an organization` | The active project has no organization. | Link the project to an organization first. | Note: `X-API-Key` works for all publishing and read endpoints. `/v1/auth/me` and `/v1/projects` require a user-session JWT and reject API keys. ## 402 Payment Required Billing or quota blocks the request. All 402 responses use the `{error:{code,message,upgrade_url}}` envelope shown above. | `code` | Cause | Fix | |---|---|---| | `billing_inactive` | Plan is not Free and billing status is not active or trialing (for example, a failed payment). Message: "Billing status is {status}. Please update payment method." | Update your payment method. `upgrade_url` is the billing portal (`/v1/billing/portal`). | | `quota_exceeded` | Your monthly pin quota is used up and credits do not apply. Message includes the limit and current usage. | Wait for the monthly reset, or upgrade. `upgrade_url` is `/v1/billing/checkout` (null on Agency and Enterprise). See [402 quota_exceeded](/docs/troubleshooting/quota-exceeded-402/). | | `credits_exhausted` | Monthly quota is used up, credits are enabled, and your credit balance is 0. | Buy a credit pack. `upgrade_url` is `/v1/billing/credits/purchase`. See [402 quota_exceeded](/docs/troubleshooting/quota-exceeded-402/). | | `storage_quota_exceeded` | Your organization's disk quota is full during an asset upload. Message: "Organization storage quota exhausted. Delete files or upgrade your plan to continue uploading." | Delete assets to free space, or upgrade. See [My video upload failed](/docs/troubleshooting/video-upload-failed/). | Monthly quota counts successful publishes and is consumed first; only after it is exhausted does PinBridge debit credits (1 credit per pin), if credits are enabled. Free and trial workspaces cannot buy or use credits. ## 403 Forbidden You are authenticated, but your plan or role does not allow this action. | `error_code` / detail | Cause | Fix | |---|---|---| | `feature_unavailable` — `Feature '' not available on plan.` | A plan-gated feature (for example asset upload, bulk import) is off on your plan. `upgrade_url` is `/v1/billing/checkout`. | Upgrade to a plan that includes the feature. See [403 plan_limit](/docs/troubleshooting/plan-limit-403/). | | `plan_limit` (Pinterest accounts) — `Pinterest account limit () reached for plan.` | You hit your plan's connected-account cap. Includes `current_count`. | Disconnect an account or upgrade. See [403 plan_limit](/docs/troubleshooting/plan-limit-403/). | | `plan_limit` (API keys) — `Active API key limit () reached for plan.` | You hit your plan's active API key cap. | Revoke an unused key or upgrade. See [403 plan_limit](/docs/troubleshooting/plan-limit-403/). | | `sandbox_limit` — `Pinterest account limit () reached for sandbox projects.` | You hit the sandbox project's account cap. | Disconnect a sandbox account. | | `plan_required` — `Team access is available on Agency and Enterprise plans.` | Team features require the Agency (`pro`) or Enterprise plan. `required_plan` is `pro`, `upgrade_url` is `/pricing`. | Upgrade to Agency or Enterprise. | | `insufficient_permissions` | Your role lacks the required permission. The response includes a `permission` field (for example `can_publish`, `can_manage_members`, `can_manage_billing`, `can_manage_api_keys`, `can_manage_integrations`, `can_delete_assets`, `can_reset_sandbox`, `team_view`). | Ask an owner or admin to grant the permission or perform the action. | | `trial_locked_pinterest_account` — `This Pinterest account has already been used to consume a PinBridge trial on another workspace.` | The Pinterest account was already used for a trial elsewhere. | Use a different Pinterest account, or a paid plan. | | `sandbox_board_writes_disabled` / `sandbox_board_limit` | Board create or delete is disabled or capped in sandbox. | Board writes are off by default in sandbox. Do this in a live project. | | `This invitation was sent to a different email address.` | You accepted an invite with a different account. | Sign in with the email the invite was sent to. | | `Admin API access denied` / `Admin API host not allowed` / `Admin access denied` | An admin-only endpoint was called without admin access or from a disallowed host. | These endpoints are for platform admins only. | ## 404 Not Found The referenced resource does not exist, is not in your project, or was deleted. | Detail string | Cause | Fix | |---|---|---| | `Pin not found` | The pin ID is wrong, deleted, or in another project. | Check the pin ID and that you are using the right project's key. | | `Import job not found` | The import job ID is wrong or in another project. | Check the job ID. | | `Asset not found` | The asset ID is wrong, deleted, or in another project. | Check the asset ID. | | `Asset content not found` | The asset record exists but its stored file is missing. | Re-upload the asset. | | `Schedule not found` | The schedule ID is wrong or in another project. | Check the schedule ID. | | `Webhook not found` | The webhook ID is wrong or in another project. | Check the webhook ID. See [I'm not receiving webhooks](/docs/troubleshooting/not-receiving-webhooks/). | | `Pinterest account not found` | The `account_id` is wrong, not in this workspace, or revoked. | Confirm the account is connected. See [My Pinterest account got disconnected](/docs/troubleshooting/account-disconnected/). | | `Project not found` / `Sandbox project not found` | The project (or sandbox) does not exist for this user. | Check the project, or create a sandbox first. | | `API key not found` | The key ID is wrong or already revoked. | Check the key ID. | | `Workspace not found` | The workspace behind the request cannot be resolved. | Confirm your key or session maps to a valid project. | Tip: `GET /v1/rate-meter` also returns 404 (`Pinterest account not found`) when there is no connected account to query. Connect an account first. ## 409 Conflict The request conflicts with existing state. | Detail string | Cause | Fix | |---|---|---| | `Only failed pins can be retried` | You called retry on a pin that is not in the `failed` state. | Only retry failed pins. Wait for auto-retries on `queued`/`deferred` pins. See [My pin is stuck in queued](/docs/troubleshooting/pin-stuck-in-queued/). | | `Account already exists for this email` | Signup or email change used an email that is already registered. | Sign in with that email, or use a different one. | | `That email is no longer available. Request a new email change.` | The target email was taken between request and confirmation. | Start the email change again. | | `You are already part of this organization.` | You accepted an invite to an org you already belong to. | No action needed. | | `That user is already a member of this organization.` | You invited an existing member. | No action needed. | | `An active invitation already exists for that email.` | A pending invite already exists. | Wait for it to be accepted, or revoke and re-invite. | | `Organization production project is missing` | The organization has no production project. | Contact support to restore the production project. | ## 422 Unprocessable Entity The request body failed validation. FastAPI returns an array of field errors under `detail`. Fix the field and retry. | Field / rule | Cause | Fix | |---|---|---| | `account_id`, `board_id`, `title`, `idempotency_key` required | A required PinCreate field is missing. | Send all four required fields. `idempotency_key` is never auto-generated. | | `Either image_url or asset_id must be provided` | Neither media source was set. | Provide exactly one of `image_url` or `asset_id`. | | `Provide either image_url or asset_id, not both` | Both media sources were set. | Send exactly one. | | `Provide either cover_image_url or cover_image_asset_id, not both` | Both cover fields were set. | Send at most one cover field (video pins only). | | `dominant_color must be a 6-digit hex color` | The color is not a 6-digit hex value. | Send a hex color like `#AABBCC` (leading `#` optional). | | `link_url must be <= 2048 characters` | The link URL is too long. | Shorten the URL to 2048 characters or fewer. | | `cover_image_url must be <= 2048 characters` | The cover URL is too long. | Shorten to 2048 characters or fewer. | | `title` max 100 | The title is over 100 characters. | Trim the title. | | `description` max 800 | The description is over 800 characters. | Trim the description. | | `alt_text` max 500 | The alt text is over 500 characters. | Trim the alt text. | | `idempotency_key` max 255 | The key is over 255 characters. | Use a shorter key. | | Webhook `secret` (16-255 chars) required | The webhook secret is missing or under 16 characters. | Provide a secret 16 to 255 characters long. See [I'm not receiving webhooks](/docs/troubleshooting/not-receiving-webhooks/). | | Webhook `url` invalid | The webhook URL is not a valid HTTP(S) URL. | Send a valid URL. | | `Owner role cannot be assigned by invitation` | You invited someone as owner. | Invite as admin, operator, or client. | | `At least one terms value is required` | A related-terms lookup had no terms. | Supply at least one term. | ## 429 Too Many Requests You hit a rate limit. All 429 responses include a `Retry-After` header. Wait that long, then retry. | Detail string | Cause | Fix | |---|---|---| | `Too many sign-ups from this network. Please try again later.` | Too many signups from one IP. | Wait and retry, per `Retry-After`. | | Password reset rate limit | Too many password reset requests. | Wait, per `Retry-After`. | | `Please wait before requesting another verification email.` | Verification email requested too often. | Wait, per `Retry-After`. | | `Please wait before requesting another email change.` | Email change requested too often. | Wait, per `Retry-After`. | Note: pin-publish rate limiting from Pinterest is handled internally with automatic retries (the pin goes `queued` then `deferred`), not surfaced as a 429 on `POST /v1/pins`. See [My pin is stuck in queued](/docs/troubleshooting/pin-stuck-in-queued/). ## 500 Internal Server Error Something failed on our side or upstream at Pinterest. These responses include a `request_id`. Most are transient, so retry after a short wait; if it persists, contact support with the `request_id`. | Detail string | Cause | Fix | |---|---|---| | `Internal server error` (with `request_id`) | An uncaught error. | Retry. If it persists, share the `request_id` with support. | | `OAuth callback failed: ...` | Pinterest OAuth exchange failed upstream. | Retry the connect flow. See [My Pinterest account got disconnected](/docs/troubleshooting/account-disconnected/). | | `Failed to fetch boards: ...` | Pinterest boards call failed upstream. | Retry shortly. | | `Failed to fetch related terms: ...` | Pinterest related-terms call failed upstream. | Retry shortly. | | `Failed to create board: ...` | Pinterest board create failed upstream. | Retry shortly. | | `Failed to delete board: ...` | Pinterest board delete failed upstream. | Retry shortly. | ## 503 Service Unavailable The service or a dependency is temporarily unavailable. | Detail string | Cause | Fix | |---|---|---| | Enqueue failure on pin retry | The pin could not be queued; it is set back to `failed`. | Retry the pin again shortly. See [My pin failed](/docs/troubleshooting/my-pin-failed/). | | `Admin API access is not configured` | The admin API is not configured on this deployment. | Applies to platform admins only. | ## Pin failure error codes (not HTTP) When a pin's HTTP request succeeds but publishing later fails, the failure shows up on the pin object as `error_code` and `error_message`, not as an HTTP error. See [My pin failed](/docs/troubleshooting/my-pin-failed/) for the full list and what to do about each. Common values: `rate_limited`, `token_expired`, `token_revoked`, `scope_missing`, `board_access_denied`, `transient_upstream`, `media_url_temporarily_unavailable`, `media_url_unreachable`, `resource_not_found`, `invalid_payload`, `publish_timeout`, and a fallback `api_error`. ## Next steps - [Troubleshooting](/docs/troubleshooting/) — task-focused fixes by symptom. - [My pin failed](/docs/troubleshooting/my-pin-failed/) — read a pin's `error_code`. - [402 quota_exceeded](/docs/troubleshooting/quota-exceeded-402/) — quota, credits, and upgrades. ================================================================================ # Guides URL: https://www.pinbridge.io/docs/guides/ ================================================================================ # Guides Task-based guides for building on PinBridge. - [Upload media](/docs/guides/upload-media/) — upload images and video, then publish by asset ID. - [Publish pins](/docs/guides/publish-pins/) — every PinCreate field and media option. - [Schedule pins](/docs/guides/schedule-pins/) — publish at a future time. - [Bulk imports](/docs/guides/bulk-imports/) — CSV and JSON imports with per-row results. - [Webhooks](/docs/guides/webhooks/) — events, signature verification, retries. - [Retries and failures](/docs/guides/retries-and-failures/) — the pin state machine and failure reasons. - [Multiple accounts](/docs/guides/multiple-accounts/) — agencies and many Pinterest accounts. - [Team and roles](/docs/guides/team-and-roles/) — roles and permissions. - [Activity logs](/docs/guides/activity-logs/) — audit trail and retention. ================================================================================ # Activity logs URL: https://www.pinbridge.io/docs/guides/activity-logs/ ================================================================================ # Activity logs The activity log records what happened in your project: who published, who changed settings, and what billing or integration events occurred. This page shows how to query it, filter it, and page through results. ## Prerequisites - Your API key in the `X-API-Key` header. - How far back the log goes depends on your plan (see Retention). ## The endpoint ``` GET /v1/activity-logs ``` ```bash curl "https://api.pinbridge.io/v1/activity-logs?limit=50" \ -H "X-API-Key: $PINBRIDGE_API_KEY" ``` Expected response (trimmed): ```json { "items": [ { "action": "pin.publish_succeeded", "category": "publishing", "status": "success", "actor_type": "api_key", "resource_type": "pin", "created_at": "2026-09-11T10:15:00Z" } ], "next_cursor": "eyJ...", "current_retention_days": 90, "current_retention_label": "90 days" } ``` `current_retention_days` and `current_retention_label` reflect your plan's retention window. ## Retention per plan Each log entry stamps its own retention window and expiry at write time, based on the plan in effect. Retention is **not** a flat 90 days. | Plan | Retention | |---|---| | Playground (Free) | 7 days | | Starter | 30 days | | Growth | 90 days | | Agency | 180 days | | Enterprise | 365 days | The 90-day figure is only the Growth default. Entries older than your window are not returned. ## Categories Every entry has one category: - **security** — sign-in, email verification, and similar account-security events. - **publishing** — pin publishes, deletes, retries, imports, and schedules. - **billing** — plan and payment events. - **configuration** — settings changes such as API keys and webhooks. - **integration** — Pinterest account connect, revoke, and related events. ## Statuses and actors - **Statuses:** `success`, `failed`, `queued`, `canceled`. - **Actor types:** `user`, `api_key`, `system`, `worker`, `admin`, `billing`. This tells you whether a human, an API key, or a background worker performed the action. `action` is a free-form string, for example `pin.publish_succeeded`, `pin.publish_failed`, `pin.deleted`, `import.created`, `webhook.created`, `asset.uploaded`, `schedule.created`, or `pinterest.account_connected`. ## Filters All filters are query parameters: | Parameter | Notes | |---|---| | `limit` | 1 to 200, default 50. | | `cursor` | Opaque cursor for the next page (see below). | | `category` | One of the categories above. | | `action` | Exact action string, 1 to 128 characters. | | `status` | One of the statuses above. | | `resource_type` | Resource type string, 1 to 64 characters (e.g. `pin`, `webhook`). | | `since` | ISO 8601 datetime; returns entries with `created_at >= since`. | Example: only failed publishes in the last day. ```bash curl -G "https://api.pinbridge.io/v1/activity-logs" \ -H "X-API-Key: $PINBRIDGE_API_KEY" \ --data-urlencode "category=publishing" \ --data-urlencode "status=failed" \ --data-urlencode "since=2026-09-10T00:00:00Z" ``` ## Pagination is cursor based Activity logs use **cursor (keyset) pagination**, not offset pagination. Results are ordered newest first. When a response includes a non-null `next_cursor`, pass it back as the `cursor` parameter to fetch the next page. Stop when `next_cursor` is null. ```bash # Page 2, using next_cursor from page 1 curl -G "https://api.pinbridge.io/v1/activity-logs" \ -H "X-API-Key: $PINBRIDGE_API_KEY" \ --data-urlencode "cursor=eyJ..." \ --data-urlencode "limit=50" ``` There is no `total`, `offset`, or page-number field. Keep following `next_cursor` to walk the full history. ## Python SDK ```python from pinbridge_sdk import PinbridgeClient with PinbridgeClient(api_key="pb_your_api_key") as client: cursor = None while True: page = client.activity_logs.list( limit=100, cursor=cursor, category="publishing", status="failed", ) for entry in page["items"]: print(entry["created_at"], entry["action"]) cursor = page.get("next_cursor") if not cursor: break ``` `client.activity_logs.list` accepts `limit`, `cursor`, `category`, `action`, `status`, `resource_type`, and `since`. ## Next steps - [Retries and failures](/docs/guides/retries-and-failures/) — trace failed publishes you find in the log. - [Team and roles](/docs/guides/team-and-roles/) — see which teammate performed an action. - [Bulk imports (CSV and JSON)](/docs/guides/bulk-imports/) — audit `import.created` events from your imports. ================================================================================ # Bulk imports (CSV and JSON) URL: https://www.pinbridge.io/docs/guides/bulk-imports/ ================================================================================ # Bulk imports (CSV and JSON) Bulk imports let you queue many pins in a single request instead of calling the publish endpoint once per pin. This page covers both import formats, the exact schema, and how to read the results. ## Prerequisites - A paid plan. Bulk imports use the `bulk_publishing` feature, which is off on the Free (Playground) plan. A Free-plan key gets a **403** `feature_unavailable`. Bulk imports are available on Starter, Growth, Agency, and Enterprise. - A connected Pinterest account (you need its `account_id`) and a `board_id` to publish to. - Your API key in the `X-API-Key` header. For a single pin, use `POST /v1/pins` instead. See [Publish your first pin](/docs/n8n/publish-your-first-pin/). ## The two import endpoints | Format | Endpoint | Body | Response | |---|---|---|---| | JSON | `POST /v1/pins/imports/json` | A raw JSON array of row objects | 202 Accepted with a job | | CSV | `POST /v1/pins/imports/csv` | `multipart/form-data` with a `file` field | 202 Accepted with a job | Both endpoints return **202 Accepted** and process asynchronously. You get back an import job, then poll it for per-row results. - Check one job: `GET /v1/pins/imports/{job_id}` - List jobs: `GET /v1/pins/imports` ## Per-row schema Each row is a pin. The required and optional fields are the same as a normal pin create, plus one import-only field (`run_at`). **Required in every row:** - `account_id` — the Pinterest account UUID to publish with. - `board_id` — the destination board. - `title` — the pin title, max 100 characters. - `idempotency_key` — a string you choose, max 255 characters. This is required and is never auto-generated. It is what dedupe keys on (see below). Each row also needs **exactly one** media source: either `image_url` or `asset_id`. Supplying both, or neither, fails that row. **Optional per row:** - `description` (max 800), `alt_text` (max 500), `dominant_color` (6-digit hex), `link_url` (max 2048). - `cover_image_url` or `cover_image_asset_id` (video pins only; provide at most one). - `run_at` — an ISO 8601 timestamp with a timezone offset. If set, it must be in the future, and the row becomes a schedule instead of an immediate publish. Omit it (or leave the CSV cell blank) to publish right away. ## CSV format The CSV must be UTF-8 (a BOM is accepted) and have a header row. **Required columns:** `account_id`, `board_id`, `title`, `idempotency_key`. Missing any of these returns **400** "CSV is missing required columns". **Allowed columns** (anything outside this set returns **400** "CSV contains unsupported columns"): `account_id`, `board_id`, `title`, `description`, `related_terms`, `alt_text`, `dominant_color`, `cover_image_url`, `cover_image_asset_id`, `link_url`, `image_url`, `asset_id`, `idempotency_key`, `run_at`. `related_terms` stays allowed so older files keep importing, but its value is ignored. Blank cells are treated as empty (no value). All-blank rows are skipped. Duplicate column names, a whitespace-only header, or an empty file each return **400**. ### Downloadable sample Start from this valid 3-row example: [sample-import.csv](/wp-content/uploads/pinbridge-sample-import.csv). Replace `6f1c2e4a-3b7d-4c9e-8a21-5d0f9b3e7c41` and `987654321098765432` with your real IDs. ```csv account_id,board_id,title,idempotency_key,image_url,description,link_url,run_at 6f1c2e4a-3b7d-4c9e-8a21-5d0f9b3e7c41,987654321098765432,Autumn table setting,import-2026-001,https://example.com/images/autumn-table.jpg,Warm autumn table styling ideas,https://example.com/blog/autumn-table, 6f1c2e4a-3b7d-4c9e-8a21-5d0f9b3e7c41,987654321098765432,Minimalist desk setup,import-2026-002,https://example.com/images/desk-setup.jpg,Clean desk inspiration for a focused workspace,https://example.com/blog/desk-setup, 6f1c2e4a-3b7d-4c9e-8a21-5d0f9b3e7c41,987654321098765432,Weekend brunch spread,import-2026-003,https://example.com/images/brunch.jpg,Easy brunch recipes for the weekend,https://example.com/blog/brunch,2026-12-01T09:00:00+00:00 ``` The first two rows publish immediately. The third has a future `run_at`, so it becomes a schedule. ## Limits - **Maximum 500 rows** per import, for both CSV and JSON. Over 500 returns **400** "Import exceeds the maximum of 500 rows". An empty import also returns 400. - There is no documented byte-size limit for the CSV upload in the API itself. Any file-size cap is enforced at the reverse-proxy or server layer, not in the app. ## Upload a CSV (curl) ```bash curl -X POST https://api.pinbridge.io/v1/pins/imports/csv \ -H "X-API-Key: $PINBRIDGE_API_KEY" \ -F "file=@sample-import.csv;type=text/csv" ``` Expected response (trimmed): ```json { "id": "4c8f2a6e-9b1d-4f3a-b5c7-0e2d6a8f4b92", "status": "queued", "total_rows": 3, "processed_rows": 0, "created_rows": 0, "existing_rows": 0, "failed_rows": 0 } ``` ## Upload JSON (curl) The JSON body is a raw array of row objects. ```bash curl -X POST https://api.pinbridge.io/v1/pins/imports/json \ -H "X-API-Key: $PINBRIDGE_API_KEY" \ -H "Content-Type: application/json" \ -d '[ { "account_id": "6f1c2e4a-3b7d-4c9e-8a21-5d0f9b3e7c41", "board_id": "987654321098765432", "title": "Autumn table setting", "idempotency_key": "import-2026-001", "image_url": "https://example.com/images/autumn-table.jpg" }, { "account_id": "6f1c2e4a-3b7d-4c9e-8a21-5d0f9b3e7c41", "board_id": "987654321098765432", "title": "Weekend brunch spread", "idempotency_key": "import-2026-003", "image_url": "https://example.com/images/brunch.jpg", "run_at": "2026-12-01T09:00:00+00:00" } ]' ``` ## Check job status and read results ```bash curl https://api.pinbridge.io/v1/pins/imports/4c8f2a6e-9b1d-4f3a-b5c7-0e2d6a8f4b92 \ -H "X-API-Key: $PINBRIDGE_API_KEY" ``` Expected response (trimmed): ```json { "id": "4c8f2a6e-9b1d-4f3a-b5c7-0e2d6a8f4b92", "status": "completed_with_errors", "total_rows": 3, "processed_rows": 3, "created_rows": 2, "existing_rows": 0, "failed_rows": 1, "results": [ {"row_number": 1, "status": "created", "pin_id": "8a3f6c12-4d5e-4b7a-9c01-2e6f8d4b7a93", "idempotency_key": "import-2026-001"}, {"row_number": 2, "status": "created", "pin_id": "1d9b4e7f-6a2c-4f8e-b5d3-9c0a7e2f4b16", "idempotency_key": "import-2026-002"}, {"row_number": 3, "status": "failed", "idempotency_key": "import-2026-003", "error_code": "validation_error", "error_message": "run_at must be in the future"} ] } ``` ### Per-row status Each row result carries `row_number`, `status`, `pin_id`, `schedule_id`, `idempotency_key`, `error_code`, and `error_message`. The `status` is one of: - **created** — a new pin (or schedule, for a `run_at` row) was created. - **existing** — a matching pin or schedule already existed; nothing new was created (see dedupe). - **failed** — the row was rejected. Read `error_code` and `error_message` for the reason. ### Job status The job-level `status` is one of: | Job status | Meaning | |---|---| | `queued` | Accepted, not started yet. | | `processing` | Rows are being processed. | | `completed` | All rows processed, none failed. | | `completed_with_errors` | Finished, but at least one row failed. | | `failed` | The whole job crashed before finishing. | Processing is resumable and commits per row, so a job continues from where it left off rather than reprocessing rows. ## Dedupe and idempotency Dedupe keys on the pair **(project, `idempotency_key`)**. Before creating a row's pin, PinBridge looks for an existing pin in the same project with the same `idempotency_key`. If one exists, the row is marked **existing** and points at that pin. Scheduled rows dedupe the same way against existing schedules. This means re-running the same import is safe: rows that already produced a pin come back as `existing` instead of creating duplicates. Choose stable, unique `idempotency_key` values (for example a product ID or a content slug) so the same source content always maps to the same key. ## Retrying failed rows There is no "retry the whole job" endpoint. To retry, act on the individual rows: - **Validation failures** (bad or missing fields, a past `run_at`): fix the source data and re-submit those rows as a new import. Rows that already succeeded return `existing`, so you can safely re-submit the full file. - **Pins that were created but then failed to publish**: retry them as pins. Use `POST /v1/pins/{pin_id}/retry` for one, or `POST /v1/pins/bulk-retry` for many. See [Retries and failures](/docs/guides/retries-and-failures/). ## Python SDK ```python from pinbridge_sdk import PinbridgeClient with PinbridgeClient(api_key="pb_your_api_key") as client: # JSON import: pass a list of row dicts job = client.pins.import_json([ { "account_id": "6f1c2e4a-3b7d-4c9e-8a21-5d0f9b3e7c41", "board_id": "987654321098765432", "title": "Autumn table setting", "idempotency_key": "import-2026-001", "image_url": "https://example.com/images/autumn-table.jpg", }, ]) # CSV import: pass a file handle with open("sample-import.csv", "rb") as f: job = client.pins.import_csv(f) # Poll for results result = client.pins.get_import(job["id"]) print(result["status"], result["failed_rows"]) ``` The SDK does not read environment variables, so pass `api_key` explicitly. `idempotency_key` is required on every row. ## n8n The **PinBridge** node covers imports under the **Pin** resource: - **Pin → Import JSON** (`POST /v1/pins/imports/json`) sends every incoming item's JSON as one import array. Each item is a row. - **Pin → Import CSV** (`POST /v1/pins/imports/csv`) uploads a CSV from a binary property. Set **Binary Property** to the field holding the file (default `data`). - **Pin → Get Import** and **Pin → List Imports** poll job status. List Imports supports **Import Status** and **Import Source** filters. For a full n8n walkthrough see [Bulk import pins in n8n](/docs/n8n/bulk-import/). ## Common errors | Symptom | Cause | Fix | |---|---|---| | 403 `feature_unavailable` | Free plan | Upgrade to a plan with `bulk_publishing`. | | 400 "missing required columns" | CSV lacks a required column | Include `account_id`, `board_id`, `title`, `idempotency_key`. | | 400 "unsupported columns" | Column outside the allowed set | Remove it or rename to an allowed column. | | 400 "exceeds the maximum of 500 rows" | Too many rows | Split into multiple imports. | | Row `failed` with `validation_error` | A field failed validation (e.g. past `run_at`, both/neither media source) | Fix that row and re-submit. | ## Next steps - [Retries and failures](/docs/guides/retries-and-failures/) — the pin state machine and how to retry failed pins. - [Activity logs](/docs/guides/activity-logs/) — audit what your imports created. - [Bulk import pins in n8n](/docs/n8n/bulk-import/) — the same flow without code. - [Node reference](/docs/n8n/node-reference/) — every n8n operation and parameter. ================================================================================ # Managing multiple Pinterest accounts URL: https://www.pinbridge.io/docs/guides/multiple-accounts/ ================================================================================ # Managing multiple Pinterest accounts Agencies and multi-brand publishers can connect several Pinterest accounts to a single PinBridge project and publish to all of them with one API key. This page explains how many accounts each plan allows and how to keep everything under one roof. ## One project, many Pinterest accounts In PinBridge, an organization contains a **project**, and you connect one or more **Pinterest accounts** to that project. Each pin you publish names the `account_id` it should go out on, so a single API key can drive every connected account. You do **not** need a separate PinBridge login per client or per brand. Connect each Pinterest account to the same project, and publish to each by its `account_id`. ## Account limits per plan The number of Pinterest accounts you can connect is set by your plan: | Plan | Connected Pinterest accounts | |---|---| | Playground (Free) | 1 | | Starter | 2 | | Growth | 5 | | Agency | 15 | | Enterprise | 50 | (The `plan` enum value for Agency is `pro`; Enterprise is sold as "Custom".) Trying to connect one account past your plan limit returns **403** `plan_limit` with a message naming the limit and your current count. Upgrade the plan to raise the ceiling. ## List your connected accounts ```bash curl https://api.pinbridge.io/v1/pinterest/accounts \ -H "X-API-Key: $PINBRIDGE_API_KEY" ``` Expected response (trimmed): ```json [ {"id": "6f1c2e4a-3b7d-4c9e-8a21-5d0f9b3e7c41", "username": "brand-one", "status": "active"}, {"id": "0b8e5d27-91f4-4a6c-b3d2-7e1a4c9f6d58", "username": "brand-two", "status": "active"} ] ``` An API key with no connected accounts returns an empty array `[]`, not a 404, so this is a safe first call to test a key. ### Python SDK ```python from pinbridge_sdk import PinbridgeClient with PinbridgeClient(api_key="pb_your_api_key") as client: for account in client.pinterest.list_accounts(): print(account["id"], account["username"]) ``` ### n8n Use **Connection → List** to enumerate accounts, then feed each `id` into a publish or schedule step as the account. **Rate Meter → Get** reports the live rate budget per account, which helps you pace publishing across many accounts. See the [Node reference](/docs/n8n/node-reference/). ## Consolidate on one account, not many If you are running several separate PinBridge accounts to get around per-account limits, consolidate them into a single PinBridge account on the right plan instead. High-volume users should connect all of their Pinterest accounts under one PinBridge account on a plan sized for the volume (for example Agency or Enterprise). One account is easier to bill, gives you one place for activity logs and team access, and keeps your rate budget coordinated. ## Shared server IPs are expected and safe Pinterest identifies an account by its OAuth token, not by IP address. PinBridge publishes through shared server IPs, which is exactly what a registered Pinterest API application is expected to do. Using PinBridge alongside other customers on shared IPs is not a risk to your accounts. Learn more: [Is it safe that PinBridge uses shared IPs?](/docs/troubleshooting/shared-ips-safe/). ## Watch per-account rate budgets Each connected account has its own token-bucket rate budget. Check it before a large burst: ```bash curl "https://api.pinbridge.io/v1/rate-meter?account_id=6f1c2e4a-3b7d-4c9e-8a21-5d0f9b3e7c41" \ -H "X-API-Key: $PINBRIDGE_API_KEY" ``` Note your monthly pin quota is shared across the whole project (it counts successful publishes), while the rate meter is per account. Plan your imports so no single account starves the others. ## Next steps - [Team and roles](/docs/guides/team-and-roles/) — invite teammates and control who can publish to which accounts. - [Bulk imports (CSV and JSON)](/docs/guides/bulk-imports/) — publish to many accounts at once by setting `account_id` per row. - [Activity logs](/docs/guides/activity-logs/) — see who connected or published on each account. - [PinBridge for n8n](/docs/n8n/) — automate multi-account publishing without code. ================================================================================ # Publish pins URL: https://www.pinbridge.io/docs/guides/publish-pins/ ================================================================================ # Publish pins Create a pin with `POST /v1/pins`. This page covers every field on the request, the media options, and the state a pin moves through after you submit it. ## Prerequisites - An API key in the `X-API-Key` header. See [Authentication](/docs/authentication/). - A connected Pinterest account (`account_id`) and a board on it (`board_id`). - Media: either a public `image_url`, or an uploaded `asset_id`. See [Upload media](/docs/guides/upload-media/). ## Pin fields Every request is a `PinCreate` object. ### Required | Field | Type | Notes | |---|---|---| | `account_id` | UUID | The connected Pinterest account to publish with. | | `board_id` | string | The Pinterest board id. | | `title` | string | Max 100 characters. | | `idempotency_key` | string | Max 255 characters. **Required**, never auto-generated. Reusing a key returns the existing pin instead of creating a duplicate. | | one of `image_url` / `asset_id` | string | Exactly one is required (see Media options). | ### Optional | Field | Type | Notes | |---|---|---| | `description` | string | Max 800 characters. | | `alt_text` | string | Max 500 characters. Accessibility text. | | `dominant_color` | string | 6-digit hex, optional leading `#` (for example `#6E7874`); normalized to upper-case. | | `link_url` | string | Destination URL, max 2048 characters. | | `cover_image_url` | string | Public cover image URL. **Video pins only.** | | `cover_image_asset_id` | UUID | Uploaded **image** asset used as a video cover. **Video pins only.** | > **`related_terms` is deprecated.** Pinterest doesn't list it for creating or reading a pin, so PinBridge stopped sending it in API 1.65.1. A request that still includes it keeps working and the value is ignored. Pin responses keep the field for now: `null` for new pins, the stored value for older ones. To find keyword ideas for your titles and descriptions, use [List Related Terms](/docs/api/pinterest/). ## Media options Provide the pin's media in exactly one way: - **Public image URL** — set `image_url`. Works on every plan. - **Uploaded asset** — set `asset_id` from an uploaded image or video. Paid plans only; see [Upload media](/docs/guides/upload-media/). Notes: - Provide **exactly one** of `image_url` / `asset_id`. Both missing or both present each return `422`. - A **video pin** is created by referencing an uploaded video `asset_id`. There is no `video_url` field. - Cover images (`cover_image_url` / `cover_image_asset_id`) apply only to video pins, and you may set at most one of the two. > **Heads up: Pinterest can change your title.** When your pin has a `link_url`, Pinterest re-scrapes that destination page and may **override your pin title** with the page's Open Graph / meta tags. If your published title doesn't match what you sent, this is almost always why. Control it by setting good `og:title` (and `og:description`) meta tags on the destination page. This is the most common "my title changed" surprise. ## Publish a pin ### curl ```bash curl -X POST https://api.pinbridge.io/v1/pins \ -H "X-API-Key: $PINBRIDGE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "account_id": "6f1c2e4a-3b7d-4c9e-8a21-5d0f9b3e7c41", "board_id": "987654321098765432", "title": "My first PinBridge pin", "description": "A short, useful description.", "link_url": "https://example.com/product", "image_url": "https://example.com/pin.png", "idempotency_key": "publish-demo-001" }' ``` Response (`201`, trimmed): ```json { "id": "8a3f6c12-4d5e-4b7a-9c01-2e6f8d4b7a93", "status": "queued", "media_type": "image", "title": "My first PinBridge pin", "board_id": "987654321098765432", "idempotency_key": "publish-demo-001", "pinterest_pin_id": null, "error_code": null } ``` The pin is accepted as `queued` and published by a background worker. Poll its status or use a [webhook](/docs/guides/webhooks/) to learn the outcome. ### Python SDK ```python from pinbridge_sdk import PinbridgeClient from pinbridge_sdk.models import PinCreate with PinbridgeClient(api_key="$PINBRIDGE_API_KEY") as client: pin = client.pins.create( PinCreate( account_id="6f1c2e4a-3b7d-4c9e-8a21-5d0f9b3e7c41", board_id="987654321098765432", title="My first PinBridge pin", image_url="https://example.com/pin.png", # or asset_id="e3a7c5f9-1b4d-4c8e-a2f6-7d9b3e5a1c80" link_url="https://example.com/product", idempotency_key="publish-demo-001", ) ) print(pin.id, pin.status) ``` `idempotency_key` is a required field on `PinCreate`. To publish a video pin, drop `image_url` and pass `asset_id` for an uploaded video. ### n8n Use the **PinBridge** node with **Resource: Pin**, **Operation: Publish**. Choose the **Media Source** (Uploaded Asset or Public Image URL) and fill in **Title**, **Board**, and the account **Connection**. The **Idempotency Key** field defaults to `={{$execution.id + "-" + $itemIndex}}`, which gives each item a stable, unique key. ## Pin states A pin moves through these statuses: - `queued` → `publishing` → `published` on success (sets `pinterest_pin_id` and `published_at`). - `failed` on a non-retryable error (sets `error_code` and `error_message`). - Retryable errors move the pin to `deferred` and auto-retry, up to 8 deferrals, before finally becoming `failed`. Common failure `error_code` values include `rate_limited`, `token_expired`, `token_revoked`, `scope_missing`, `board_access_denied`, `transient_upstream`, `media_url_unreachable`, `invalid_payload`, and `publish_timeout`. See [Rate limits and quotas](/docs/rate-limits/) for how retries and quota interact. To reprocess failures: `POST /v1/pins/{pin_id}/retry` (only `failed` pins; otherwise `409`), or the bulk endpoints `POST /v1/pins/bulk-retry` and `POST /v1/pins/bulk-delete`. ## Verify it worked - Fetch the pin with `GET /v1/pins/{pin_id}` (SDK: `client.pins.get(pin_id)`) and check `status`. - When `status` is `published`, `pinterest_pin_id` holds the id of the live Pinterest pin. - For push notifications instead of polling, subscribe to `pin.published` and `pin.failed` [webhooks](/docs/guides/webhooks/). ## Common errors | Status | Cause | |---|---| | 422 | `Either image_url or asset_id must be provided` / `Provide either image_url or asset_id, not both`. | | 422 | Missing a required field such as `account_id`, or `title` over 100 chars. | | 403 | `feature_unavailable` — used an `asset_id` on the Free plan. Use a public `image_url` or upgrade. | | 400 | `cover_image_url is only supported for video pins`. | ## Next steps - [Upload media](/docs/guides/upload-media/) — host images and videos and publish by `asset_id`. - [Schedule pins](/docs/guides/schedule-pins/) — publish the same payload at a future time. - [Webhooks](/docs/guides/webhooks/) — get `pin.published` / `pin.failed` events. - [Rate limits and quotas](/docs/rate-limits/) — publishing quotas and retry behavior. ================================================================================ # Retries and failures URL: https://www.pinbridge.io/docs/guides/retries-and-failures/ ================================================================================ # Retries and failures When a pin does not publish, PinBridge either retries it for you or marks it failed with an error code you can act on. This page explains the pin state machine, when retries happen automatically, and how to retry manually. ## The pin state machine Every pin moves through these states: - **queued** — created and waiting for a worker. - **publishing** — the worker is calling Pinterest right now. - **published** — success. The pin has a `pinterest_pin_id` and a `published_at`. - **deferred** — a retryable error kept failing, so the pin is parked for a longer retry. - **failed** — terminal. The pin has an `error_code` and `error_message`. The happy path is `queued → publishing → published`. A retryable error sends the pin back to `queued` (and, after several attempts, to `deferred`). A non-retryable error goes straight to `failed`. ``` queued ──▶ publishing ──▶ published ▲ │ │ (retryable)│ (non-retryable) └── deferred ◀── (after retries) failed exhausted ──▶ failed ``` ## Automatic retry PinBridge classifies each publish error as retryable or not. **Retryable errors** (`rate_limited`, `transient_upstream`, `media_url_temporarily_unavailable`, and similar) are retried for you: 1. The pin resets to `queued` and is re-enqueued after about 60 seconds (or the Pinterest-supplied `retry_after`, capped at 300 seconds), plus up to 15 seconds of jitter. This happens up to 3 times. 2. If it is still failing after those attempts, the pin is parked as `deferred` and retried after about 1 hour (or `retry_after` if larger), with the attempt counter reset. 3. A pin can be deferred up to **8 times**. After the 8th deferral it is set to `failed`. For a live Pinterest 429, the whole account is briefly paused for the `retry_after` window before the next attempt. There is also a backstop: a reconciler runs every 120 seconds. Pins stuck in `queued`/`publishing` for more than 10 minutes are re-driven back to `queued`; pins stuck for more than 6 hours are failed with `error_code` = `publish_timeout`. You do not call anything for automatic retries. They apply only to retryable errors. A non-retryable error fails immediately. ## Manual retry Only **failed** pins can be retried manually. Retrying a pin in any other state returns **409** "Only failed pins can be retried". Retry one pin: ```bash curl -X POST https://api.pinbridge.io/v1/pins/8a3f6c12-4d5e-4b7a-9c01-2e6f8d4b7a93/retry \ -H "X-API-Key: $PINBRIDGE_API_KEY" ``` Expected response (trimmed): ```json {"id": "8a3f6c12-4d5e-4b7a-9c01-2e6f8d4b7a93", "status": "queued", "error_code": null, "error_message": null} ``` Retrying clears the error fields, `pinterest_pin_id`, and `published_at`, then re-queues the pin. You can override the board or account on retry, which is useful when the failure was `board_access_denied`: ```bash curl -X POST https://api.pinbridge.io/v1/pins/8a3f6c12-4d5e-4b7a-9c01-2e6f8d4b7a93/retry \ -H "X-API-Key: $PINBRIDGE_API_KEY" \ -H "Content-Type: application/json" \ -d '{"board_id": "987654321098765433"}' ``` ## Bulk retry Retry many failed pins in one call: ```bash curl -X POST https://api.pinbridge.io/v1/pins/bulk-retry \ -H "X-API-Key: $PINBRIDGE_API_KEY" \ -H "Content-Type: application/json" \ -d '{"ids": ["8a3f6c12-4d5e-4b7a-9c01-2e6f8d4b7a93", "1d9b4e7f-6a2c-4f8e-b5d3-9c0a7e2f4b16", "5e7a1c3d-8b4f-4d2a-a6e9-3f1b8c5d7e20"]}' ``` Each item comes back in one of three buckets: - **succeeded** — reset to `queued` and re-enqueued. - **skipped** with `error_code` `ineligible_status` — the pin was not in `failed` state. - **failed** with `error_code` `not_found` — no such pin in your project. There is also `POST /v1/pins/bulk-delete` if you would rather remove failed pins than retry them. ## Python SDK ```python from pinbridge_sdk import PinbridgeClient from pinbridge_sdk.models import PinRetryRequest with PinbridgeClient(api_key="pb_your_api_key") as client: # Retry one, optionally moving it to another board client.pins.retry("8a3f6c12-4d5e-4b7a-9c01-2e6f8d4b7a93", PinRetryRequest(board_id="987654321098765433")) # Retry many client.pins.bulk_retry(["8a3f6c12-4d5e-4b7a-9c01-2e6f8d4b7a93", "1d9b4e7f-6a2c-4f8e-b5d3-9c0a7e2f4b16"]) ``` ## Failure error_code reference When a pin is `failed`, its `error_code` tells you the cause. Codes marked "auto-retried" are attempted for you before the pin lands in `failed`. | error_code | Cause | Fix | |---|---|---| | `rate_limited` | Pinterest returned a rate-limit (429). | Auto-retried, and the account is paused for the retry window. If it still fails, slow your publish rate. | | `token_expired` | The Pinterest OAuth token has expired. | Reconnect the Pinterest account. | | `token_revoked` | The token was revoked at Pinterest. | Reconnect the Pinterest account. | | `scope_missing` | The OAuth token is missing a required permission scope. | Reconnect the account and grant all requested permissions. | | `board_access_denied` | The account cannot write to that board. | Confirm the board belongs to the account, or retry with a different `board_id`. | | `transient_upstream` | A temporary Pinterest 5xx or "temporarily unavailable" error. | Auto-retried; usually clears on its own. Retry manually if it reaches `failed`. | | `media_url_temporarily_unavailable` | Pinterest could not yet fetch PinBridge-hosted media. | Auto-retried after about 120 seconds. | | `media_url_unreachable` | Pinterest could not fetch the media URL at all. | Make the image or video URL publicly reachable (returns 200), then retry. | | `resource_not_found` | A referenced Pinterest resource is missing. | Verify the board and account exist, then retry. | | `invalid_payload` | Pinterest rejected the pin data. | Correct the pin fields (title, link, media) and retry. | | `sandbox_board_not_allowed` | Board writes are disabled in the sandbox. | Use a simulated board, or enable sandbox board writes. | | `pinterest_board_environment_mismatch` | The board belongs to a different (sandbox vs live) environment. | Use a board created in the same environment as the request. | | `publish_timeout` | The pin was stuck too long (over 6 hours) and the reconciler failed it. | Retry the pin. | | `account_not_found` | The connected account no longer exists or was revoked. | Reconnect the account, then retry. | | `missing_video_asset` | A video pin was created without a video asset. | Provide a valid video `asset_id` and retry. | | `asset_not_found` | The referenced asset is missing. | Re-upload the asset or correct the `asset_id`, then retry. | | `queue_error` | The pin could not be enqueued. | Retry the pin. | | `internal_error` | An unexpected error occurred. | Retry; if it persists, contact support. | | `api_error` | An unclassified Pinterest error (fallback). | Read `error_message`, retry, and contact support if it repeats. | One common cause of a "wrong" title is not a failure at all: Pinterest re-scrapes the destination URL and may override your pin title with the page's Open Graph or meta tags. Control this by setting good OG tags on the destination page. ## Get notified Subscribe to the `pin.failed` webhook event to react to failures automatically. The payload is `{pin_id, error_code, error_message}`. In n8n, receive it with the generic Webhook node. See [Receive pin events with a webhook](/docs/n8n/webhook-results/). ## Next steps - [Bulk imports (CSV and JSON)](/docs/guides/bulk-imports/) — import many pins and read per-row failures. - [Activity logs](/docs/guides/activity-logs/) — audit publish successes and failures over time. - [Troubleshooting in n8n](/docs/n8n/troubleshooting/) — fixes for credential and publish errors. ================================================================================ # Schedule pins URL: https://www.pinbridge.io/docs/guides/schedule-pins/ ================================================================================ # Schedule pins Publish a pin at a future time with `POST /v1/schedules`. A schedule carries the same media and pin details you'd send to [Publish pins](/docs/guides/publish-pins/), plus a `run_at` timestamp. A background task materializes the pin when the time arrives. ## Prerequisites - An API key in the `X-API-Key` header. See [Authentication](/docs/authentication/). - A connected Pinterest account (`account_id`) and a `board_id`. - Media: a public `image_url` or an uploaded `asset_id`. See [Upload media](/docs/guides/upload-media/). ## Fields a schedule supports A `ScheduleCreate` accepts: | Field | Required | Notes | |---|---|---| | `account_id` | yes | Connected Pinterest account (UUID). | | `run_at` | yes | Future ISO 8601 timestamp **with a timezone offset** (see below). | | `board_id` | yes | Pinterest board id. | | `title` | yes | Max 100 characters. | | `description` | no | Max 800 characters. | | `alt_text` | no | Accessibility text, max 500 characters. The pin keeps it when the schedule runs. | | `link_url` | no | Destination URL, max 2048 characters. | | one of `image_url` / `asset_id` | yes | Exactly one, same rule as publishing. | | `cover_image_url` / `cover_image_asset_id` | no | Video pins only; at most one. | ### Schedule fields vs. publish fields One `PinCreate` field is **not** part of `ScheduleCreate`: `dominant_color`. If you need it, publish directly with [Publish pins](/docs/guides/publish-pins/) instead of scheduling. `alt_text` works on both. ### run_at rules - Must include an explicit timezone offset (for example `2026-12-25T09:00:00-05:00` or `...Z`). A naive timestamp is rejected with `422`. It's normalized to UTC on the server. - Must be in the **future**, else `400 run_at must be in the future`. ## Create a schedule ### curl ```bash curl -X POST https://api.pinbridge.io/v1/schedules \ -H "X-API-Key: $PINBRIDGE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "account_id": "6f1c2e4a-3b7d-4c9e-8a21-5d0f9b3e7c41", "board_id": "987654321098765432", "title": "Holiday promo pin", "image_url": "https://example.com/pin.png", "link_url": "https://example.com/promo", "alt_text": "Gift boxes wrapped in red paper under a decorated tree", "run_at": "2026-12-25T09:00:00-05:00" }' ``` Response (`201`, trimmed): ```json { "id": "c4e2a9b1-7d3f-4e6a-8b50-1f9d2c7e3a64", "pinterest_account_id": "6f1c2e4a-3b7d-4c9e-8a21-5d0f9b3e7c41", "run_at": "2026-12-25T14:00:00Z", "status": "scheduled", "pin_id": null, "last_error": null } ``` ### Python SDK ```python from datetime import datetime, timezone from pinbridge_sdk import PinbridgeClient from pinbridge_sdk.models import ScheduleCreate with PinbridgeClient(api_key="$PINBRIDGE_API_KEY") as client: schedule = client.schedules.create( ScheduleCreate( account_id="6f1c2e4a-3b7d-4c9e-8a21-5d0f9b3e7c41", board_id="987654321098765432", title="Holiday promo pin", image_url="https://example.com/pin.png", run_at=datetime(2026, 12, 25, 14, 0, tzinfo=timezone.utc), ) ) print(schedule.id, schedule.status) ``` `run_at` must be timezone-aware. ### n8n Use the **PinBridge** node with **Resource: Schedule**, **Operation: Create**. Set **Run At** (a dateTime), the **Connection**, **Board**, **Title**, and **Media Source**. From node version 1.3.1 the schedule form also has **Alt Text**. Dominant Color is publish-only, matching the API. ## Schedule statuses A schedule's `status` is one of: `scheduled` → `queued` → `running` → `done` on success. Retryable errors go to `deferred`; a hard failure is `failed`; a canceled schedule is `canceled`. Once the pin is created you can follow it with `pin_id` and the pin's own [publish states](/docs/guides/publish-pins/). ## Manage schedules ### List and fetch ```bash curl "https://api.pinbridge.io/v1/schedules?limit=50&offset=0" \ -H "X-API-Key: $PINBRIDGE_API_KEY" curl "https://api.pinbridge.io/v1/schedules/c4e2a9b1-7d3f-4e6a-8b50-1f9d2c7e3a64" \ -H "X-API-Key: $PINBRIDGE_API_KEY" ``` ### Cancel, retry, delete ```bash # Cancel a pending schedule curl -X POST https://api.pinbridge.io/v1/schedules/c4e2a9b1-7d3f-4e6a-8b50-1f9d2c7e3a64/cancel \ -H "X-API-Key: $PINBRIDGE_API_KEY" # Retry a failed schedule curl -X POST https://api.pinbridge.io/v1/schedules/c4e2a9b1-7d3f-4e6a-8b50-1f9d2c7e3a64/retry \ -H "X-API-Key: $PINBRIDGE_API_KEY" # Delete a schedule (returns 204 No Content) curl -X DELETE https://api.pinbridge.io/v1/schedules/c4e2a9b1-7d3f-4e6a-8b50-1f9d2c7e3a64 \ -H "X-API-Key: $PINBRIDGE_API_KEY" ``` Cancel, retry, and delete are status-dependent: you'll get a `400` such as `Cannot cancel schedule with status `, `Cannot retry schedule with status `, or `Cannot delete schedule with status ; cancel it first.` when the current status doesn't allow the action. In the SDK: `client.schedules.cancel(id)`, `client.schedules.retry(id)`, `client.schedules.delete(id)`. ### Bulk actions Each bulk endpoint takes `{ "ids": [...] }` with up to 500 ids. ```bash curl -X POST https://api.pinbridge.io/v1/schedules/bulk-cancel \ -H "X-API-Key: $PINBRIDGE_API_KEY" \ -H "Content-Type: application/json" \ -d '{"ids": ["c4e2a9b1-7d3f-4e6a-8b50-1f9d2c7e3a64", "9f5b3d1e-2a7c-4b8d-a4e6-6c0f1b9d5e27"]}' ``` Response (`200`, trimmed): ```json { "succeeded_count": 1, "skipped_count": 1, "failed_count": 0, "results": [ {"id": "c4e2a9b1-7d3f-4e6a-8b50-1f9d2c7e3a64", "status": "succeeded"}, {"id": "9f5b3d1e-2a7c-4b8d-a4e6-6c0f1b9d5e27", "status": "skipped", "error_code": "ineligible_status"} ] } ``` The same shape applies to `POST /v1/schedules/bulk-retry` and `POST /v1/schedules/bulk-delete` (SDK: `client.schedules.bulk_cancel(ids)`, `bulk_retry(ids)`, `bulk_delete(ids)`). ## Verify it worked - The create response returns `201` with `status: scheduled` and the `run_at` normalized to UTC. - Fetch with `GET /v1/schedules/{schedule_id}` and watch `status` advance. - Once `pin_id` is set, follow the pin with `GET /v1/pins/{pin_id}` or a [webhook](/docs/guides/webhooks/). ## Common errors | Status | Cause | |---|---| | 422 | `run_at` missing a timezone offset, or a missing required field. | | 400 | `run_at must be in the future`. | | 400 | `Cannot cancel / retry / delete schedule with status `. | | 403 | `feature_unavailable` — used an `asset_id` on the Free plan. | ## Next steps - [Publish pins](/docs/guides/publish-pins/) — publish immediately, and the full field reference. - [Upload media](/docs/guides/upload-media/) — host media and schedule an asset-backed pin. - [Webhooks](/docs/guides/webhooks/) — get notified when a scheduled pin publishes or fails. - [Rate limits and quotas](/docs/rate-limits/) — how scheduled publishes count against quota. ================================================================================ # Team and roles URL: https://www.pinbridge.io/docs/guides/team-and-roles/ ================================================================================ # Team and roles Invite teammates to your PinBridge organization and give each one the right level of access. This page covers the four roles, exactly what each can do, and how invitations work. ## Prerequisites - **Team access is available on the Agency and Enterprise plans.** On other plans, team endpoints return **403** `plan_required` ("Team access is available on Agency and Enterprise plans."). (The `plan` enum value for Agency is `pro`.) - Team endpoints use a **user session**, not an API key. Send `Authorization: Bearer `. Calling them with an `X-API-Key` is rejected. - Only members with `can_manage_members` (owner or admin) can invite, update, or remove teammates. ## The four roles - **owner** — full control, including billing. Every organization has an owner. The owner role cannot be handed out by invitation. - **admin** — manages members, API keys, and integrations, and can publish and delete content, but cannot manage billing. - **operator** — can publish and view reports. Cannot manage members, keys, integrations, or delete content. - **client** — read and report only. Blocked from team-management views. Useful for giving a customer visibility without edit rights. ## Permission matrix | Permission | owner | admin | operator | client | |---|---|---|---|---| | Manage members | Yes | Yes | No | No | | Manage billing | Yes | No | No | No | | Manage API keys | Yes | Yes | No | No | | Manage integrations | Yes | Yes | No | No | | Publish pins | Yes | Yes | Yes | No | | Delete content (pins/schedules) | Yes | Yes | No | No | | Delete assets | Yes | Yes | No | No | | Reset sandbox | Yes | Yes | No | No | | View reports | Yes | Yes | Yes | Yes | Note that managing billing is owner-only, and deleting content is admin-level (operators can publish but not delete). ## Invitation flow Invitations are token based. The lifecycle is: 1. An owner or admin creates an invitation for an email address and a role. 2. The invited person receives a link carrying the token and accepts it. 3. The invitation status moves through `pending` → `accepted` (or `expired` / `revoked`). Invitations expire after a configured window. You can resend or revoke a pending one. ### Endpoints (`/v1/team/*`) | Action | Endpoint | |---|---| | List members | `GET /v1/team/members` | | Update a member's role | `PATCH /v1/team/members/{member_id}` | | Remove a member | `DELETE /v1/team/members/{member_id}` | | List invitations | `GET /v1/team/invitations` | | Create an invitation | `POST /v1/team/invitations` | | Preview an invitation | `GET /v1/team/invitations/preview` | | Accept an invitation | `POST /v1/team/invitations/accept` | | Resend an invitation | `POST /v1/team/invitations/{invitation_id}/resend` | | Revoke an invitation | `DELETE /v1/team/invitations/{invitation_id}` | ### Invite a teammate (curl) ```bash curl -X POST https://api.pinbridge.io/v1/team/invitations \ -H "Authorization: Bearer $PINBRIDGE_JWT" \ -H "Content-Type: application/json" \ -d '{"email": "teammate@example.com", "role": "operator"}' ``` Expected response (trimmed): ```json {"id": "b6e0a4d8-2f7c-4b3e-9d1a-5c7f3e9b1d42", "email": "teammate@example.com", "role": "operator", "status": "pending"} ``` Valid `role` values are `admin`, `operator`, and `client`. You cannot invite someone as `owner` (that returns 422), and you cannot invite an unsupported role. ### List members (curl) ```bash curl https://api.pinbridge.io/v1/team/members \ -H "Authorization: Bearer $PINBRIDGE_JWT" ``` Expected response (trimmed): ```json [ {"member_id": "a1d5f9c3-6e2b-4a7d-8c4f-3b9e1d5a7f06", "email": "you@example.com", "role": "owner"}, {"member_id": "f8c2e6a4-3d9b-4f1c-a7e5-9a4d2c8f6b31", "email": "teammate@example.com", "role": "operator"} ] ``` ### Change a member's role (curl) ```bash curl -X PATCH https://api.pinbridge.io/v1/team/members/f8c2e6a4-3d9b-4f1c-a7e5-9a4d2c8f6b31 \ -H "Authorization: Bearer $PINBRIDGE_JWT" \ -H "Content-Type: application/json" \ -d '{"role": "admin"}' ``` ## Python SDK The SDK exposes team management under `client.team`. Authenticate with a bearer token, since team endpoints require a user session. ```python from pinbridge_sdk import PinbridgeClient from pinbridge_sdk.models import TeamInvitationCreateRequest with PinbridgeClient(bearer_token="eyJ...") as client: client.team.create_invitation( TeamInvitationCreateRequest(email="teammate@example.com", role="operator") ) for member in client.team.list_members(): print(member["email"], member["role"]) ``` Other methods: `list_invitations`, `resend_invitation(id)`, `revoke_invitation(id)`, `update_member(...)`, `remove_member(id)`, `preview_invitation(token)`, and `accept_invitation(...)`. ## Common errors | Status | Code / message | Cause | |---|---|---| | 403 | `plan_required` | Team access needs the Agency or Enterprise plan. | | 403 | `insufficient_permissions` (`can_manage_members`) | The caller is not an owner or admin. | | 409 | "You are already part of this organization." | Self-invite. | | 409 | "That user is already a member of this organization." | The invitee is already a member. | | 409 | "An active invitation already exists for that email." | Duplicate pending invite. | | 422 | "Owner role cannot be assigned by invitation" | Tried to invite an owner. | ## Next steps - [Managing multiple Pinterest accounts](/docs/guides/multiple-accounts/) — consolidate agency clients under one project. - [Activity logs](/docs/guides/activity-logs/) — see which teammate did what. - [Retries and failures](/docs/guides/retries-and-failures/) — how operators handle failed pins. ================================================================================ # Upload media (images and videos) URL: https://www.pinbridge.io/docs/guides/upload-media/ ================================================================================ # Upload media (images and videos) Host your pin media on PinBridge, then publish pins that reference it by `asset_id`. This is the alternative to pointing a pin at a public `image_url`, and it's the only way to publish a video pin. ## Prerequisites - A paid plan. Uploads are a paid feature: the **Playground (Free)** plan cannot upload assets (you'll get a `403`). On Free, publish with a public `image_url` instead (see [Publish pins](/docs/guides/publish-pins/)). - An API key, sent in the `X-API-Key` header. See [Authentication](/docs/authentication/). - Your files must be one of the supported formats below. ## When to upload vs. use a public URL You have two ways to give a pin its media: - **Public `image_url`** — PinBridge fetches the image from a URL you host. Works on every plan, images only. - **Uploaded `asset_id`** — you upload the file to PinBridge first, then reference the returned asset. Required for **video** pins, and useful when you don't have a public URL. Paid plans only. Assets have no expiry. They persist until you delete them. ## Supported formats | Media | Content types | |---|---| | Image | `image/gif`, `image/jpeg`, `image/png`, `image/webp` | | Video | `video/mp4`, `video/quicktime` (`.mov`) | Any other content type is rejected with `400 Unsupported image content type` (or `... video content type`). ## Size limits Per-file limits are set by your plan: | Plan | Image per file | Video per file | |---|---|---| | Playground (Free) | Uploads blocked | Uploads blocked | | Starter | 10 MiB | 250 MiB | | Growth | 50 MiB | 500 MiB | | Agency | 100 MiB | 1 GiB | | Enterprise | 100 MiB | 1 GiB | Files over the limit return `400 file_too_large`. Your organization also has a total disk quota across all uploaded assets: | Plan | Storage quota | |---|---| | Playground (Free) | 0 (uploads blocked) | | Starter | 2 GiB | | Growth | 7 GiB | | Agency | 20 GiB | | Enterprise | 20 GiB | When an upload would exceed the org quota, it returns `402 storage_quota_exceeded`. Delete unused assets or upgrade to free space. ## Upload an image `POST /v1/assets/images` is a `multipart/form-data` request with a single `file` field. ### curl ```bash curl -X POST https://api.pinbridge.io/v1/assets/images \ -H "X-API-Key: $PINBRIDGE_API_KEY" \ -F "file=@/path/to/pin.png;type=image/png" ``` Response (`201`, trimmed): ```json { "id": "e3a7c5f9-1b4d-4c8e-a2f6-7d9b3e5a1c80", "asset_type": "image", "original_filename": "pin.png", "content_type": "image/png", "file_size_bytes": 148213, "file_size_display": "144.7 KiB", "referenced_by_pin_count": 0, "public_url": "https://api.pinbridge.io/v1/assets/e3a7c5f9-1b4d-4c8e-a2f6-7d9b3e5a1c80/content" } ``` Keep the `id`. That's the `asset_id` you'll pass when publishing. ### Python SDK ```python from pinbridge_sdk import PinbridgeClient with PinbridgeClient(api_key="$PINBRIDGE_API_KEY") as client: asset = client.assets.upload_image("/path/to/pin.png") print(asset.id, asset.asset_type, asset.public_url) ``` `upload_image` also accepts raw `bytes` or a file object; pass `filename=` / `content_type=` if you do. The SDK does not read environment variables, so pass `api_key=` explicitly. ### n8n Use the **PinBridge** node with **Resource: Asset**, **Operation: Upload Image**. The file comes from the item's binary data: set **Binary Property** to the property that holds the file (default `data`). Chain it after a node that produces binary data (for example an HTTP Request or Read Binary File node). ## Upload a video Same shape, at `POST /v1/assets/videos`. ```bash curl -X POST https://api.pinbridge.io/v1/assets/videos \ -H "X-API-Key: $PINBRIDGE_API_KEY" \ -F "file=@/path/to/clip.mp4;type=video/mp4" ``` The response is the same `AssetResponse` shape, with `"asset_type": "video"`. In n8n, use **Operation: Upload Video**; in the SDK, `client.assets.upload_video(...)`. ## Publish a pin from an asset Reference the asset by `asset_id` when you create the pin. Provide exactly one of `image_url` or `asset_id`, never both. ```bash curl -X POST https://api.pinbridge.io/v1/pins \ -H "X-API-Key: $PINBRIDGE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "account_id": "6f1c2e4a-3b7d-4c9e-8a21-5d0f9b3e7c41", "board_id": "987654321098765432", "title": "My pin", "asset_id": "e3a7c5f9-1b4d-4c8e-a2f6-7d9b3e5a1c80", "idempotency_key": "upload-demo-001" }' ``` The pin's media type is derived from the asset: an image asset produces an image pin, a video asset produces a video pin. There is no separate `video_url` field. For the full list of pin fields, see [Publish pins](/docs/guides/publish-pins/). ## Video cover images The cover image for a video pin is a field on the **pin**, not on the asset. On `PinCreate` (and `ScheduleCreate`), set one of: - `cover_image_url` — a public image URL, or - `cover_image_asset_id` — an uploaded **image** asset's id. Rules: - Cover fields are only accepted for **video** pins. Setting a cover on an image pin returns `400 cover_image_url is only supported for video pins`. - `cover_image_asset_id` must reference an image asset (not a video), else `400`. - Provide at most one of the two cover fields, else `422`. ## Verify it worked - The upload response returns `201` with the asset `id` and `public_url`. - Fetch the asset later with `GET /v1/assets/{asset_id}` (SDK: `client.assets.get(asset_id)`). - After you publish, `referenced_by_pin_count` reflects how many pins use the asset. ## Common errors | Status | Code / message | Cause | |---|---|---| | 403 | `feature_unavailable` | Uploads are not available on the Free plan. Use a public `image_url`, or upgrade. | | 400 | `Unsupported image content type` / `... video content type` | File type is not in the supported list. | | 400 | `file_too_large` | File exceeds your plan's per-file limit. | | 400 | `Uploaded file is empty` | The `file` part had no bytes. | | 402 | `storage_quota_exceeded` | Org disk quota is full. Delete assets or upgrade. | ## Next steps - [Publish pins](/docs/guides/publish-pins/) — all pin fields and media options. - [Schedule pins](/docs/guides/schedule-pins/) — publish an asset-backed pin at a future time. - [Rate limits and quotas](/docs/rate-limits/) — how storage and publishing quotas work per plan. - [Docs home](/docs/) — quickstart and the rest of the API. ================================================================================ # Webhooks URL: https://www.pinbridge.io/docs/guides/webhooks/ ================================================================================ # Webhooks Webhooks push pin outcomes to your endpoint instead of making you poll. Register a URL, verify the signature on each delivery, and react to the event. ## Prerequisites - An API key in the `X-API-Key` header. See [Authentication](/docs/authentication/). - A publicly reachable HTTPS endpoint that returns a 2xx quickly. - A secret string, 16 to 255 characters, used to sign deliveries. Store it where your endpoint can read it. ## Events PinBridge emits exactly **two** event types: | Event | When | Payload | |---|---|---| | `pin.published` | A pin publishes successfully | `{ "pin_id", "pinterest_pin_id", "title", "published_at" }` | | `pin.failed` | A pin fails permanently | `{ "pin_id", "error_code", "error_message" }` | Example bodies: ```json { "pin_id": "8a3f6c12-4d5e-4b7a-9c01-2e6f8d4b7a93", "pinterest_pin_id": "1234567890123456789", "title": "My first PinBridge pin", "published_at": "2026-09-11T10:05:00Z" } ``` ```json { "pin_id": "8a3f6c12-4d5e-4b7a-9c01-2e6f8d4b7a93", "error_code": "board_access_denied", "error_message": "The connected account cannot write to this board." } ``` These are the only events that fire. You can technically subscribe to other event names, but nothing else is ever delivered. The JSON body carries **no** envelope: there is no top-level `event`, `id`, or `timestamp` field. The event type travels in a header instead. ## Delivery headers Every delivery carries: | Header | Value | |---|---| | `Content-Type` | `application/json` | | `X-PinBridge-Event` | The event type, e.g. `pin.published` | | `X-PinBridge-Signature` | HMAC-SHA256 of the raw body, hex-encoded (see below) | | `X-PinBridge-Delivery-ID` | A unique id for this delivery attempt group | ## Verify the signature `X-PinBridge-Signature` is `HMAC-SHA256(secret, raw_request_body)`, hex-encoded. Two things to get right: - **Sign the raw body bytes, exactly as received.** Do not re-serialize the parsed JSON first, or whitespace and key order differences will break the match. - **Only the body is signed.** There is no timestamp in the signed string and no timestamp header. This is not Stripe's `t=...,v1=...` scheme, so don't try to parse the signature that way. Compute the same HMAC over the raw body with your secret, then compare it to the header using a constant-time comparison. Reject the request if they differ. ### Python The snippet reads the raw request body, computes `hmac.new(secret, body, sha256).hexdigest()`, and compares it to `X-PinBridge-Signature` with `hmac.compare_digest`. Wire it in before you parse or trust the JSON. ```python """Verify a PinBridge webhook signature (Python). PinBridge signs the raw JSON request body with HMAC-SHA256 (hex) using your webhook secret, and sends it in the `X-PinBridge-Signature` header. Verify against the RAW body bytes, before parsing JSON. Matches api/app/core/security.py: sign_webhook_payload. """ import hashlib import hmac def verify_signature(secret: str, raw_body: bytes, signature: str) -> bool: expected = hmac.new(secret.encode("utf-8"), raw_body, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, signature or "") # Example (Flask): # @app.post("/pinbridge-webhook") # def hook(): # raw = request.get_data() # raw bytes, not request.json # if not verify_signature(SECRET, raw, request.headers.get("X-PinBridge-Signature", "")): # abort(401) # event = request.headers["X-PinBridge-Event"] # pin.published | pin.failed # delivery_id = request.headers["X-PinBridge-Delivery-ID"] # for idempotent handling # data = json.loads(raw) # ... ``` ### Node The snippet does the same in Node: `crypto.createHmac("sha256", secret).update(rawBody).digest("hex")`, compared to the header with `crypto.timingSafeEqual`. Make sure your framework gives you the **raw** body (for example Express's `express.raw()`), not an already-parsed object. ```javascript // Verify a PinBridge webhook signature (Node). // HMAC-SHA256 (hex) over the RAW request body, compared to the X-PinBridge-Signature header. const crypto = require("crypto"); function verifySignature(secret, rawBody, signature) { const expected = crypto.createHmac("sha256", secret).update(rawBody).digest("hex"); const a = Buffer.from(expected); const b = Buffer.from(signature || ""); return a.length === b.length && crypto.timingSafeEqual(a, b); } // Example (Express): use express.raw({type:"application/json"}) so req.body is a Buffer. // app.post("/pinbridge-webhook", express.raw({type:"application/json"}), (req, res) => { // if (!verifySignature(SECRET, req.body, req.get("X-PinBridge-Signature"))) return res.sendStatus(401); // const event = req.get("X-PinBridge-Event"); // pin.published | pin.failed // const deliveryId = req.get("X-PinBridge-Delivery-ID"); // idempotency key // const data = JSON.parse(req.body.toString()); // res.sendStatus(200); // }); module.exports = { verifySignature }; ``` ## Register a webhook `POST /v1/webhooks`. The `secret` is required (16 to 255 chars); `events` defaults to both event types. ### curl ```bash curl -X POST https://api.pinbridge.io/v1/webhooks \ -H "X-API-Key: $PINBRIDGE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://example.com/webhooks/pinbridge", "secret": "a-strong-secret-at-least-16-chars", "events": ["pin.published", "pin.failed"], "is_enabled": true }' ``` Response (`201`, trimmed): ```json { "id": "d2f6b0e4-8a3c-4d7f-b1e9-2a6c4f8d0b53", "url": "https://example.com/webhooks/pinbridge", "events": ["pin.published", "pin.failed"], "is_enabled": true } ``` The secret is never returned in responses. Keep your own copy. ### Python SDK ```python from pinbridge_sdk import PinbridgeClient from pinbridge_sdk.models import WebhookCreate with PinbridgeClient(api_key="$PINBRIDGE_API_KEY") as client: webhook = client.webhooks.create( WebhookCreate( url="https://example.com/webhooks/pinbridge", secret="a-strong-secret-at-least-16-chars", ) ) print(webhook.id, webhook.events) ``` Manage webhooks with `client.webhooks.list()`, `get(id)`, `update(id, WebhookUpdate)`, and `delete(id)`. ### n8n There are two different nodes here: - **To receive events**, use n8n's generic **Webhook** node as the listener, then point PinBridge at that node's URL. The PinBridge community node ships **no trigger node**. - **To register the URL**, use the **PinBridge** node with **Resource: Webhook**, **Operation: Create** (default events `pin.published,pin.failed`). ## Retries and delivery - **Success is any HTTP 2xx.** Anything else (non-2xx, timeout, connection error) is a failure and is retried. - **Up to 5 attempts** per delivery. - **Exponential backoff in minutes, base 2**, so retries land roughly 2, 4, 8, and 16 minutes after the failed attempts. - **Per-attempt timeout is 30 seconds.** Return fast, then do slow work asynchronously. After 5 attempts the delivery is marked permanently failed. ## Write idempotent consumers Because deliveries can be retried, your endpoint may receive the same event more than once. Make handling idempotent: dedupe on the `X-PinBridge-Delivery-ID` header, or on `pin_id` combined with the event type. Record what you've processed and no-op on repeats. ## Verify it worked - The create call returns `201` with the webhook `id` and `is_enabled: true`. - [Publish a pin](/docs/guides/publish-pins/) and watch for a `pin.published` (or `pin.failed`) POST to your endpoint. - Confirm your endpoint returns a 2xx and that your signature check passes. ## Common errors | Status | Cause | |---|---| | 422 | `secret` is missing or shorter than 16 characters, or `url` is not a valid URL. | | 404 | `Webhook not found` — wrong webhook id on get / update / delete. | | — | Deliveries retried and eventually dropped: your endpoint returned non-2xx or timed out past 30s. | ## Next steps - [Publish pins](/docs/guides/publish-pins/) — trigger `pin.published` / `pin.failed`. - [Schedule pins](/docs/guides/schedule-pins/) — scheduled pins fire the same events when they run. - [Rate limits and quotas](/docs/rate-limits/) — the failure `error_code` values you'll see in `pin.failed`. - [Docs home](/docs/) — quickstart and the rest of the API. ================================================================================ # MCP URL: https://www.pinbridge.io/docs/mcp/ ================================================================================ # MCP Use PinBridge from Claude, ChatGPT, Codex and other MCP clients. ## [MCP setup](/docs/mcp/setup/) Connect PinBridge to your AI assistant and see the available tools. ## [Guide: PinBridge in ChatGPT](/chatgpt-pinterest/) Install the PinBridge plugin from ChatGPT's Plugins directory and publish your first pin, step by step with screenshots. ## [Walkthrough: connect Claude with OAuth](/blog/connect-claude-to-pinterest-without-pasting-an-api-key/) Step-by-step screenshots of adding PinBridge to Claude as a custom connector and signing in with OAuth instead of an API key. ## [Usage policy](/docs/mcp/usage-policy/) Acceptable use for AI-assisted Pinterest publishing. ## Related - [Quickstart](/docs/) — the REST and SDK path to your first pin. - [Authentication](/docs/authentication/) — create the API key your MCP client uses. - [Python SDK](/docs/python-sdk/) — the same publishing pipeline from code. ================================================================================ # MCP setup — connect PinBridge to your AI assistant URL: https://www.pinbridge.io/docs/mcp/setup/ ================================================================================ # Connect PinBridge to your AI assistant PinBridge runs a hosted MCP server for Pinterest. In Claude, add it from the connector directory and sign in. In ChatGPT, install the PinBridge plugin and sign in. Neither needs an API key. Every other MCP client connects with one API key. Either way it takes about a minute. - **Endpoint:** `https://mcp.pinbridge.io` - **Claude:** listed in [Claude's connector directory](https://claude.ai/directory/pinbridge), sign in with your PinBridge account (OAuth) - **ChatGPT:** the PinBridge plugin in [ChatGPT's Plugins directory](https://chatgpt.com/plugins/plugin_asdk_app_6ac9fe36c4308191be4a0573c058caeb), sign in with your PinBridge account (OAuth). The free ChatGPT plan works. - **Other clients:** send `Authorization: Bearer ` ## What you need - **A PinBridge account.** Create one for free. The Playground plan is enough to try the whole flow. - **A connected Pinterest account.** Connect it once from the PinBridge dashboard: open **Pinterest Accounts** and approve access on Pinterest. It takes about 30 seconds. PinBridge stores and refreshes the tokens, so your assistant never touches Pinterest credentials, and it can only publish to accounts linked to your workspace. - **An AI assistant.** Claude on the web or in the desktop app, ChatGPT, Claude Code, Codex, Cursor, Windsurf, or any assistant built on the Model Context Protocol. ## Connect Claude from the connector directory 1. Open [PinBridge in Claude's connector directory](https://claude.ai/directory/pinbridge). You can also find it in Claude: open **Customize**, then **Connectors**, and search for PinBridge. 2. Click **Connect to Claude**. 3. Sign in to PinBridge with your email and password, or click **Continue with Google**. New to PinBridge? The sign-in page links to the plans so you can create an account first. 4. Choose the workspace Claude may use and click **Authorize**. Claude is now connected. The connector's page in Claude lists every PinBridge tool, and you can decide for each one whether Claude asks you before using it. The connection shows up in your PinBridge dashboard under **API Keys**, labelled **Agent** with the assistant's name. Revoke it there to disconnect Claude. Connecting again replaces the previous connection's key. PinBridge is listed as a community connector. If **Connect to Claude** isn't available in your Claude organization, ask an admin to allow it, or add `https://mcp.pinbridge.io` as a custom connector instead. It uses the same sign-in. ## Connect ChatGPT with the PinBridge plugin 1. In ChatGPT, click **Plugins** in the left sidebar and search for PinBridge. You can also open [the PinBridge plugin page](https://chatgpt.com/plugins/plugin_asdk_app_6ac9fe36c4308191be4a0573c058caeb) directly. 2. Open **Pinbridge** by Amendorg and click **Install plugin**. A PinBridge sign-in window opens. 3. Sign in to PinBridge with your email and password, or click **Continue with Google**. New to PinBridge? Click **Create an account**. You sign up and connect your Pinterest account in the same window. 4. Choose the workspace ChatGPT may use and click **Authorize**. A brand-new account skips this screen. The window closes and ChatGPT shows the plugin as installed. To use it, type **@** in a chat and pick Pinbridge, or click **Try in chat** on the plugin page. By default ChatGPT asks before PinBridge publishes anything. **Allow once** asks again next time, **Always allow** stops asking, and you can change it later in ChatGPT's plugin settings, under Permission. The free ChatGPT plan works. To disconnect, uninstall PinBridge from ChatGPT's plugin settings, or revoke the ChatGPT key under **API Keys** in your PinBridge dashboard. The [ChatGPT guide](/chatgpt-pinterest/) shows every step with screenshots. ## Connect other clients with an API key ### 1. Get your API key Log into the PinBridge dashboard, open **API Keys**, create a key, and copy it. It starts with `pb_` followed by 64 hexadecimal characters: ```text pb_your_api_key ``` Keep this key private. Anyone who has it can publish to your connected Pinterest accounts. Store it in a secrets manager, never in client-side code or source control. See [Authentication](/docs/authentication/) for creating and revoking keys. ### 2. Add PinBridge to your client Every client connects to the same endpoint, `https://mcp.pinbridge.io` (streamable HTTP), with your key in an `Authorization: Bearer` header. **Claude Code.** Add the server from your terminal in one command: ```bash claude mcp add pinbridge --transport http https://mcp.pinbridge.io \ --header "Authorization: Bearer pb_your_api_key" ``` To skip the key, add the server without the header, then run `/mcp` inside Claude Code, pick **pinbridge** and choose **Authenticate**. You sign in to PinBridge in the browser, as in the Claude steps above. **Codex, Windsurf, Cursor & other clients.** Add a new MCP server in your client's settings using these values, then restart the client: ```text # Transport streamable HTTP # URL https://mcp.pinbridge.io # Header Authorization: Bearer pb_your_api_key ``` Replace `pb_your_api_key` with your real key everywhere, and make sure there are no stray spaces around it. ## Verify the connection Open a new conversation and ask your assistant: ```text List my Pinterest accounts ``` It should return your connected account details. You can also ask it to run `server_info`, which reports the server version, the PinBridge API it targets, and whether write (publishing) tools are enabled. ## Publish your first pin In a new conversation, describe the pin: ```text Publish a pin to my Home Ideas board titled "My first AI pin" using this image: https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=800 ``` Your assistant calls the `create_pin` tool, PinBridge queues the publish with per-account rate handling and retries, and the pin appears on Pinterest within seconds. ## What your assistant can do The hosted server exposes **34 tools: 16 read and 18 write**, all scoped to your workspace. - **Publish & schedule.** Publish a pin now with `create_pin`, or several at once with `create_pins_batch`. Queue one for later with `create_schedule`, then change, cancel, retry or delete it. Pass `dry_run=true` to `create_pin` to check a pin before anything is published. - **Fix mistakes.** Change a pin with `update_pin` before it publishes. Published pins can't be edited through Pinterest's API, so remove a wrong one with `delete_pin` and publish it again. Retry a failed pin with `retry_pin`. Use `upload_asset` for an image or video that isn't at a public URL, and `delete_asset` to remove an upload you no longer need. - **Manage boards.** List boards, confirm a board can take pins with `check_board_access`, and create, update or delete one. - **Measure.** Read pin and account analytics, get a dashboard summary, and find Pinterest keyword ideas with `list_related_terms`. - **Browse & track.** List and inspect pins and schedules, review activity logs, check billing status and the live rate meter, and manage webhooks for publish results. Read tools are always available. The hosted server at `https://mcp.pinbridge.io` has the write tools enabled too; if you run the server yourself, they stay off until you set `PINBRIDGE_MCP_ENABLE_WRITE_TOOLS=true`. Ask your assistant to run `server_info` to see what your connection allows. The delete tools (`delete_pin`, `delete_board`, `delete_schedule`, `delete_webhook`, `delete_asset`) are marked destructive, so clients ask you to confirm before calling them, and your key's scopes and account allow-list apply to every call. All publishing follows Pinterest's per-pin intent rule. See the [usage policy](/docs/mcp/usage-policy/) for what that means. ## Common issues **Claude doesn't use the PinBridge tools.** Check that the connector is turned on for the conversation in Claude's tools menu. If Claude reports an authorization error, open the connector in **Customize → Connectors** and connect again. **ChatGPT doesn't use PinBridge.** Type **@** in the message box and pick Pinbridge, so ChatGPT knows to use the plugin for that request. **Tools are not showing up in another client.** Restart the client fully after saving the config. Some clients need a complete quit and reopen, not just a window reload. **Authentication error with an API key.** Re-copy the key straight from the dashboard to avoid hidden characters or trailing spaces, and confirm it hasn't been revoked. **"No Pinterest accounts found".** Connect a Pinterest account from the PinBridge dashboard first. The MCP server only sees accounts linked to your workspace. **Can't publish / write tools missing.** Ask your assistant to run `server_info`. On the hosted server the write tools are always on, so a publish that fails with `insufficient_scope` means your API key lacks the `write` scope: create a key with it, or ask a workspace admin. If you self-host, check `PINBRIDGE_MCP_ENABLE_WRITE_TOOLS`. Read tools stay available regardless. **"Quota exceeded".** Your plan's weekly MCP request limit has been reached. Upgrade your plan or wait for the quota to reset. ## Next steps - [MCP usage policy](/docs/mcp/usage-policy/): how Pinterest's per-pin intent rule applies to AI publishing. - [Authentication](/docs/authentication/): create and manage the API keys your clients use. - [Rate limits & quotas](/docs/rate-limits/): the quotas behind "quota exceeded". - [MCP overview](/docs/mcp/): the two-guide index for MCP. ================================================================================ # MCP usage policy — AI-assisted Pinterest publishing URL: https://www.pinbridge.io/docs/mcp/usage-policy/ ================================================================================ # AI-assisted publishing, the compliant way PinBridge gives AI assistants direct access to Pinterest publishing. Pinterest's developer guidelines define how that publishing must be initiated, and PinBridge operates fully within those rules. ## What Pinterest's guidelines require Pinterest's Developer Guidelines state that end users must specifically consider each action before it is taken. For publishing, this means: > "If your app allows end users to schedule Pin publishing, the end user must choose each Pin to be published." > — Pinterest Developer Guidelines - "Specifically" is the operative word — each pin must be individually and intentionally chosen. - A single approval for a batch ("publish all of these") does not satisfy the per-pin intent requirement. - AI-assisted drafting and editing is fine. The per-pin requirement applies at the moment of publishing. ## Compliant vs. outside permitted use ### Compliant workflows - Using Claude to draft a pin, review it, then publish it. - Asking an assistant to suggest a schedule time for a specific pin you selected. - AI-generated captions reviewed and approved by you before publishing. - Reviewing each item in a list individually and publishing one at a time. ### Outside permitted use - Fully autonomous agents that generate content, select boards, and publish with no human review. - Batch flows where you approve once and the AI publishes many pins without per-item confirmation. - Content calendars generated and queued entirely by AI without per-pin sign-off. - Bulk republishing or rescheduling of existing pins without per-pin review. ## How PinBridge handles this PinBridge operates as an authorised Pinterest API partner and applies Pinterest's guidelines to all publishing activity. The MCP server exposes the same Pinterest publishing pipeline as the REST API. Per-pin intent is a Pinterest platform requirement that applies to all publishing methods, including AI-assisted ones. Pinterest actively reviews third-party integrations and distinguishes between tools that facilitate publishing (allowed) and tools that take actions without specific knowledge and consent (not allowed). Keeping a human in the loop at the moment of publishing keeps your workflow on the right side of that line. - Pinterest Developer Guidelines ↗ - Pinterest API Terms of Service ↗ ## Ready to connect your assistant? Follow the [setup guide](/docs/mcp/setup/) to connect PinBridge to Claude, ChatGPT, Codex, or any MCP client, with per-pin publishing built in. [MCP setup guide](/docs/mcp/setup/) · [About the MCP server](/docs/mcp/) ================================================================================ # PinBridge for n8n URL: https://www.pinbridge.io/docs/n8n/ ================================================================================ # PinBridge for n8n The PinBridge community node lets you publish, schedule, and manage Pinterest pins from any n8n workflow, without writing code. This page explains what the node is, how to install it, and where to go next. ## What the node is `n8n-nodes-pinbridge` is a community node package (version 1.2.12). It ships a single action node called **PinBridge** that talks to the PinBridge API on your behalf. One node covers 8 resources: - **Asset** — upload images and videos to PinBridge storage. - **Board** — list, create, and delete Pinterest boards. - **Connection** — list and manage connected Pinterest accounts. - **Pin** — publish pins, check status, and run bulk imports. - **Rate Meter** — read your live rate-limit budget. - **Schedule** — queue pins to publish at a future time. - **Term** — look up related keywords for a Pinterest account. - **Webhook** — register delivery URLs for pin events. There is **no trigger node** in this package. To react to pin events (`pin.published`, `pin.failed`) inside n8n, you use n8n's built-in Webhook node and register its URL with PinBridge. See [Receive pin events with a webhook](/docs/n8n/webhook-results/). ## Prerequisites - An n8n instance (self-hosted 1.0.0 or later, or n8n Cloud). - A PinBridge account with a Pinterest account connected. - A PinBridge API key (create one in your project settings). The Free (Playground) plan works for publishing from public image URLs. Uploading assets and running bulk imports require a paid plan. See [Bulk import pins](/docs/n8n/bulk-import/) and [Upload images and video](/docs/n8n/upload-images-and-video/) for the exact plan gates. ## Install on self-hosted n8n 1. Open n8n and go to **Settings → Community nodes**. 2. Choose **Install a community node**. 3. Enter the package name `n8n-nodes-pinbridge` and confirm. 4. n8n installs the package and the **PinBridge** node appears in the node panel. You can also install from the command line inside your n8n environment: ```bash npm install n8n-nodes-pinbridge ``` ## Install on n8n Cloud The PinBridge node is a verified community node, so it installs on n8n Cloud the same way: 1. Open **Settings → Community nodes** in your Cloud instance. 2. Choose **Install a community node**, enter `n8n-nodes-pinbridge`, and confirm. No self-hosting is required. The node runs inside your Cloud instance. ## Credential setup, in short The node authenticates with a single credential type, **PinBridge API Key** (`pinBridgeApi`). It has two fields: - **Base URL** (`baseUrl`) — defaults to `https://api.pinbridge.io`. Change it only if you run a self-hosted PinBridge API. - **API Key** (`apiKey`) — your key, sent in the `X-API-Key` header. When you save the credential, n8n runs a test call against `GET /v1/pinterest/accounts` to confirm the key works. Full walkthrough: [Set up your PinBridge credential](/docs/n8n/credentials/). ## Where to go next - [Set up your PinBridge credential](/docs/n8n/credentials/) — connect your API key and pass the credential test. - [Publish your first pin](/docs/n8n/publish-your-first-pin/) — a step-by-step flow from an image URL. - [Node reference](/docs/n8n/node-reference/) — every resource, operation, parameter, and endpoint. - [Troubleshooting](/docs/n8n/troubleshooting/) — fixes for credential, publish, and event-receiving errors. ================================================================================ # Bulk import pins in n8n URL: https://www.pinbridge.io/docs/n8n/bulk-import/ ================================================================================ # Bulk import pins in n8n Bulk import turns a spreadsheet or table of rows into a batch of pins in one call. Feed rows from Google Sheets or Airtable into the PinBridge node, submit them as JSON or CSV, then poll the import job for per-row results. ## Prerequisites - A paid plan. Bulk import requires the `bulk_publishing` feature, which is off on Free (Playground); a Free-plan import returns a 403. See [PinBridge for n8n](/docs/n8n/) for plan notes. - The PinBridge credential set up. See [Set up your PinBridge credential](/docs/n8n/credentials/). - A source of rows (Google Sheets, Airtable, or any node that outputs items). ## Row rules and limits - **Max 500 rows** per import (JSON or CSV). - Each row is a pin definition: it needs `account_id`, `board_id`, `title`, and `idempotency_key`, plus exactly one of `image_url` or `asset_id`. - Optional per row: `description`, `alt_text`, `dominant_color`, `cover_image_url`, `cover_image_asset_id`, `link_url`, and `run_at`. - `run_at` (optional) schedules the row for a future time. It must be ISO 8601 with an explicit timezone offset (for example `2026-10-01T14:30:00+00:00`) and must be in the future, or the row fails. - Deduplication is by `(project, idempotency_key)`. A row whose key already exists is reported as `existing` rather than creating a duplicate. This makes re-running an import safe. ## Path A: Import JSON (from Sheets or Airtable) The **Import JSON** operation reads every incoming item's JSON and posts them as one array. Shape each item so its fields match the row schema above. 1. Add **Google Sheets** (or **Airtable**) and read your rows. Each row becomes one n8n item. 2. Add a **Set** or **Edit Fields** node if you need to rename columns to `account_id`, `board_id`, `title`, `idempotency_key`, and `image_url` (or `asset_id`). 3. Add a **PinBridge** node set to **Pin → Import JSON**. It has no field for the rows; it reads the incoming items directly. 4. Execute. The node calls `POST /v1/pins/imports/json` and returns a single import job item. ## Path B: Import CSV The **Import CSV** operation uploads a CSV file as multipart. 1. Produce a CSV as binary data (for example a **Read Binary File** node, or convert items to a file). 2. Add a **PinBridge** node set to **Pin → Import CSV**. 3. Set **Binary Property** to the property holding the CSV (default `data`). 4. Execute. The node calls `POST /v1/pins/imports/csv`. CSV required columns: `account_id`, `board_id`, `title`, `idempotency_key`. Allowed extra columns: `description`, `related_terms`, `alt_text`, `dominant_color`, `cover_image_url`, `cover_image_asset_id`, `link_url`, `image_url`, `asset_id`, `run_at`. Any column outside that set fails the whole file with a 400. `related_terms` stays allowed so older files keep importing, but its value is ignored. ## Check the import job status Import runs asynchronously, so the submit call returns a job, not finished pins. 1. Add a **PinBridge** node set to **Pin → Get Import**. 2. Set **Import Job ID** to `={{$json["id"]}}` to read the id from the submit node's output. 3. Execute (or poll on a loop). The node calls `GET /v1/pins/imports/{importJobId}`. Job status moves through `queued → processing → completed` (or `completed_with_errors` if any row failed, or `failed` for a whole-job crash). The response carries counters (`total_rows`, `created_rows`, `existing_rows`, `failed_rows`) and a `results` array with a per-row `status` of `created`, `existing`, or `failed`, plus `error_code` and `error_message` for failures. To list past jobs, use **Pin → List Imports**, optionally filtered by **Import Status** and **Import Source** (json or csv). ## Retry failed rows There is no retry operation for individual import rows in the node. To retry, re-import only the rows that failed: 1. Read the job with **Pin → Get Import** and split out `results`. 2. Filter to `status === "failed"` (an **IF** or **Filter** node). 3. Fix the underlying data for those rows (a bad URL, a missing field). 4. Re-run **Import JSON** or **Import CSV** with the corrected rows. Because dedupe is by `(project, idempotency_key)`, rows that already succeeded are reported as `existing` and are not duplicated, so it is safe to resubmit the whole set if that is easier. ## curl equivalent ```bash # Submit a JSON import (raw array of row objects) curl -sS -X POST "https://api.pinbridge.io/v1/pins/imports/json" \ -H "X-API-Key: $PINBRIDGE_API_KEY" \ -H "Content-Type: application/json" \ -d '[ {"account_id":"6f1c2e4a-3b7d-4c9e-8a21-5d0f9b3e7c41","board_id":"987654321098765432","title":"Row 1","image_url":"https://placehold.co/1000x1500.png","idempotency_key":"import-row-1"} ]' # Check the job curl -sS "https://api.pinbridge.io/v1/pins/imports/" \ -H "X-API-Key: $PINBRIDGE_API_KEY" ``` ## Next steps - [Schedule pins](/docs/n8n/schedules/) — schedule a single pin (or use `run_at` per row here). - [Node reference](/docs/n8n/node-reference/) — every import parameter and endpoint. - [Troubleshooting](/docs/n8n/troubleshooting/) — 403 gating and row-level errors. ================================================================================ # Set up your PinBridge credential in n8n URL: https://www.pinbridge.io/docs/n8n/credentials/ ================================================================================ # Set up your PinBridge credential in n8n Before any PinBridge node can run, it needs a credential that holds your API key. This page shows how to create it, what each field does, and how to confirm it works. ## Prerequisites - The PinBridge node installed. See [PinBridge for n8n](/docs/n8n/). - A PinBridge API key from your project settings. ## The credential type The node uses one credential type, shown in n8n as **PinBridge API Key** (internal name `pinBridgeApi`). It has exactly two fields. | Field | Name | Default | Notes | |---|---|---|---| | Base URL | `baseUrl` | `https://api.pinbridge.io` | The PinBridge API root. Change only for self-hosted. | | API Key | `apiKey` | (empty) | Your key, stored as a password field. | The node sends your key in the **`X-API-Key`** header on every request. The PinBridge API also accepts `Authorization: Bearer ` for compatibility, but this node always uses `X-API-Key`, so paste an API key here, not a user-session token. ## Create the credential 1. In any PinBridge node, open the **Credential to connect with** dropdown and choose **Create New Credential**. (You can also create it under **Credentials → Add credential → PinBridge API Key**.) 2. Leave **Base URL** as `https://api.pinbridge.io` unless you self-host the PinBridge API. 3. Paste your key into **API Key**. Use the `$PINBRIDGE_API_KEY` placeholder here when you copy examples. 4. Click **Save**. On save, n8n runs the built-in credential test. ## The credential test The credential test calls `GET /v1/pinterest/accounts` with your key. This endpoint is a safe first call: it is not plan-gated and returns an empty list (`[]`) for a workspace with no connected accounts, so a valid key on a brand-new project still passes. - **Test succeeds** — your key and base URL are correct. You are ready to build. - **Test fails with 401** — the key is missing or wrong. Recheck the value and that you pasted an API key, not a session token. - **Test fails to connect** — the base URL is unreachable. Recheck it (see self-hosted below). If the test fails, see [Troubleshooting](/docs/n8n/troubleshooting/). ## Self-hosted PinBridge base URL If you run your own PinBridge API instead of the hosted service, set **Base URL** to your API root, for example `https://pinbridge.internal.example.com`. Every request the node makes is appended to this value. All other behavior, including the `X-API-Key` header and the credential test path, stays the same. Leave the default `https://api.pinbridge.io` if you use hosted PinBridge. ## Verify it worked Add a **PinBridge** node, set **Resource** to **Connection** and **Operation** to **List**, then execute the node. A successful run returns your connected Pinterest accounts (or an empty list if you have not connected one yet). If you see accounts or an empty array, your credential is good. ## Next steps - [Publish your first pin](/docs/n8n/publish-your-first-pin/) — use the credential in a real flow. - [Node reference](/docs/n8n/node-reference/) — the full list of resources and operations. - [Troubleshooting](/docs/n8n/troubleshooting/) — credential test and 401 fixes. ================================================================================ # PinBridge n8n node reference URL: https://www.pinbridge.io/docs/n8n/node-reference/ ================================================================================ # PinBridge n8n node reference Every resource, operation, parameter, and endpoint in the PinBridge node (`n8n-nodes-pinbridge` v1.2.12). Parameter names are the exact node field names. Endpoints are appended to the credential **Base URL** (default `https://api.pinbridge.io`). For setup, start with [PinBridge for n8n](/docs/n8n/) and [Set up your PinBridge credential](/docs/n8n/credentials/). ## Dropdowns that load from the API Two fields load their options live: - **Connection** (`accountId`) loads from `GET /v1/pinterest/accounts`. Present on Board, Term, Pin, Schedule, and Rate Meter operations. On Connection → Revoke the same picker is named **Connection** (`connectionId`). - **Board** (`boardId`) loads from `GET /v1/pinterest/boards?account_id=`. It returns nothing until you pick a Connection first. ## Shared parameters These appear across multiple operations: | Parameter | Field name | Notes | |---|---|---| | Return All | `returnAll` | Boolean, default **true**. Shown on List / List Imports for boards, connections, pins, schedules, webhooks. | | Limit | `limit` | Number 1 to 1000, default 50. Shown only when Return All is off. | | Media Source | `imageSource` | `asset` = Uploaded Asset (default), `url` = Public Image URL. | | Image URL | `imageUrl` | Shown when Media Source = Public Image URL. | | Asset ID | `assetId` | Shown when Media Source = Uploaded Asset. Default `={{$json["id"]}}`. | | Title | `title` | Max 100 characters. | | Description | `description` | Max 800 characters. | | Link URL | `linkUrl` | Max 2048 characters. | | Video Cover Source | `coverImageSource` | `none` (API default), `url`, `asset`. | | Cover Image URL | `coverImageUrl` | Shown when Video Cover Source = url. | | Cover Image Asset ID | `coverImageAssetId` | Shown when Video Cover Source = asset. Default `={{$json["id"]}}`. | | Binary Property | `binaryPropertyName` | File source for uploads and CSV import. Default `data`. | ## Asset | Operation | Value | Endpoint | Parameters | |---|---|---|---| | Get | `get` | `GET /v1/assets/{assetLookupId}` | Asset ID (`assetLookupId`, default `={{$json["id"]}}`) | | Upload Image | `uploadImage` | `POST /v1/assets/images` (multipart) | Binary Property (`binaryPropertyName`) | | Upload Video | `uploadVideo` | `POST /v1/assets/videos` (multipart) | Binary Property (`binaryPropertyName`) | See [Upload images and video](/docs/n8n/upload-images-and-video/). ## Board | Operation | Value | Endpoint | Parameters | |---|---|---|---| | Create | `create` | `POST /v1/pinterest/boards` | Connection (`accountId`), Board Name (`boardName`), Board Description (`boardDescription`), Board Privacy (`boardPrivacy`: ``/`PUBLIC`/`PROTECTED`/`SECRET`) | | Delete | `delete` | `DELETE /v1/pinterest/boards/{boardId}?account_id=...` | Connection (`accountId`), Board (`boardId`) | | List | `list` | `GET /v1/pinterest/boards?account_id=...` | Connection (`accountId`), Return All, Limit | Board → List uses **client-side Limit** (see the quirk below). ## Connection | Operation | Value | Endpoint | Parameters | |---|---|---|---| | Complete OAuth Callback | `completeOAuth` | `GET /v1/pinterest/oauth/callback?code=&state=` | OAuth Code (`oauthCode`), OAuth State (`oauthState`) | | List | `list` | `GET /v1/pinterest/accounts` | Return All, Limit | | Revoke | `revoke` | `DELETE /v1/pinterest/accounts/{connectionId}` | Connection (`connectionId`) | | Start OAuth | `startOAuth` | `GET /v1/pinterest/oauth/start` | (none) | Connection → List uses **client-side Limit** (see the quirk below). ## Pin | Operation | Value | Endpoint | Parameters | |---|---|---|---| | Publish | `publish` | `POST /v1/pins` | Connection (`accountId`), Board (`boardId`), Media Source + Image URL/Asset ID, Title, Description, Link URL, Alt Text (`altText`, max 500), Dominant Color (`dominantColor`, hex), Video Cover Source + cover fields, Idempotency Key (`idempotencyKey`, default `={{$execution.id + "-" + $itemIndex}}`) | | Get | `get` | `GET /v1/pins/{pinId}` | Pin ID (`pinId`, default `={{$json["id"]}}`) | | Get Status | `getStatus` | `GET /v1/jobs/{pinId}` | Pin ID (`pinId`) | | Delete | `delete` | `DELETE /v1/pins/{pinId}` | Pin ID (`pinId`) | | List | `list` | `GET /v1/pins` | Return All, Limit | | Import JSON | `importJson` | `POST /v1/pins/imports/json` | (none; reads incoming items as the row array) | | Import CSV | `importCsv` | `POST /v1/pins/imports/csv` (multipart) | Binary Property (`binaryPropertyName`) | | Get Import | `getImport` | `GET /v1/pins/imports/{importJobId}` | Import Job ID (`importJobId`, default `={{$json["id"]}}`) | | List Imports | `listImports` | `GET /v1/pins/imports` | Return All, Limit, Import Status (`importStatus`: ``/`queued`/`processing`/`completed`/`completed_with_errors`/`failed`), Import Source (`importSourceType`: ``/`json`/`csv`) | Publish runs item by item, so each input item produces one pin with its own idempotency key. See [Publish your first pin](/docs/n8n/publish-your-first-pin/) and [Bulk import pins](/docs/n8n/bulk-import/). From node version 1.3.2, **Publish** has no Related Terms field. Older versions still show it, and the value is ignored. The pin output keeps `relatedTerms` for now: `null` for new pins, the stored value for older ones. **Term → List Related** still works. ## Rate Meter | Operation | Value | Endpoint | Parameters | |---|---|---|---| | Get | `get` | `GET /v1/rate-meter?account_id=...` | Connection (`accountId`) | Returns the per-account and global token-bucket state. Requires a connected account, or it returns 404. ## Schedule | Operation | Value | Endpoint | Parameters | |---|---|---|---| | Create | `create` | `POST /v1/schedules` | Connection (`accountId`), Board (`boardId`), Run At (`runAt`, dateTime, ISO 8601 with offset), Media Source + Image URL/Asset ID, Title, Description, Alt Text (`altText`, max 500, node 1.3.1 or later), Link URL, Video Cover Source + cover fields | | Get | `get` | `GET /v1/schedules/{scheduleId}` | Schedule ID (`scheduleId`, default `={{$json["id"]}}`) | | Cancel | `cancel` | `POST /v1/schedules/{scheduleId}/cancel` | Schedule ID (`scheduleId`) | | List | `list` | `GET /v1/schedules` | Return All, Limit | Schedule → Create has **no** Dominant Color field (it is Publish-only). Alt Text is on Schedule → Create from node version 1.3.1. See [Schedule pins](/docs/n8n/schedules/). ## Term | Operation | Value | Endpoint | Parameters | |---|---|---|---| | List Related | `listRelated` | `GET /v1/pinterest/terms/related?account_id=&terms=&exact_match=` | Connection (`accountId`), Terms (`termsInput`, comma-separated), Exact Match (`exactMatch`, boolean) | ## Webhook | Operation | Value | Endpoint | Parameters | |---|---|---|---| | Create | `create` | `POST /v1/webhooks` | Webhook URL (`webhookUrl`), Webhook Secret (`webhookSecret`, min 16), Webhook Events (`webhookEvents`, default `pin.published,pin.failed`), Enabled (`webhookEnabled`) | | Get | `get` | `GET /v1/webhooks/{webhookId}` | Webhook ID (`webhookId`, default `={{$json["id"]}}`) | | Update | `update` | `PATCH /v1/webhooks/{webhookId}` | Webhook ID, Webhook URL (Optional) (`webhookUrlUpdate`), Webhook Secret (Optional) (`webhookSecretUpdate`), Webhook Events (Optional) (`webhookEventsUpdate`), Enabled (Optional) (`webhookEnabledUpdate`: ``/`true`/`false`) | | List | `list` | `GET /v1/webhooks` | Return All, Limit | | Delete | `delete` | `DELETE /v1/webhooks/{webhookId}` | Webhook ID (`webhookId`) | Webhook → List uses **client-side Limit** (see the quirk below). See [Receive pin events with a webhook](/docs/n8n/webhook-results/). ## The Limit quirk (boards, connections, webhooks) Pagination is not uniform across resources: - **Pin → List**, **Pin → List Imports**, and **Schedule → List** use real server-side paging (limit and offset). Setting **Limit** actually reduces how much the API returns. - **Board → List**, **Connection → List**, and **Webhook → List** fetch the **full** collection from the API and then slice to your **Limit** on the client. The Limit trims the node's output, but it does **not** reduce the API call or the data fetched. On a large account, expect the full list to load regardless of Limit. See [Troubleshooting](/docs/n8n/troubleshooting/) for the practical impact. ## Notes - Auth header is `X-API-Key` on every call. - The node has one version (`version: 1`); there are no versioned node variants. - Idempotency is defined only on Pin → Publish. No other operation sends an idempotency key. ================================================================================ # Publish your first pin with n8n URL: https://www.pinbridge.io/docs/n8n/publish-your-first-pin/ ================================================================================ # Publish your first pin with n8n This walkthrough publishes a single pin from a public image URL. It is the fastest way to confirm your setup end to end, and it works on the Free (Playground) plan because it uses a public image, not an uploaded asset. ## Prerequisites - The PinBridge node installed and a credential saved. See [Set up your PinBridge credential](/docs/n8n/credentials/). - A Pinterest account connected in PinBridge, with at least one board. - A public image URL. Pinterest prefers tall images (a 2:3 ratio, for example 1000x1500). ## Steps (n8n) 1. **Add the node.** In your workflow, add a **PinBridge** node. 2. **Select the credential.** Choose your **PinBridge API Key** credential. 3. **Pick the resource and operation.** Set **Resource** to **Pin** and **Operation** to **Publish**. 4. **Pick the Connection.** Open the **Connection** dropdown. The node loads your connected Pinterest accounts. Select the one to publish with. (This maps to `account_id`.) 5. **Pick the Board.** Open the **Board** dropdown. The node loads boards for the selected connection. Choose your target board. (This maps to `board_id`.) 6. **Set the Media Source.** Set **Media Source** to **Public Image URL**, then paste your image into the **Image URL** field. 7. **Set the Title.** Enter a **Title** (max 100 characters). Pinterest may re-scrape your destination page and override the title with the page's Open Graph tags, so set good OG tags on the linked page if you use a **Link URL**. 8. **Leave the Idempotency Key default.** The **Idempotency Key** field is prefilled with the expression `={{$execution.id + "-" + $itemIndex}}`. Keep it. It is required and it stops the same pin being created twice if the workflow re-runs. Each input item gets its own key. 9. **Execute the node.** Click **Execute node** (or run the workflow). The node calls `POST /v1/pins` and returns the created pin. Optional fields you can add on **Publish**: **Description** (max 800), **Link URL** (max 2048), **Alt Text** (max 500), and **Dominant Color** (hex). ## Steps (curl) The same request from the command line: ```bash curl -sS -X POST "https://api.pinbridge.io/v1/pins" \ -H "X-API-Key: $PINBRIDGE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "account_id": "6f1c2e4a-3b7d-4c9e-8a21-5d0f9b3e7c41", "board_id": "987654321098765432", "title": "My first PinBridge pin", "image_url": "https://placehold.co/1000x1500.png", "idempotency_key": "first-pin-001" }' ``` Expected response (trimmed): ```json { "id": "…", "status": "queued", "account_id": "6f1c2e4a-3b7d-4c9e-8a21-5d0f9b3e7c41", "board_id": "987654321098765432", "title": "My first PinBridge pin" } ``` ## Steps (Python SDK) ```python from pinbridge_sdk import PinbridgeClient from pinbridge_sdk.models import PinCreate with PinbridgeClient(api_key="$PINBRIDGE_API_KEY") as client: pin = client.pins.create( PinCreate( account_id="6f1c2e4a-3b7d-4c9e-8a21-5d0f9b3e7c41", board_id="987654321098765432", title="My first PinBridge pin", image_url="https://placehold.co/1000x1500.png", idempotency_key="first-pin-001", ) ) print(pin.id, pin.status) ``` ## Verify it worked A new pin starts as `queued`, then moves to `publishing`, then `published`. To check status in n8n, add a second **PinBridge** node set to **Pin → Get** (or **Pin → Get Status**), and set **Pin ID** to `={{$json["id"]}}` so it reads the id from the Publish node's output. When `status` is `published`, the `pinterest_pin_id` field is filled and the pin is live. If `status` is `failed`, read `error_code` and see [Troubleshooting](/docs/n8n/troubleshooting/). ## Importable sample workflow An importable example lives at `samples/n8n/publish-first-pin.json`. Import it in n8n via **Workflows → Import from File**. It contains: 1. A **Manual Trigger** node to run the flow on demand. 2. A **Set** node that defines `imageUrl`, `title`, and `linkUrl` so you can edit inputs in one place. 3. A **PinBridge** node set to **Pin → Publish**: Connection and Board selected from the dropdowns, **Media Source** = Public Image URL, **Image URL** and **Title** bound to the Set node's fields, and the default **Idempotency Key** expression left in place. 4. A **PinBridge** node set to **Pin → Get** with **Pin ID** = `={{$json["id"]}}` to read back the published status. After import, open the PinBridge nodes, attach your credential, and pick your Connection and Board. ## Next steps - [Upload images and video](/docs/n8n/upload-images-and-video/) — publish from uploaded assets instead of a URL. - [Schedule pins](/docs/n8n/schedules/) — publish at a future time. - [Node reference](/docs/n8n/node-reference/) — every parameter for Pin → Publish. ================================================================================ # Schedule pins in n8n URL: https://www.pinbridge.io/docs/n8n/schedules/ ================================================================================ # Schedule pins in n8n A schedule is a pin queued to publish at a future time. Use the **Schedule → Create** operation when you want a pin to go live later instead of now. ## Prerequisites - The PinBridge credential set up. See [Set up your PinBridge credential](/docs/n8n/credentials/). - A connected Pinterest account and a target board. ## Create a schedule 1. Add a **PinBridge** node set to **Resource** = **Schedule**, **Operation** = **Create**. 2. Select your **Connection** and **Board**. 3. Set **Run At** to a future time. This is a dateTime field and it must be **ISO 8601 with an explicit timezone offset**, for example `2026-10-01T14:30:00+00:00`. A value without an offset is rejected, and a time in the past is rejected. 4. Set **Media Source** to **Public Image URL** (then **Image URL**) or **Uploaded Asset** (then **Asset ID**, default `={{$json["id"]}}`). 5. Set a **Title** (max 100). 6. Execute. The node calls `POST /v1/schedules` and returns the created schedule. Optional fields on a schedule: **Description** (max 800), **Link URL** (max 2048), **Alt Text** (max 500, node version 1.3.1 or later), and the video cover fields (**Video Cover Source**, then **Cover Image URL** or **Cover Image Asset ID**). ## What schedules do not support Schedules carry title, description, link, alt text, media, and cover. They do **not** accept **Dominant Color**. That field exists only on **Pin → Publish**, not on **Schedule → Create**. If you need a dominant color on a scheduled pin, use a bulk import row with a `run_at` value instead, which is a full pin definition. See [Bulk import pins](/docs/n8n/bulk-import/). Node versions before 1.3.1 have no **Alt Text** field on schedules. Update the node to get it. ## Schedule states A schedule moves through `scheduled → queued → running → done`, or `failed`. Retryable failures pass through `deferred`, and a schedule can be `canceled`. When the schedule fires, it produces a pin that follows the normal pin lifecycle. ## Manage schedules The Schedule resource also supports: - **List** — `GET /v1/schedules` (use **Return All**, or **Limit** when Return All is off). - **Get** — `GET /v1/schedules/{scheduleId}`; set **Schedule ID** to `={{$json["id"]}}`. - **Cancel** — `POST /v1/schedules/{scheduleId}/cancel`. ## curl equivalent ```bash curl -sS -X POST "https://api.pinbridge.io/v1/schedules" \ -H "X-API-Key: $PINBRIDGE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "account_id": "6f1c2e4a-3b7d-4c9e-8a21-5d0f9b3e7c41", "board_id": "987654321098765432", "title": "Scheduled pin", "image_url": "https://placehold.co/1000x1500.png", "run_at": "2026-10-01T14:30:00+00:00" }' ``` Note: the schedule endpoint does not take an `idempotency_key`. That field is a Pin → Publish concern. ## Verify it worked Run **Schedule → Get** with **Schedule ID** = `={{$json["id"]}}`. A `status` of `scheduled` and a `run_at` matching what you sent confirm it is queued. After the run time passes, the linked pin appears with its own status; check it with [Publish your first pin](/docs/n8n/publish-your-first-pin/)'s verify step. ## Next steps - [Bulk import pins](/docs/n8n/bulk-import/) — schedule many pins at once with per-row `run_at`. - [Node reference](/docs/n8n/node-reference/) — every Schedule parameter and endpoint. - [Troubleshooting](/docs/n8n/troubleshooting/) — timezone and past-time errors. ================================================================================ # Troubleshooting the PinBridge n8n node URL: https://www.pinbridge.io/docs/n8n/troubleshooting/ ================================================================================ # Troubleshooting the PinBridge n8n node Fixes for the problems people hit most often. If your issue is not here, check the [Node reference](/docs/n8n/node-reference/) for the exact parameter or endpoint. ## The credential test fails When you save the credential, n8n calls `GET /v1/pinterest/accounts`. If it fails: - **Wrong base URL.** Confirm **Base URL** is `https://api.pinbridge.io` (hosted) or your self-hosted API root, with no trailing path. A URL n8n cannot reach fails the test before auth is checked. - **Wrong key type.** The **API Key** field expects a PinBridge API key sent as `X-API-Key`. A user-session token will not work here. - **Network egress.** A self-hosted n8n behind a proxy or firewall must be allowed to reach the base URL. Full setup steps: [Set up your PinBridge credential](/docs/n8n/credentials/). ## 401 invalid key A `401` means the key was rejected. Two variants: - **Missing API key.** No credential was attached, or the key field is empty. Attach the **PinBridge API Key** credential to the node. - **Invalid API key.** The key is wrong, revoked, or from a different project. Generate a fresh key in your project settings and update the credential. Note the node always authenticates with `X-API-Key`, even though the API also accepts a Bearer header. Paste an API key, not a JWT. ## 422 missing account_id (or another required field) A `422` is a request-validation error. The most common cause on Pin → Publish is a missing **Connection**, which sends `account_id`. - **account_id required.** Pick a **Connection** from the dropdown. If the dropdown is empty, your project has no connected Pinterest account yet, or the credential is not valid, so the account list could not load. - **Media source.** You must set exactly one of **Image URL** or **Asset ID** (via Media Source). Both missing, or both present, returns 422. - **Field limits.** Title max 100, Description max 800, Alt Text max 500, Link URL max 2048, Dominant Color must be 6-digit hex. Idempotency Key is required (leave the default expression in place). ## Binary property not found Upload Image, Upload Video, and Import CSV read the file from the **Binary Property** field (`binaryPropertyName`, default `data`). If the operation errors that the binary property is missing: - **Name mismatch.** The upstream node output the file under a different property name. Set **Binary Property** to match it (check the previous node's output for the binary key), or rename it with a **Move Binary Data** / **Edit Fields** node. - **No binary at all.** The incoming item has JSON but no binary data. Make sure the previous node actually produces a file (for example an HTTP Request with response format "File", or Read Binary File). See [Upload images and video](/docs/n8n/upload-images-and-video/). ## Why Limit does not reduce API calls (boards, connections, webhooks) For **Board → List**, **Connection → List**, and **Webhook → List**, the node fetches the entire collection from the API, then slices the result to your **Limit** on the client. So: - Setting a small **Limit** trims the node's output but does **not** make the API return less or run faster. - On a large account, the full list still loads. Plan for that if you have many boards. By contrast, **Pin → List**, **Pin → List Imports**, and **Schedule → List** page on the server, so their Limit does reduce the fetch. Details in the [Node reference](/docs/n8n/node-reference/). ## Not receiving pin events Events reach n8n only through the generic Webhook node, since the package ships no trigger node. - **Workflow not active.** The Webhook node must use its **Production URL** and the workflow must be activated. A test URL only fires during a manual "Listen for test event". - **URL not registered.** You must register the Webhook node's URL with PinBridge via **Webhook → Create**. Confirm with **Webhook → List**. - **Wrong events.** Only `pin.published` and `pin.failed` are ever sent. Subscribing to any other name delivers nothing. - **Delivery failing.** A delivery counts as success only on a 2xx response, retries up to 5 times, and each attempt times out at 30 seconds. If your workflow is slow or errors, deliveries are retried then dropped. Return 2xx quickly. - **Event name looks missing.** The event type is in the `X-PinBridge-Event` header, not the JSON body. Read it from `={{$json["headers"]["x-pinbridge-event"]}}`. Full setup: [Receive pin events with a webhook](/docs/n8n/webhook-results/). ## Signature verification fails The `X-PinBridge-Signature` header is HMAC-SHA256 (hex) over the **raw JSON body only**. - **Verify before reshaping.** Compute the HMAC on the raw body, before any node changes the JSON. Enable **Raw Body** on the Webhook node. - **Secret mismatch.** Use the exact secret you set on **Webhook → Create**. If you rotated it with **Webhook → Update**, use the new value. - **No timestamp.** The signed string is the body alone; there is no timestamp header to include. ## A pin failed: reading the error When a pin ends in `failed`, read its `error_code`. Common values: `rate_limited`, `token_expired`, `token_revoked`, `scope_missing`, `board_access_denied`, `media_url_unreachable`, `media_url_temporarily_unavailable`, `resource_not_found`, `invalid_payload`, and `publish_timeout`. Retryable failures auto-retry (queued to deferred, up to 8 deferrals) before ending in `failed`. ## Next steps - [Node reference](/docs/n8n/node-reference/) — exact parameters and endpoints. - [Publish your first pin](/docs/n8n/publish-your-first-pin/) — a known-good baseline flow. - [Set up your PinBridge credential](/docs/n8n/credentials/) — recheck your credential. ================================================================================ # Upload images and video in n8n URL: https://www.pinbridge.io/docs/n8n/upload-images-and-video/ ================================================================================ # Upload images and video in n8n Uploading an asset to PinBridge lets you publish a pin from your own hosted file instead of a public URL. It is also the only way to publish a **video** pin: you upload a video asset, then reference its ID. This page covers both. ## Prerequisites - A paid plan. Uploading assets requires the `uploaded_media_assets` feature, which is off on Free (Playground). A Free-plan upload returns a 403; on Free, use [Publish your first pin](/docs/n8n/publish-your-first-pin/) with a public image URL instead. - The PinBridge credential set up. See [Set up your PinBridge credential](/docs/n8n/credentials/). - Binary file data in your workflow (from an HTTP Request, Read Binary File, Google Drive, or similar node). ## Supported formats and size limits - **Images:** gif, jpeg, png, webp. - **Videos:** mp4, quicktime (mov). Per-file size is plan-based: Starter 10 MiB image / 250 MiB video, Growth 50 MiB / 500 MiB, Agency 100 MiB / 1 GiB, Enterprise 100 MiB / 1 GiB. Each project also has a total disk quota (Starter 2 GiB, Growth 7 GiB, Agency 20 GiB, Enterprise 20 GiB); exceeding it returns a 402 `storage_quota_exceeded`. Assets have no expiry. ## The binary property Both upload operations read the file from a **Binary Property** field (`binaryPropertyName`), which defaults to **`data`**. Whatever node produces your file must expose it on a binary property, and that property name must match this field. Most upstream nodes (HTTP Request with response format "File", Read Binary File) output binary data as `data` by default, so you usually leave this field alone. ## Step 1: Upload the file 1. Add a **PinBridge** node set to **Resource** = **Asset**. 2. Set **Operation** to **Upload Image** or **Upload Video**. 3. Confirm **Binary Property** matches the property holding your file (default `data`). 4. Execute the node. It calls `POST /v1/assets/images` (or `.../videos`) as `multipart/form-data` and returns the created asset, including its `id`. The response's `id` is the asset ID you reference in the next step. ## Step 2: Publish a pin from the uploaded asset 1. Add a **PinBridge** node set to **Pin → Publish**. 2. Select your **Connection** and **Board**. 3. Set **Media Source** to **Uploaded Asset**. 4. In the **Asset ID** field, use `={{$json["id"]}}`. This is the field's default expression and it reads the `id` from the previous Upload node's output. 5. Set a **Title**, keep the default **Idempotency Key**, and execute. For a **video pin**, upload with **Upload Video**, then publish with **Media Source** = Uploaded Asset and **Asset ID** pointing at the video asset. There is no video URL field; a video pin always references an uploaded video asset. To add a cover image, set **Video Cover Source** to a URL or an uploaded **image** asset (a cover asset must be an image). ## curl equivalent Upload, then publish from the returned `id`: ```bash # 1) Upload an image curl -sS -X POST "https://api.pinbridge.io/v1/assets/images" \ -H "X-API-Key: $PINBRIDGE_API_KEY" \ -F "file=@./pin.png" # → {"id": "e3a7c5f9-1b4d-4c8e-a2f6-7d9b3e5a1c80", "asset_type": "image", ...} # 2) Publish using the asset_id from step 1 curl -sS -X POST "https://api.pinbridge.io/v1/pins" \ -H "X-API-Key: $PINBRIDGE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "account_id": "6f1c2e4a-3b7d-4c9e-8a21-5d0f9b3e7c41", "board_id": "987654321098765432", "title": "Pin from an uploaded asset", "asset_id": "e3a7c5f9-1b4d-4c8e-a2f6-7d9b3e5a1c80", "idempotency_key": "asset-pin-001" }' ``` ## Verify it worked Check the publish node's output `status`, or add a **Pin → Get** node with **Pin ID** = `={{$json["id"]}}`. When it reaches `published`, the asset was accepted and the pin is live. A missing or wrong asset reference fails with `asset_not_found`; see [Troubleshooting](/docs/n8n/troubleshooting/). ## Common errors - **403 on upload** — your plan does not include uploaded assets (Free), or the per-file limit for your plan is 0. Publish from a public image URL, or upgrade. - **400 file_too_large** — the file exceeds your plan's per-file cap. - **402 storage_quota_exceeded** — your project disk quota is full. Delete unused assets or upgrade. - **Binary property not found** — the **Binary Property** name does not match the property holding your file. See [Troubleshooting](/docs/n8n/troubleshooting/). ## Next steps - [Publish your first pin](/docs/n8n/publish-your-first-pin/) — the URL-based flow. - [Bulk import pins](/docs/n8n/bulk-import/) — publish many pins at once. - [Node reference](/docs/n8n/node-reference/) — every Asset operation and endpoint. ================================================================================ # Receive pin events with a webhook in n8n URL: https://www.pinbridge.io/docs/n8n/webhook-results/ ================================================================================ # Receive pin events with a webhook in n8n PinBridge can notify you when a pin publishes or fails. The PinBridge node package does **not** ship a trigger node, so you receive these events with n8n's built-in **Webhook** node and register that node's URL with PinBridge. ## The two events PinBridge emits exactly two webhook events: - **`pin.published`** — body: `{"pin_id", "pinterest_pin_id", "title", "published_at"}`. - **`pin.failed`** — body: `{"pin_id", "error_code", "error_message"}`. The event name is **not** in the JSON body. It arrives in the `X-PinBridge-Event` header. Each delivery also sends `X-PinBridge-Delivery-ID` and a signature header (below). ## Step 1: Add n8n's Webhook node 1. In a new workflow, add the built-in **Webhook** node (not a PinBridge node). 2. Set the HTTP method to **POST**. 3. In the node's options, turn on **Raw Body**. You need the exact raw bytes to verify the signature. 4. Copy the **Production URL** shown on the node. This is the URL PinBridge will call. 5. Activate the workflow so the production URL is live. ## Step 2: Register the URL with PinBridge 1. Add a **PinBridge** node set to **Resource** = **Webhook**, **Operation** = **Create**. 2. Set **Webhook URL** to the production URL from step 1. 3. Set **Webhook Secret** to a random string of 16 to 255 characters. Store it; you need it to verify signatures. 4. Leave **Webhook Events** at the default `pin.published,pin.failed`, or narrow it to one. 5. Leave **Enabled** on. 6. Execute. The node calls `POST /v1/webhooks` and registers the delivery URL. Delivery behavior: a delivery succeeds on any 2xx response. PinBridge retries up to 5 attempts with exponential backoff (base-2 minutes), and each attempt times out after 30 seconds. Return 2xx quickly from your Webhook node's workflow. ## Step 3: Verify the signature The signature header is **`X-PinBridge-Signature`**. It is an HMAC-SHA256 hex digest computed over the **raw JSON request body only** (no timestamp, no prefix). To verify: 1. After the Webhook node, add a **Crypto** node. 2. Set it to **HMAC**, algorithm **SHA256**, output **HEX**. 3. Set the value to the raw body from the Webhook node, and the secret to the secret you set in step 2. 4. Add an **IF** node that compares the Crypto node's output to `={{$json["headers"]["x-pinbridge-signature"]}}`. 5. On the true branch, continue. On the false branch, reject the request (the body was tampered with or the secret is wrong). Because the signature covers the raw body only, verify **before** any node reshapes the JSON. ## Step 4: Branch by event Read the event name from the header and route with an **IF** or **Switch** node: - Compare `={{$json["headers"]["x-pinbridge-event"]}}` against `pin.published` and `pin.failed`. - On `pin.published`, use `pin_id`, `pinterest_pin_id`, `title`, and `published_at`. - On `pin.failed`, use `pin_id`, `error_code`, and `error_message`. See [Troubleshooting](/docs/n8n/troubleshooting/) for the error codes. If you prefer to branch on the body, `pin.failed` carries `error_code` and `pin.published` does not, but the header is the reliable signal. ## Manage the subscription The Webhook resource also supports **List** (`GET /v1/webhooks`), **Get**, **Update** (`PATCH /v1/webhooks/{webhookId}`), and **Delete**. Use **Update** to rotate the secret or toggle the subscription without recreating it. See [Node reference](/docs/n8n/node-reference/). ## Verify it worked Publish a test pin (see [Publish your first pin](/docs/n8n/publish-your-first-pin/)). When it reaches `published`, your Webhook node should fire with `X-PinBridge-Event: pin.published` and a matching signature. If nothing arrives, confirm the workflow is active and the registered URL is the production URL. ## Next steps - [Publish your first pin](/docs/n8n/publish-your-first-pin/) — generate an event to test against. - [Node reference](/docs/n8n/node-reference/) — every Webhook operation and endpoint. - [Troubleshooting](/docs/n8n/troubleshooting/) — why events might not arrive. ================================================================================ # Python SDK URL: https://www.pinbridge.io/docs/python-sdk/ ================================================================================ # Python SDK A typed Python client for the PinBridge API, with a sync and an async client. This page covers install, client setup, errors and the common recipes. Every method is listed in the [SDK README](https://github.com/pinbridge/python-sdk#resource-guide). ## Install ```bash pip install pinbridge-sdk ``` The package is `pinbridge-sdk` and the import is `pinbridge_sdk`. It needs Python 3.10 or later. This page is checked against SDK 1.10.5. ## Set up a client The SDK does not read environment variables. Pass your API key explicitly (the class is `PinbridgeClient`, with a lowercase "b"): ```python from pinbridge_sdk import PinbridgeClient with PinbridgeClient(api_key="$PINBRIDGE_API_KEY") as client: accounts = client.pinterest.list_accounts() print(accounts) ``` `base_url` defaults to `https://api.pinbridge.io`. The key is sent in the `x-api-key` header. For a user session, pass `bearer_token=` instead. See [Authentication](/docs/authentication/) for how to create a key. ### Async client Use `AsyncPinbridgeClient` in asyncio code. It has the same resources and methods; you await them: ```python import asyncio from pinbridge_sdk import AsyncPinbridgeClient async def main(): async with AsyncPinbridgeClient(api_key="$PINBRIDGE_API_KEY") as client: pins = await client.pins.list(limit=10) print(pins) asyncio.run(main()) ``` ## Errors Errors live in `pinbridge_sdk.errors` and are also importable from `pinbridge_sdk`: - `PinbridgeError` is the base class. - `APIError` is raised for any non-2xx response. It carries `status_code`, `message`, `code`, `details` and `response`. - `AuthenticationError` (401 and 403), `NotFoundError` (404), `ValidationError` (422) and `RateLimitError` (429) are subclasses of `APIError`. ```python from pinbridge_sdk import PinbridgeClient from pinbridge_sdk.errors import NotFoundError, RateLimitError with PinbridgeClient(api_key="$PINBRIDGE_API_KEY") as client: try: pin = client.pins.get("00000000-0000-0000-0000-000000000000") except NotFoundError: print("no such pin") except RateLimitError as e: print("slow down:", e.status_code) ``` `code` is the stable error code from the API, such as `quota_exceeded` or `pin_already_published`. The [error reference](/docs/errors/) lists them. ## Publish a pin `idempotency_key` is required on `PinCreate`, and you set exactly one of `image_url` or `asset_id`. `alt_text` (up to 500 characters) describes the image for people using screen readers: ```python from pinbridge_sdk import PinbridgeClient from pinbridge_sdk.models import PinCreate with PinbridgeClient(api_key="$PINBRIDGE_API_KEY") as client: pin = client.pins.create( PinCreate( account_id="6f1c2e4a-3b7d-4c9e-8a21-5d0f9b3e7c41", board_id="987654321098765432", title="Summer styles 2026", image_url="https://cdn.shop/img/1.jpg", alt_text="Three linen summer dresses on a clothes rail", idempotency_key="launch-123", ) ) print(pin.status) # queued ``` Pass the same `PinCreate` to `client.pins.validate(...)` for a dry run. It checks the board, the media and your quota without publishing, and returns `valid`, the `checks` it ran and the `resolved` values. ## Upload an asset, then publish by asset_id Upload an image or video to PinBridge storage, then use the returned asset's id. Asset uploads aren't available on the Free (Playground) plan, which publishes from a public `image_url`. ```python from pinbridge_sdk import PinbridgeClient from pinbridge_sdk.models import PinCreate with PinbridgeClient(api_key="$PINBRIDGE_API_KEY") as client: with open("pin.jpg", "rb") as f: asset = client.assets.upload_image(f) pin = client.pins.create( PinCreate( account_id="6f1c2e4a-3b7d-4c9e-8a21-5d0f9b3e7c41", board_id="987654321098765432", title="Summer styles 2026", asset_id=asset.id, idempotency_key="launch-124", ) ) print(pin.status) ``` Use `client.assets.upload_video(f)` for videos. ## Publish several pins in one call `client.pins.create_batch` takes a list of pins and reports each one separately as `created`, `existing` (its idempotency key was already used) or `failed`. It needs a plan with bulk publishing. ```python with PinbridgeClient(api_key="$PINBRIDGE_API_KEY") as client: result = client.pins.create_batch([pin_one, pin_two, pin_three]) # PinCreate objects print(result.created_count, result.existing_count, result.failed_count) ``` For large files, `client.pins.import_csv(f)` and `client.pins.import_json(rows)` start an import job. Follow it with `client.pins.get_import(job_id)`. ## Schedule a pin `ScheduleCreate.run_at` must be a future time with a timezone. The SDK refuses a time without one before sending anything. Schedules take `alt_text` too: ```python from datetime import datetime, timedelta, timezone from pinbridge_sdk import PinbridgeClient from pinbridge_sdk.models import ScheduleCreate with PinbridgeClient(api_key="$PINBRIDGE_API_KEY") as client: schedule = client.schedules.create( ScheduleCreate( account_id="6f1c2e4a-3b7d-4c9e-8a21-5d0f9b3e7c41", board_id="987654321098765432", title="Autumn drop", image_url="https://cdn.shop/img/2.jpg", alt_text="A wool coat and scarf laid out on a wooden floor", run_at=datetime.now(timezone.utc) + timedelta(hours=2), ) ) print(schedule.status) # scheduled ``` ### Change, cancel or retry a schedule Edit a pending schedule in place instead of cancelling it and creating a new one. Send only the fields you change; `None` clears an optional field such as `alt_text`: ```python from pinbridge_sdk.models import ScheduleUpdate with PinbridgeClient(api_key="$PINBRIDGE_API_KEY") as client: client.schedules.update( "2d8c7a51-0b9e-4f3a-8c6d-1e2f3a4b5c6d", ScheduleUpdate( run_at=datetime(2026, 12, 1, 9, 0, tzinfo=timezone.utc), alt_text="A wool coat and red scarf on a wooden floor", ), ) ``` `client.schedules.cancel(schedule_id)` stops a pending schedule, and `client.schedules.retry(schedule_id)` runs a failed one again. `client.schedules.validate(...)` is the dry run. ## List, search and page through results `list()` returns one page as a list. `list_page()` takes the same filters and returns a `Page` with `items`, `total` and `has_more`: ```python with PinbridgeClient(api_key="$PINBRIDGE_API_KEY") as client: page = client.pins.list_page(status="failed", q="autumn", limit=50, offset=0) print(page.total, page.has_more) for pin in page.items: print(pin.id, pin.title) upcoming = client.schedules.list(sort="run_at_asc", limit=10) one = client.pins.get("8a3f6c12-4d5e-4b7a-9c01-2e6f8d4b7a93") ``` ## Edit or retry a pin `client.pins.update(pin_id, PinUpdate(...))` changes a pin's title, description, link, alt text or board before it publishes. A published pin can't be edited (the API returns `409 pin_already_published`), so delete it and publish a corrected one. `client.pins.retry(pin_id)` re-queues a failed pin. Bulk versions exist: `client.pins.bulk_retry(ids)` and `client.pins.bulk_delete(ids)`. ## Boards (under client.pinterest) There is no `client.boards`. Boards live under `client.pinterest`: ```python with PinbridgeClient(api_key="$PINBRIDGE_API_KEY") as client: account_id = "6f1c2e4a-3b7d-4c9e-8a21-5d0f9b3e7c41" boards = client.pinterest.list_boards(account_id) access = client.pinterest.check_board_access("987654321098765432", account_id=account_id) print(access.publishable, access.code) ``` `create_board` and `update_board` take a `BoardCreateRequest` or `BoardUpdateRequest`. `delete_board(board_id, account_id=...)` deletes the board on Pinterest with every pin on it, and can't be undone. `list_related_terms(account_id, ["yoga"])` suggests related search terms. ## Analytics and the dashboard summary ```python with PinbridgeClient(api_key="$PINBRIDGE_API_KEY") as client: account = client.pinterest.account_analytics( "6f1c2e4a-3b7d-4c9e-8a21-5d0f9b3e7c41", start_date="2026-10-01", end_date="2026-10-28", ) pin_stats = client.pins.analytics("8a3f6c12-4d5e-4b7a-9c01-2e6f8d4b7a93") summary = client.dashboard.summary(start="2026-10-01T00:00:00Z", end="2026-11-01T00:00:00Z") ``` Account and pin analytics return impressions, saves and clicks per day. The dashboard summary covers publishing activity for a time range: pin outcomes, a time series, the current queue, schedules and import jobs. ## Plan, quota and rate limits ```python with PinbridgeClient(api_key="$PINBRIDGE_API_KEY") as client: billing = client.billing.status() print(billing.plan, billing.calls_used, billing.quota_calls_monthly, billing.quota_reset_at) meter = client.rate_meter.get("6f1c2e4a-3b7d-4c9e-8a21-5d0f9b3e7c41") ``` `calls_used` counts the pins published this calendar month (UTC). Plan changes and credit packs are handled in the PinBridge dashboard. See [Billing & credits](/docs/billing/) and [Rate limits & quotas](/docs/rate-limits/). ## Webhooks Register a webhook to receive `pin.published` and `pin.failed` events. The `secret` must be 16 to 255 characters: ```python from pinbridge_sdk import PinbridgeClient from pinbridge_sdk.models import WebhookCreate, WebhookUpdate with PinbridgeClient(api_key="$PINBRIDGE_API_KEY") as client: hook = client.webhooks.create( WebhookCreate( url="https://example.com/pinbridge", secret="a-secret-at-least-16-chars", events=["pin.published", "pin.failed"], ) ) client.webhooks.list() client.webhooks.get(hook.id) client.webhooks.update(hook.id, WebhookUpdate(url="https://example.com/hook2")) client.webhooks.delete(hook.id) ``` ## More examples Runnable examples for each resource are in the [python-examples repository](https://github.com/pinbridge/python-examples). ## Next steps - [Authentication](/docs/authentication/): create the API key the client uses. - [API reference](/docs/api/): every endpoint the SDK calls. - [Rate limits & quotas](/docs/rate-limits/): what counts against your quota. - [MCP setup](/docs/mcp/setup/): the same publishing pipeline from an AI assistant. ================================================================================ # Rate limits & quotas URL: https://www.pinbridge.io/docs/rate-limits/ ================================================================================ # Rate limits & quotas Runtime throttling, monthly pin-creation quotas, and the billing errors that enforce them. ## Runtime throttling (token buckets) PinBridge enforces request pacing with Redis-backed token buckets: - **Per Pinterest account** — capacity 50 tokens, refill 1.0 token/second. - **Global** — capacity 200 tokens, refill 3.0 tokens/second. Inspect current token availability at any time: ```bash curl "https://api.pinbridge.io/v1/rate-meter?account_id=6f1c2e4a-3b7d-4c9e-8a21-5d0f9b3e7c41" \ -H "X-API-Key: $PINBRIDGE_API_KEY" ``` The rate-meter needs a connected Pinterest account. Calling it for an account that is not connected returns `404`. ## Monthly quotas by billing plan Only successful new pin creations count against this quota. That includes direct `POST /v1/pins`, successful import rows, and successful scheduled publishes. Reads, auth, board sync, webhooks, invalid rows, and idempotent retries do not. | Plan | Monthly pin creations | Pinterest accounts | Notes | |---|---|---|---| | Playground | 50 | 1 | Basic publish flow and board listing | | Starter | 300 | 2 | Uploaded assets + bulk imports | | Growth | 1,000 | 5 | Uploaded assets + bulk imports | | Agency | 5,000 | 15 | Uploaded assets + bulk imports | | Enterprise | Custom | Custom | Manual setup and custom limits | The API `plan` enum value for the Agency plan is `pro`. Enterprise is sold as "Custom" and is not part of the self-serve catalog. See [/pricing/](/pricing/) for current prices. When the monthly quota is exceeded, `POST /v1/pins` returns `402 Payment Required` with error code `quota_exceeded`. On paid plans with credits enabled, PinBridge debits 1 credit per pin after the monthly quota is used up. See [Billing & credits](/docs/billing/). ## Billing and limit errors - `402 billing_inactive` — subscription is not active or trialing on a paid plan. - `402 quota_exceeded` — monthly pin-creation quota reached (and credits are not applicable). - `402 credits_exhausted` — quota reached, credits are enabled, and the balance is 0. - `403 plan_limit` — Pinterest account connection limit reached for your plan. ## Pinterest platform limits Pinterest applies its own upstream limits. PinBridge handles Pinterest `429` responses by backing off and retrying in worker processing where appropriate, so a Pinterest-side throttle does not fail your publish outright. ## Good to know - PinBridge does not currently emit universal `X-RateLimit-*` headers on every response. For live throttling visibility, use `GET /v1/rate-meter`. - For quota visibility, use `GET /v1/billing/status`. - Bulk imports run asynchronously through import jobs. Use `POST /v1/pins/imports/json`, `POST /v1/pins/imports/csv`, and `GET /v1/pins/imports/{job_id}` to monitor long-running uploads. ## Next steps - [Billing & credits](/docs/billing/) — plans, what counts against quota, and credit packs. - [Authentication](/docs/authentication/) — get an API key before you publish. - [Quickstart](/docs/) — connect Pinterest and publish your first pin. - [Python SDK](/docs/python-sdk/) — read the rate-meter and billing status from code. ================================================================================ # Sandbox URL: https://www.pinbridge.io/docs/sandbox/ ================================================================================ # Sandbox The sandbox is a separate project where you can build and test an integration without publishing anything to Pinterest. It simulates the whole publish flow so you can develop confidently, then flip to production when you are ready. ## What the sandbox simulates When you publish in the sandbox, PinBridge does not call Pinterest at all. Instead: - **Publishing is simulated locally.** The pin still flows through the normal queue and reaches `published`, but no real Pinterest write happens. - **You get a fake pin ID.** The simulated pin gets a deterministic 18-digit numeric `pinterest_pin_id` that echoes your board, title, and link back, so you can exercise your success-handling code. - **No real Pinterest and no OAuth token.** The simulated publish path never uses an access token or the network. - **Board writes are disabled by default.** Creating or deleting boards in the sandbox returns `403 sandbox_board_writes_disabled`. You publish to a simulated board ID that PinBridge fills in for you at pin-create time. - **Deletes are hard deletes.** In production, deleting a pin is a soft delete. In the sandbox, deletes remove the record for real, which keeps a test project tidy. Because publishing is simulated by default, a sandbox pin is "published" as fast as the worker picks it up. It is not synchronous in the API response, but it skips the network round trip, token refresh, and per-account rate limiter. See the [pin state machine](/docs/core-concepts/) for the states a pin moves through. ## You need a user session, not just an API key Creating, switching, and resetting projects act on your organization, so they require a **user-session token** in `Authorization: Bearer `, not an API key. These project endpoints reject API keys. (Once you are publishing inside the sandbox, individual pin calls use an API key as usual, see below.) Get a session token by logging in (`POST /v1/auth/login`) or from the dashboard. See [Core concepts](/docs/core-concepts/) for how the two auth styles differ. ## Create a sandbox project Creating a sandbox is idempotent. If a sandbox already exists for your organization, you get it back. curl: ```bash curl -sS -X POST https://api.pinbridge.io/v1/projects/sandbox \ -H "Authorization: Bearer $PINBRIDGE_SESSION_TOKEN" \ -H "Content-Type: application/json" \ -d '{"name": "My sandbox"}' ``` The `name` field is optional. The response is your projects context, listing the organization, the active project, and all projects including the new sandbox. Expected response (trimmed): ```json { "active_project": { "id": "7b1e4c9a-0d5f-4e2b-9a63-4d8f2b6e0c19", "environment": "production" }, "projects": [ { "id": "7b1e4c9a-0d5f-4e2b-9a63-4d8f2b6e0c19", "name": "Production", "environment": "production" }, { "id": "2a6d8f1b-5c3e-4a9d-b7f2-8e4c0a6b1d35", "name": "My sandbox", "environment": "sandbox" } ] } ``` Note that creating the sandbox does not move you into it. `active_project` is still production. Switch into it next. Python SDK (authenticate with the session token, not an API key): ```python from pinbridge_sdk import PinbridgeClient with PinbridgeClient(bearer_token="$PINBRIDGE_SESSION_TOKEN") as client: ctx = client.projects.create_sandbox() # optionally pass {"name": "My sandbox"} sandbox = next(p for p in ctx.projects if p.environment == "sandbox") print("sandbox project id:", sandbox.id) ``` ## Switch into the sandbox Switching projects is how you enter the sandbox. It returns a **new access token** whose claims include the environment. In other words, the environment travels in the JWT, not in a per-request header. Use the returned token for everything you do in the sandbox. curl: ```bash curl -sS -X POST https://api.pinbridge.io/v1/projects/switch \ -H "Authorization: Bearer $PINBRIDGE_SESSION_TOKEN" \ -H "Content-Type: application/json" \ -d '{"project_id": "2a6d8f1b-5c3e-4a9d-b7f2-8e4c0a6b1d35"}' ``` Expected response (trimmed): ```json { "active_project": { "id": "2a6d8f1b-5c3e-4a9d-b7f2-8e4c0a6b1d35", "environment": "sandbox" }, "access_token": "eyJhbGciOi...", "token_type": "bearer", "expires_in": 3600 } ``` Python SDK: ```python from pinbridge_sdk import PinbridgeClient with PinbridgeClient(bearer_token="$PINBRIDGE_SESSION_TOKEN") as client: switched = client.projects.switch({"project_id": "2a6d8f1b-5c3e-4a9d-b7f2-8e4c0a6b1d35"}) client.set_bearer_token(switched.access_token) # now scoped to the sandbox ``` ## Publish in the sandbox Once you are in the sandbox you publish exactly as you would in production. Point your requests at the sandbox: either keep using the switched session token, or create an API key while in the sandbox project and use that key's `X-API-Key` for pin calls. An API key created in the sandbox project is scoped to the sandbox, so pins you publish with it are simulated. This is what makes the sandbox convenient for tools that only take an API key, like the n8n node: set a **sandbox-scoped** API key as the `apiKey` in the `pinBridgeApi` credential and your workflow publishes into the sandbox. The n8n node has no project-management action, so create and switch the sandbox through the API or dashboard first, then hand n8n the sandbox key. See [Publish your first pin](/docs/n8n/publish-your-first-pin/). A simulated pin reaches `published` and carries a fake `pinterest_pin_id`. Nothing appears on Pinterest. ## Reset the sandbox Resetting wipes the sandbox project's data (its pins, schedules, connected accounts, webhooks, API keys, usage, and uploaded assets) so you can start clean. It does not touch production. curl: ```bash curl -sS -X POST https://api.pinbridge.io/v1/projects/sandbox/reset \ -H "Authorization: Bearer $PINBRIDGE_SESSION_TOKEN" ``` Python SDK: ```python from pinbridge_sdk import PinbridgeClient with PinbridgeClient(bearer_token="$PINBRIDGE_SESSION_TOKEN") as client: client.projects.reset_sandbox() ``` If there is no sandbox project to reset, this returns `404`. ## Moving to live When your integration works in the sandbox, switching to production is the same switch operation, pointed at your production project: ```bash curl -sS -X POST https://api.pinbridge.io/v1/projects/switch \ -H "Authorization: Bearer $PINBRIDGE_SESSION_TOKEN" \ -H "Content-Type: application/json" \ -d '{"project_id": "7b1e4c9a-0d5f-4e2b-9a63-4d8f2b6e0c19"}' ``` Then use a production API key for publishing. Before you go live: - [Connect a real Pinterest account](/docs/connect-pinterest/) in your production project. Sandbox connections do not carry over. - Swap any sandbox API key for a production key in your app, n8n credential, or scripts. - Remember that production pins count against your monthly pin quota and are soft-deleted rather than hard-deleted. ## Common errors and troubleshooting - **`401 Invalid or missing access token`** on a project endpoint. You sent an API key. Create, switch, and reset need a user-session JWT in `Authorization: Bearer`. - **`403 sandbox_board_writes_disabled`** when creating or deleting a board in the sandbox. Board writes are off by default. Publish to the simulated board ID instead. - **`404 Sandbox project not found`** on reset. Create the sandbox first. - **Pins publish to real Pinterest when you expected simulation.** Confirm your token or API key is scoped to the sandbox project, not production. Check `active_project.environment` after switching. ## Next steps - [Core concepts](/docs/core-concepts/) for projects, environments, and the pin state machine. - [Connect Pinterest](/docs/connect-pinterest/) to add a real account for production. - [Publish your first pin](/docs/n8n/publish-your-first-pin/) once you are ready to go live. ================================================================================ # Troubleshooting URL: https://www.pinbridge.io/docs/troubleshooting/ ================================================================================ # Troubleshooting Common problems and how to fix them. Each article follows the same shape: the symptom, the cause, the fix, and related links. For the full list of API errors by HTTP status, see the [Error reference](/docs/errors/). ## Publishing - [Why did Pinterest change my pin title?](/docs/troubleshooting/pinterest-changed-my-title/) — Pinterest re-scrapes your destination page and can override the title from its OG tags. - [Why is my pin stuck in queued?](/docs/troubleshooting/pin-stuck-in-queued/) — how queued and deferred work, and when a stuck pin is auto-recovered. - [Why did my pin fail?](/docs/troubleshooting/my-pin-failed/) — read the pin's `error_code` and `error_message`, then act on it. ## Accounts and access - [Why did my Pinterest account get disconnected?](/docs/troubleshooting/account-disconnected/) — `token_expired` and `token_revoked`, and how to reconnect. - [Why am I getting 402 quota_exceeded?](/docs/troubleshooting/quota-exceeded-402/) — monthly quota, credits, and upgrading. - [Why am I getting 403 plan_limit?](/docs/troubleshooting/plan-limit-403/) — account, API key, and feature limits per plan. - [Why am I getting 401 Invalid API key?](/docs/troubleshooting/invalid-api-key-401/) — the `X-API-Key` header and key scope. ## Media, webhooks, and infrastructure - [My video upload failed. What now?](/docs/troubleshooting/video-upload-failed/) — supported formats, plan-based size limits, and disk quota. - [Why am I not receiving webhooks?](/docs/troubleshooting/not-receiving-webhooks/) — enablement, 2xx responses, retries, and signatures. - [Is it safe that PinBridge uses shared IPs?](/docs/troubleshooting/shared-ips-safe/) — why shared server IPs are expected and not a risk. ## Next steps - [Error reference](/docs/errors/) — every API error by HTTP status. - [PinBridge for n8n](/docs/n8n/) — publish and schedule from n8n. ================================================================================ # My Pinterest account got disconnected URL: https://www.pinbridge.io/docs/troubleshooting/account-disconnected/ ================================================================================ # My Pinterest account got disconnected ## Symptom Pins fail with `error_code` `token_expired` or `token_revoked`, or a call returns `Pinterest account not found` (404). Publishing to that account stops working. ## Cause PinBridge publishes using an OAuth token that Pinterest issued when you connected the account. That token can stop working: - **`token_expired`** — the token reached the end of its life and could not be refreshed. - **`token_revoked`** — access was revoked, usually because someone removed PinBridge from the Pinterest account's connected apps, or changed the Pinterest password, or Pinterest invalidated the grant. In both cases PinBridge can no longer act on the account until you reconnect it. ## Fix 1. **Reconnect the account.** In PinBridge, start the Pinterest connect flow again for the affected account and complete the OAuth consent on Pinterest. Grant all requested permissions so you do not hit `scope_missing` afterward. 2. **Confirm the connection.** List your accounts and check the account is present and not revoked: ```bash curl -H "X-API-Key: $PINBRIDGE_API_KEY" \ "https://api.pinbridge.io/v1/pinterest/accounts" ``` 3. **Retry the failed pins.** Reconnecting does not automatically re-publish pins that already failed. Retry them: ```bash curl -X POST -H "X-API-Key: $PINBRIDGE_API_KEY" \ "https://api.pinbridge.io/v1/pins//retry" ``` For many pins, use `POST /v1/pins/bulk-retry`. See [My pin failed](/docs/troubleshooting/my-pin-failed/). ## Preventing it There is no way to stop Pinterest from expiring or revoking a token, but you can catch it early: subscribe to the `pin.failed` webhook and watch for `token_expired` / `token_revoked` in the payload so you know to reconnect. See [I'm not receiving webhooks](/docs/troubleshooting/not-receiving-webhooks/). ## Related - [My pin failed](/docs/troubleshooting/my-pin-failed/) — read the pin's error code. - [Error reference](/docs/errors/) — 401, 403, and 404 account errors. - [Troubleshooting](/docs/troubleshooting/) — all articles. ================================================================================ # 401 Invalid API key URL: https://www.pinbridge.io/docs/troubleshooting/invalid-api-key-401/ ================================================================================ # 401 Invalid API key ## Symptom A request returns HTTP 401 with one of: ```json { "detail": "Invalid API key" } ``` ```json { "detail": "Missing API key. Provide X-API-Key or Authorization: Bearer ." } ``` ## Cause Authentication failed. Common reasons: - The key is missing from the request. - The key is mistyped, revoked, or from a different project. - You sent it in the wrong place (for example, a request body or query string instead of a header). - You used a user-session token where an API key was expected. ## Fix ### 1. Send the key in the `X-API-Key` header ```bash curl -H "X-API-Key: $PINBRIDGE_API_KEY" \ "https://api.pinbridge.io/v1/pinterest/accounts" ``` The API also accepts `Authorization: Bearer ` for compatibility, but `X-API-Key` is the standard header for API keys. Do not put the key in the URL or body. ### 2. Use a project API key, not a session token API keys come from your project settings. A user-session JWT is a different credential. Some endpoints only accept one or the other: - `X-API-Key` works for all publishing and read endpoints. - `/v1/auth/me` and `/v1/projects` require a user-session JWT and reject API keys. If you call those with an API key you get 401. ### 3. Check the key's scope Make sure the key belongs to the project you are targeting. A valid key for project A returns 401-like "not found" behavior against project B's resources. Regenerate the key in the correct project if unsure. ### 4. Test the key `GET /v1/pinterest/accounts` is a safe first call: it is not plan-gated and returns an empty list for a project with no connected accounts, so a valid key passes even on a brand-new project. ```bash curl -i -H "X-API-Key: $PINBRIDGE_API_KEY" \ "https://api.pinbridge.io/v1/pinterest/accounts" ``` A `200` with a JSON array means the key is good. A `401` means recheck the value and header. ## Related - [403 plan_limit](/docs/troubleshooting/plan-limit-403/) — when the key is valid but the plan blocks the action. - [Error reference](/docs/errors/) — all 401 errors. - [Troubleshooting](/docs/troubleshooting/) — all articles. ================================================================================ # My pin failed URL: https://www.pinbridge.io/docs/troubleshooting/my-pin-failed/ ================================================================================ # My pin failed ## Symptom A pin's status is `failed` and it did not publish to Pinterest. ## Cause A pin reaches `failed` either because the problem is not retryable (for example a bad payload or a revoked token), or because it exhausted its automatic retries (up to 8 deferrals). Every failed pin carries two fields that tell you why: `error_code` and `error_message`. ## Fix ### 1. Read the error Fetch the pin and look at `error_code` and `error_message`: ```bash curl -H "X-API-Key: $PINBRIDGE_API_KEY" \ "https://api.pinbridge.io/v1/pins/" ``` ```json { "id": "", "status": "failed", "error_code": "board_access_denied", "error_message": "..." } ``` ### 2. Match the error code to an action | `error_code` | What it means | What to do | |---|---|---| | `rate_limited` | Pinterest rate limit hit, retries exhausted. | Wait, then retry. Slow your publish rate. See [My pin is stuck in queued](/docs/troubleshooting/pin-stuck-in-queued/). | | `token_expired` | The Pinterest token expired. | Reconnect the account, then retry. See [My Pinterest account got disconnected](/docs/troubleshooting/account-disconnected/). | | `token_revoked` | The Pinterest token was revoked. | Reconnect the account, then retry. | | `scope_missing` | The token lacks a required Pinterest permission. | Reconnect and grant all requested permissions. | | `board_access_denied` | The account cannot post to that board. | Use a board the account owns or can access. | | `resource_not_found` | The board or a referenced resource no longer exists. | Check the `board_id` and account. | | `invalid_payload` | Pinterest rejected the pin data. | Fix the pin fields (title, link, media) and retry. | | `media_url_unreachable` | Pinterest could not fetch your image or video URL. | Use a publicly reachable URL, or upload an asset. | | `media_url_temporarily_unavailable` | Your hosted media was briefly unreachable. | Usually transient. Retry. | | `transient_upstream` | A temporary Pinterest or upstream error. | Retry shortly. | | `publish_timeout` | The pin stalled and was failed by the backstop sweep. | Retry. See [My pin is stuck in queued](/docs/troubleshooting/pin-stuck-in-queued/). | | `api_error` | Fallback for an unclassified Pinterest error. | Check `error_message`, fix if clear, then retry. | Other codes can appear for setup or internal issues, including `missing_video_asset`, `asset_not_found`, `account_not_found`, `sandbox_board_not_allowed`, `pinterest_board_environment_mismatch`, `queue_error`, and `internal_error`. These usually mean a referenced ID is wrong or there was a temporary internal fault. Fix the referenced ID, or retry and contact support if it persists. ### 3. Retry the pin Only `failed` pins can be retried (a non-failed pin returns 409): ```bash curl -X POST -H "X-API-Key: $PINBRIDGE_API_KEY" \ "https://api.pinbridge.io/v1/pins//retry" ``` Retry resets the pin to `queued` and clears the error. You can override `board_id` or `account_id` on retry if that was the problem. For many pins at once, use `POST /v1/pins/bulk-retry`. ## Related - [Error reference](/docs/errors/) — every error by HTTP status, plus the full pin failure code list. - [My Pinterest account got disconnected](/docs/troubleshooting/account-disconnected/) — for `token_expired` / `token_revoked`. - [My pin is stuck in queued](/docs/troubleshooting/pin-stuck-in-queued/) — before a pin fails. ================================================================================ # I'm not receiving webhooks URL: https://www.pinbridge.io/docs/troubleshooting/not-receiving-webhooks/ ================================================================================ # I'm not receiving webhooks ## Symptom You registered a webhook, but your endpoint never receives PinBridge deliveries, or receives some and then stops. ## Causes and fixes ### 1. Only two events exist PinBridge emits exactly two webhook events: **`pin.published`** and **`pin.failed`**. Any other event name you subscribe to (for example `schedule.created` or `asset.uploaded`) will never fire, because those are activity-log actions, not webhook events. Make sure your webhook subscribes to `pin.published` and/or `pin.failed`. ### 2. The webhook is not enabled A webhook only delivers when it is enabled (`is_enabled`). Confirm it is on: ```bash curl -H "X-API-Key: $PINBRIDGE_API_KEY" \ "https://api.pinbridge.io/v1/webhooks" ``` If `is_enabled` is false, update the webhook to enable it. ### 3. Your endpoint must return 2xx A delivery counts as successful only if your endpoint returns an HTTP **2xx** status. Any other status (or a network error) is treated as a failure and retried. - Respond `200` (any 2xx) as soon as you receive the payload. - Do your processing after responding, not before, so slow work does not cause a timeout. ### 4. Respond within the timeout Each attempt has a **30-second** timeout. If your endpoint takes longer, the attempt is counted as failed. Acknowledge fast, process async. ### 5. Retries are finite Failed deliveries are retried up to **5 attempts** with exponential backoff (roughly 2, 4, 8, 16 minutes after the first attempts). After that the delivery is marked permanently failed and will not be retried. If your endpoint was down for a while, expect to miss those deliveries; fix the endpoint before the retries run out. ### 6. Signature mismatch If you verify signatures (recommended) and your check fails, you may be rejecting valid deliveries. PinBridge signs with **HMAC-SHA256** over the **raw JSON request body only** (no timestamp), and sends the hex digest in the `X-PinBridge-Signature` header. Also sent: `X-PinBridge-Event` (the event type) and `X-PinBridge-Delivery-ID`. To verify: - Compute `HMAC-SHA256(secret, raw_body)` and hex-encode it. - Compare against `X-PinBridge-Signature` using a constant-time comparison. - Use the **raw** request bytes, not a re-serialized JSON object. Re-serializing changes the bytes and breaks the signature. - There is no timestamp in the signed string; do not expect a Stripe-style `t=...,v1=...` header. The secret is set when you create the webhook (16 to 255 characters, required). If you rotate it, update your verifier too. ### 7. Payload shape Only these fields are sent, and the event type is in the header, not the body: ```json // pin.published { "pin_id": "...", "pinterest_pin_id": "...", "title": "...", "published_at": "..." } // pin.failed { "pin_id": "...", "error_code": "...", "error_message": "..." } ``` If your handler expects a top-level `event` or `timestamp` field in the body, that is why it looks empty. Read the event type from `X-PinBridge-Event`. ## Related - [My pin failed](/docs/troubleshooting/my-pin-failed/) — the `pin.failed` payload carries the same `error_code`. - [Error reference](/docs/errors/) — 422 errors when creating a webhook (secret and URL rules). - [Troubleshooting](/docs/troubleshooting/) — all articles. ================================================================================ # My pin is stuck in queued URL: https://www.pinbridge.io/docs/troubleshooting/pin-stuck-in-queued/ ================================================================================ # My pin is stuck in queued ## Symptom You created a pin, but its status stays `queued` (or `deferred`) instead of moving to `published`. ## Cause A pin flows through these states: `queued → publishing → published`, or `failed`. Retryable problems (a temporary Pinterest rate limit or upstream hiccup) send the pin `queued → deferred`, where it waits for an automatic retry rather than failing. - **`queued`** means the pin is waiting for a worker to pick it up, or waiting out a short retry delay (around 60 seconds plus jitter for transient errors). - **`deferred`** means the pin has already been retried a few times and is now parked for a longer wait (about an hour) before trying again. PinBridge defers up to 8 times before giving up and marking the pin `failed`. So a pin sitting in `queued` or `deferred` for a while is usually normal: it is being retried automatically. You do not need to do anything. ## Fix 1. **Wait for the automatic retry.** Deferred pins retry roughly hourly. Transient `queued` pins retry within a minute or two. There is no need to resubmit. 2. **Check the rate meter.** If many pins are queued at once, you may be near Pinterest's rate limit. Check `GET /v1/rate-meter?account_id=6f1c2e4a-3b7d-4c9e-8a21-5d0f9b3e7c41`: ```bash curl -H "X-API-Key: $PINBRIDGE_API_KEY" \ "https://api.pinbridge.io/v1/rate-meter?account_id=6f1c2e4a-3b7d-4c9e-8a21-5d0f9b3e7c41" ``` 3. **Do not retry a queued or deferred pin manually.** The retry endpoint only accepts `failed` pins, so calling it on a queued pin returns 409. See [My pin failed](/docs/troubleshooting/my-pin-failed/). 4. **Backstop recovery.** If a pin somehow stalls in `queued` or `publishing` for a long time, PinBridge's stuck-pin sweep re-drives it automatically. A pin idle past roughly 10 minutes is re-queued; one idle past about 6 hours is failed with `error_code = publish_timeout`. If you see `publish_timeout`, treat it like any failure and retry it. ## When to worry If a pin reaches `failed`, it is done retrying. Read its `error_code` and act on it. See [My pin failed](/docs/troubleshooting/my-pin-failed/). ## Related - [My pin failed](/docs/troubleshooting/my-pin-failed/) — read a failed pin's error code. - [Error reference](/docs/errors/) — all API errors. - [Troubleshooting](/docs/troubleshooting/) — all articles. ================================================================================ # Pinterest changed my pin title URL: https://www.pinbridge.io/docs/troubleshooting/pinterest-changed-my-title/ ================================================================================ # Pinterest changed my pin title ## Symptom You published a pin with a specific title through PinBridge, but the title that shows on Pinterest is different, usually matching text from the destination page instead of what you sent. ## Cause This is Pinterest's behavior, not a PinBridge bug. When a pin links to a destination URL, Pinterest re-scrapes that page and can override your pin's title and description with the page's Open Graph (OG) and meta tags. PinBridge sends the title you provide; Pinterest decides to replace it based on what it finds at the `link_url`. ## Fix Control the title by setting good OG and meta tags on the destination page: - Set `` to the title you want on the pin. - Set `` for the description. - Set `` so the preview image is predictable too. Once the destination page has clean OG tags that match your intended title, re-publish the pin (or publish a new one). Pinterest reads those tags on its next scrape. If you do not control the destination page, or you want the title to stay exactly as sent, publish a pin without a `link_url`, or link to a page whose OG tags you do control. ## Related - [Error reference](/docs/errors/) — full list of API errors. - [My pin failed](/docs/troubleshooting/my-pin-failed/) — when a pin does not publish at all. - [Troubleshooting](/docs/troubleshooting/) — all articles. ================================================================================ # 403 plan_limit URL: https://www.pinbridge.io/docs/troubleshooting/plan-limit-403/ ================================================================================ # 403 plan_limit ## Symptom An action returns HTTP 403 with `error_code` `plan_limit`, `feature_unavailable`, or `plan_required`. For example: ```json { "error_code": "plan_limit", "detail": "Pinterest account limit (2) reached for starter plan.", "current_count": 2 } ``` ## Cause Your plan caps how many of something you can have, or gates a feature off entirely. Three kinds: - **`plan_limit`** — a resource count cap (Pinterest accounts or active API keys). - **`feature_unavailable`** — a gated feature (asset upload, bulk import) is off on your plan. - **`plan_required`** — a feature that requires a specific plan tier (team access needs Agency or Enterprise). ### Per-plan limits | Plan | Pinterest accounts | API keys | Asset upload | Bulk import | Team | |---|---|---|---|---|---| | Playground (Free) | 1 | 1 | No | No | No | | Starter | 2 | 2 | Yes | Yes | No | | Growth | 5 | 5 | Yes | Yes | No | | Agency | 15 | 15 | Yes | Yes | Yes | | Enterprise | 50 | Unlimited | Yes | Yes | Yes | Note: asset upload and bulk import are blocked on the Free (Playground) plan. Free must publish from public `image_url`s. Team roles are Agency and Enterprise only. ## Fix Depending on the error: - **`plan_limit` (accounts)** — disconnect a Pinterest account you no longer use, or upgrade to a plan with a higher cap. - **`plan_limit` (API keys)** — revoke an unused active key, or upgrade. - **`feature_unavailable`** — upgrade to a plan that includes the feature. The response's `upgrade_url` (`/v1/billing/checkout`) takes you there. For asset upload specifically, see [My video upload failed](/docs/troubleshooting/video-upload-failed/). - **`plan_required` (team)** — upgrade to Agency or Enterprise. The `upgrade_url` is `/pricing`. If you run several PinBridge accounts to work around limits, consolidate under one account on the right plan instead. It is simpler to manage and usually cheaper. ## Related - [402 quota_exceeded](/docs/troubleshooting/quota-exceeded-402/) — the monthly pin quota (a different limit). - [Error reference](/docs/errors/) — all 403 errors. - [Troubleshooting](/docs/troubleshooting/) — all articles. ================================================================================ # 402 quota_exceeded URL: https://www.pinbridge.io/docs/troubleshooting/quota-exceeded-402/ ================================================================================ # 402 quota_exceeded ## Symptom Publishing returns HTTP 402 with this body: ```json { "error": { "code": "quota_exceeded", "message": "Monthly pin creation quota (1000) exceeded. Current usage: 1000.", "upgrade_url": "/v1/billing/checkout" } } ``` ## Cause Every plan includes a monthly pin quota, counted as **successful publishes**: | Plan | Monthly pins | |---|---| | Playground (Free) | 50 | | Starter | 300 | | Growth | 1,000 | | Agency | 5,000 | | Enterprise | Custom | `quota_exceeded` means you have used all of this month's quota and credits do not apply (either credits are not enabled, or you are on a plan or trial that cannot use them). The quota resets on your monthly billing cycle. If you have credits enabled but a zero balance, you will see `credits_exhausted` (also 402) instead, with `upgrade_url` pointing to the credit purchase endpoint. ## How quota and credits work together - Your monthly quota is consumed **first**, one per successful publish. - Only after the monthly quota is exhausted does PinBridge debit **1 credit per pin**, if credits are enabled. - Free and trial workspaces cannot buy or use credits. ## Fix Choose one: 1. **Wait for the monthly reset.** Quota refreshes on your billing cycle. 2. **Buy a credit pack** (paid, non-trial plans only). Packs: Small 200 pins ($8), Medium 500 ($17.50), Large 1,500 ($45), XLarge 3,000 ($75). Credits are purchased through billing (`upgrade_url` on a `credits_exhausted` response points to the purchase endpoint). Note there is no SDK method for credits; buy them through the billing UI or API. 3. **Upgrade your plan** for a higher monthly quota. Follow the `upgrade_url` on the 402 response (`/v1/billing/checkout`). On Agency and Enterprise, `upgrade_url` is `null`; contact support to raise limits. ## Related - [403 plan_limit](/docs/troubleshooting/plan-limit-403/) — a different limit (accounts, keys, features). - [Error reference](/docs/errors/) — all 402 billing errors and the envelope shape. - [Troubleshooting](/docs/troubleshooting/) — all articles. ================================================================================ # Is it safe that PinBridge uses shared IPs? URL: https://www.pinbridge.io/docs/troubleshooting/shared-ips-safe/ ================================================================================ # Is it safe that PinBridge uses shared IPs? ## The question PinBridge publishes on behalf of many customers from a shared pool of server IP addresses. Does sharing IPs with other accounts put your Pinterest account at risk? ## Short answer No. Pinterest identifies and authorizes your account by its **OAuth token**, not by IP address. Shared server IPs are exactly what Pinterest expects from a registered API application, so this is normal and not a risk. ## Why - **Pinterest authorizes by token, not IP.** When you connect your account, Pinterest issues PinBridge an OAuth token scoped to your account. Every publish is authenticated with that token. Pinterest ties the action to your account through the token, regardless of which server IP the request came from. - **Registered API apps are meant to run from shared infrastructure.** PinBridge is a registered Pinterest API application. API traffic from a hosting provider's shared IPs is the expected pattern for any such app, the same way large email or scheduling platforms operate. It is not treated like suspicious browser logins from unfamiliar locations. - **This is different from logging in through a browser.** Password logins from new locations can trigger security checks. API publishing through an authorized token does not work that way. ## What actually protects your account - Only connect PinBridge to accounts you control. - Grant only the permissions you need during the OAuth consent. - If you ever want to cut access, revoke PinBridge from your Pinterest connected apps. That invalidates the token, which is the real access control (see [My Pinterest account got disconnected](/docs/troubleshooting/account-disconnected/)). ## Related - [My Pinterest account got disconnected](/docs/troubleshooting/account-disconnected/) — how token access works. - [Error reference](/docs/errors/) — authentication and account errors. - [Troubleshooting](/docs/troubleshooting/) — all articles. ================================================================================ # My video upload failed URL: https://www.pinbridge.io/docs/troubleshooting/video-upload-failed/ ================================================================================ # My video upload failed ## Symptom Uploading a video (or image) to PinBridge storage returns an error instead of an asset. ## Causes and fixes ### Unsupported format (400) `Unsupported video content type` means the file is not one of the accepted types. - **Videos:** `video/mp4` (.mp4) and `video/quicktime` (.mov). - **Images:** gif, jpeg, png, webp. Re-encode the file to a supported format and re-upload. ### Empty file (400) `Uploaded file is empty` means the file had zero bytes. Re-export it and try again. ### File too large for your plan (400) `file_too_large` — "File exceeds the maximum allowed size of `` MB for your plan." Per-file limits are plan-based: | Plan | Image per file | Video per file | |---|---|---| | Playground (Free) | Not allowed | Not allowed | | Starter | 10 MiB | 250 MiB | | Growth | 50 MiB | 500 MiB | | Agency | 100 MiB | 1 GiB | | Enterprise | 100 MiB | 1 GiB | Compress the video, lower the resolution or bitrate, or upgrade to a plan with a higher limit. ### Free plan blocked (403) `feature_unavailable` on upload means you are on the Free (Playground) plan, which cannot upload hosted assets. Free must publish from a public `image_url` instead. To upload videos or images, upgrade to Starter or higher. See [403 plan_limit](/docs/troubleshooting/plan-limit-403/). Note: video pins require an uploaded video `asset_id` (there is no `video_url` field), so publishing a video pin requires a paid plan. ### Disk quota full (402) `storage_quota_exceeded` — "Organization storage quota exhausted. Delete files or upgrade your plan to continue uploading." Disk quota is per organization: | Plan | Disk quota | |---|---| | Playground (Free) | 0 | | Starter | 2 GiB | | Growth | 7 GiB | | Agency | 20 GiB | | Enterprise | 20 GiB | Delete assets you no longer need to free space, or upgrade. Assets have no automatic expiry; they persist until you delete them. The per-file plan limit above is the effective size cap for uploads. ## Related - [403 plan_limit](/docs/troubleshooting/plan-limit-403/) — plan feature gates. - [402 quota_exceeded](/docs/troubleshooting/quota-exceeded-402/) — other 402 billing errors. - [Error reference](/docs/errors/) — all upload errors by status.