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_publishingfeature, which is off on the Free (Playground) plan. A Free-plan key gets a 403feature_unavailable. Bulk imports are available on Starter, Growth, Agency, and Enterprise. - A connected Pinterest account (you need its
account_id) and aboard_idto publish to. - Your API key in the
X-API-Keyheader.
For a single pin, use POST /v1/pins instead. See 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),related_terms,alt_text(max 500),dominant_color(6-digit hex),link_url(max 2048).cover_image_urlorcover_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.
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. Replace 6f1c2e4a-3b7d-4c9e-8a21-5d0f9b3e7c41 and 987654321098765432 with your real IDs.
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)
curl -X POST https://api.pinbridge.io/v1/pins/imports/csv \
-H "X-API-Key: $PINBRIDGE_API_KEY" \
-F "[email protected];type=text/csv"
Expected response (trimmed):
{
"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.
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
curl https://api.pinbridge.io/v1/pins/imports/4c8f2a6e-9b1d-4f3a-b5c7-0e2d6a8f4b92 \
-H "X-API-Key: $PINBRIDGE_API_KEY"
Expected response (trimmed):
{
"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_atrow) was created. - existing — a matching pin or schedule already existed; nothing new was created (see dedupe).
- failed — the row was rejected. Read
error_codeanderror_messagefor 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 returnexisting, 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}/retryfor one, orPOST /v1/pins/bulk-retryfor many. See Retries and failures.
Python SDK
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 (defaultdata). - 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.
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 — the pin state machine and how to retry failed pins.
- Activity logs — audit what your imports created.
- Bulk import pins in n8n — the same flow without code.
- Node reference — every n8n operation and parameter.
