@channel47/pinterest-ads-mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 channel47
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,194 @@
1
+ # @channel47/pinterest-ads-mcp
2
+
3
+ MCP server for Pinterest Ads using the Pinterest REST API (`v5`).
4
+
5
+ This server exposes four tools expected by channel47 Pinterest workflows:
6
+
7
+ - `list_accounts`
8
+ - `query`
9
+ - `analytics`
10
+ - `mutate`
11
+
12
+ ## Installation
13
+
14
+ ### Standalone
15
+
16
+ ```bash
17
+ npx @channel47/pinterest-ads-mcp@latest
18
+ ```
19
+
20
+ ### Monorepo Development
21
+
22
+ ```bash
23
+ cd mcps
24
+ npm install
25
+ npm run test
26
+ ```
27
+
28
+ ## Configuration
29
+
30
+ ### Getting Credentials
31
+
32
+ 1. Create an app at [developers.pinterest.com/apps](https://developers.pinterest.com/apps/) and request Standard access for the `ads:read` (and `ads:write` for mutations) scopes.
33
+ 2. Complete the OAuth authorization-code flow to obtain an access token and refresh token — see [Set up authentication and authorization](https://developers.pinterest.com/docs/getting-started/set-up-authentication-and-authorization/).
34
+ 3. Either paste a valid access token into `PINTEREST_ADS_ACCESS_TOKEN`, or configure the client credentials + refresh token and let this server mint access tokens itself.
35
+
36
+ ### Required (one of the two options)
37
+
38
+ | Variable | Description |
39
+ |----------|-------------|
40
+ | `PINTEREST_ADS_ACCESS_TOKEN` | Pinterest OAuth access token (sent as `Authorization: Bearer`) |
41
+
42
+ or all three of:
43
+
44
+ | Variable | Description |
45
+ |----------|-------------|
46
+ | `PINTEREST_ADS_CLIENT_ID` | Pinterest app ID |
47
+ | `PINTEREST_ADS_CLIENT_SECRET` | Pinterest app secret |
48
+ | `PINTEREST_ADS_REFRESH_TOKEN` | OAuth refresh token used to mint access tokens via `POST /v5/oauth/token` |
49
+
50
+ When both are configured, the refresh-token flow wins. Access tokens are cached in
51
+ memory and refreshed about 5 minutes before expiry. Pinterest uses continuous
52
+ refresh tokens and may rotate the refresh token on use — the server logs a stderr
53
+ warning when that happens because it cannot persist the new value; update
54
+ `PINTEREST_ADS_REFRESH_TOKEN` yourself when you see the warning.
55
+
56
+ ### Optional
57
+
58
+ | Variable | Description |
59
+ |----------|-------------|
60
+ | `PINTEREST_ADS_AD_ACCOUNT_ID` | Default ad account ID used when `ad_account_id` is omitted |
61
+ | `PINTEREST_ADS_READ_ONLY` | Set to `true` to disable live mutations |
62
+ | `PINTEREST_ADS_REQUEST_TIMEOUT_MS` | HTTP request timeout in milliseconds (default `30000`) |
63
+
64
+ ### Claude Code
65
+
66
+ ```bash
67
+ claude mcp add pinterest-ads \
68
+ --env PINTEREST_ADS_ACCESS_TOKEN=<token> \
69
+ --env PINTEREST_ADS_AD_ACCOUNT_ID=<ad-account-id> \
70
+ -- npx @channel47/pinterest-ads-mcp@latest
71
+ ```
72
+
73
+ Or as JSON config:
74
+
75
+ ```json
76
+ {
77
+ "mcpServers": {
78
+ "pinterest-ads": {
79
+ "command": "npx",
80
+ "args": ["@channel47/pinterest-ads-mcp@latest"],
81
+ "env": {
82
+ "PINTEREST_ADS_ACCESS_TOKEN": "<token>",
83
+ "PINTEREST_ADS_AD_ACCOUNT_ID": "<ad-account-id>"
84
+ }
85
+ }
86
+ }
87
+ }
88
+ ```
89
+
90
+ ## Tool Reference
91
+
92
+ ### `list_accounts`
93
+
94
+ List accessible ad accounts from `GET /ad_accounts` with bookmark pagination.
95
+
96
+ **Params:**
97
+ - `include_shared_accounts` (optional): include accounts shared with you (API default `true`)
98
+
99
+ **Notes:**
100
+ - Returns `id`, `name`, `currency`, `country`, `owner_username`, `time_zone`, `permissions`
101
+ - Follows bookmark pagination to return all accessible accounts
102
+
103
+ ### `query`
104
+
105
+ Structured entity reads over:
106
+
107
+ - `campaigns` — `GET /ad_accounts/{id}/campaigns`
108
+ - `ad_groups` — `GET /ad_accounts/{id}/ad_groups`
109
+ - `ads` — `GET /ad_accounts/{id}/ads`
110
+
111
+ **Params:**
112
+ - `entity` (required)
113
+ - `ad_account_id` (optional if `PINTEREST_ADS_AD_ACCOUNT_ID` exists)
114
+ - `entity_statuses`: `ACTIVE`, `PAUSED`, `ARCHIVED`, `DRAFT`, `DELETED_DRAFT` — the API defaults to `ACTIVE, PAUSED`, so pass this to see archived entities
115
+ - `campaign_ids` / `ad_group_ids` / `ad_ids`: id filters (each only where the endpoint supports it)
116
+ - `order`: `ASCENDING` | `DESCENDING` by ID
117
+ - `limit`: rows to return across pages (default `100`, max `1000`; API pages are max `250`)
118
+
119
+ ### `analytics`
120
+
121
+ Metrics via the v5 analytics endpoints:
122
+
123
+ - `account` — `GET /ad_accounts/{id}/analytics`
124
+ - `campaign` — `GET /ad_accounts/{id}/campaigns/analytics` (requires `campaign_ids`)
125
+ - `ad_group` — `GET /ad_accounts/{id}/ad_groups/analytics` (requires `ad_group_ids`)
126
+ - `ad` — `GET /ad_accounts/{id}/ads/analytics` (requires `ad_ids`)
127
+
128
+ **Params:**
129
+ - `start_date` / `end_date` (required, `YYYY-MM-DD`): at most 90 days back and a 90-day range
130
+ - `level`: `account` (default), `campaign`, `ad_group`, `ad`
131
+ - `columns`: defaults to `SPEND_IN_DOLLAR, IMPRESSION_2, CLICKTHROUGH_2, CTR_2, TOTAL_CONVERSIONS` (see the `pinterestads://analytics-columns` resource)
132
+ - `granularity`: `TOTAL` (default), `DAY`, `WEEK`, `MONTH`, `HOUR` (`HOUR` no longer returns conversion metrics)
133
+ - `click_window_days` / `engagement_window_days` / `view_window_days`: `0, 1, 7, 14, 30, 60`
134
+ - `conversion_report_time`: `TIME_OF_AD_ACTION` | `TIME_OF_CONVERSION`
135
+ - `reporting_timezone`: `PINTEREST_TIME_ZONE` | `AD_ACCOUNT_TIME_ZONE`
136
+
137
+ ### `mutate`
138
+
139
+ Mutation tool with dry-run safety by default.
140
+
141
+ **Operation format:**
142
+
143
+ ```json
144
+ {
145
+ "entity": "campaign",
146
+ "action": "update",
147
+ "id": "626735565838",
148
+ "params": {
149
+ "daily_spend_cap": 25000000
150
+ }
151
+ }
152
+ ```
153
+
154
+ **Supported entities:** `campaign`, `ad_group`, `ad`
155
+
156
+ **Supported actions:** `create`, `update`, `pause`, `enable`, `archive`
157
+
158
+ **Top-level params:**
159
+ - `operations` (required)
160
+ - `dry_run` (default `true`)
161
+ - `partial_failure` (default `true`)
162
+
163
+ **Notes:**
164
+ - Pinterest v5 uses bulk-array bodies: creates are `POST /ad_accounts/{id}/{campaigns|ad_groups|ads}` with an array of creation objects; updates and status changes are `PATCH` with an array of `{ id, ...changes }`
165
+ - **There is no delete.** `archive` sets `status: ARCHIVED`, which is terminal — archived entities cannot be reactivated
166
+ - Pinterest has no server-side validate-only mode, so `dry_run` performs local validation and returns a preview of the exact requests (method, path, body) without calling the API
167
+ - Creates default to `status: PAUSED` (pass an explicit `status` to override)
168
+ - Required create fields — campaign: `name`, `objective_type`; ad_group: `name`, `campaign_id`, `billable_event`; ad: `ad_group_id`, `pin_id`, `creative_type`
169
+ - Money fields (spend caps, budgets, bids) are in micro-currency: `25000000` = 25.00 in the account currency
170
+
171
+ ## Read-Only Mode
172
+
173
+ Set `PINTEREST_ADS_READ_ONLY=true` to remove the `mutate` tool from the tool list
174
+ and block mutate calls entirely. Recommended for reporting-only setups.
175
+
176
+ ## Behavior Notes
177
+
178
+ - Auth is sent via `Authorization: Bearer <token>` request header
179
+ - Retries once on HTTP `429`, using `Retry-After` when available (fallback `60s`)
180
+ - Requests abort on timeout (default `30000ms`, configurable via `PINTEREST_ADS_REQUEST_TIMEOUT_MS`)
181
+ - Errors surface as `Pinterest Ads API request failed (<status>): <message> [code <n>]`
182
+ - Pagination uses `page_size` (max 250) + `bookmark` cursors; responses are `{ items, bookmark }`
183
+
184
+ ## Development Commands
185
+
186
+ ```bash
187
+ cd pinterest-ads
188
+ npm test
189
+ node server/index.js
190
+ ```
191
+
192
+ ## License
193
+
194
+ MIT
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "@channel47/pinterest-ads-mcp",
3
+ "version": "0.1.0",
4
+ "description": "Pinterest Ads MCP Server - Query and mutate Pinterest advertising data via REST API v5",
5
+ "main": "server/index.js",
6
+ "bin": {
7
+ "pinterest-ads-mcp": "server/index.js"
8
+ },
9
+ "type": "module",
10
+ "files": [
11
+ "server/**/*.js",
12
+ "server/**/*.md",
13
+ "README.md",
14
+ "LICENSE"
15
+ ],
16
+ "scripts": {
17
+ "start": "node server/index.js",
18
+ "test": "node --test *.test.js test/*.test.js",
19
+ "prepublishOnly": "npm test"
20
+ },
21
+ "keywords": [
22
+ "mcp",
23
+ "model-context-protocol",
24
+ "pinterest-ads",
25
+ "pinterest",
26
+ "analytics",
27
+ "advertising",
28
+ "claude"
29
+ ],
30
+ "author": "channel47",
31
+ "license": "MIT",
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "git+https://github.com/channel47/mcps.git",
35
+ "directory": "pinterest-ads"
36
+ },
37
+ "bugs": {
38
+ "url": "https://github.com/channel47/mcps/issues"
39
+ },
40
+ "homepage": "https://github.com/channel47/mcps/tree/main/pinterest-ads#readme",
41
+ "dependencies": {
42
+ "@modelcontextprotocol/sdk": "^1.29.0"
43
+ },
44
+ "engines": {
45
+ "node": ">=18.0.0"
46
+ }
47
+ }
package/server/auth.js ADDED
@@ -0,0 +1,141 @@
1
+ const TOKEN_URL = 'https://api.pinterest.com/v5/oauth/token';
2
+ const REFRESH_ENV_VARS = [
3
+ 'PINTEREST_ADS_CLIENT_ID',
4
+ 'PINTEREST_ADS_CLIENT_SECRET',
5
+ 'PINTEREST_ADS_REFRESH_TOKEN'
6
+ ];
7
+ const REFRESH_MARGIN_MS = 5 * 60 * 1000;
8
+
9
+ let cachedAccessToken = null;
10
+ let cachedTokenExpiresAt = null;
11
+
12
+ function hasStaticToken() {
13
+ return Boolean(process.env.PINTEREST_ADS_ACCESS_TOKEN);
14
+ }
15
+
16
+ function missingRefreshVars() {
17
+ return REFRESH_ENV_VARS.filter((name) => !process.env[name]);
18
+ }
19
+
20
+ function hasRefreshCredentials() {
21
+ return missingRefreshVars().length === 0;
22
+ }
23
+
24
+ /**
25
+ * Validate required environment variables for Pinterest Ads auth.
26
+ * Valid when PINTEREST_ADS_ACCESS_TOKEN is set, or when all of
27
+ * PINTEREST_ADS_CLIENT_ID + PINTEREST_ADS_CLIENT_SECRET + PINTEREST_ADS_REFRESH_TOKEN are set.
28
+ * @returns {{ valid: boolean, missing: string[] }}
29
+ */
30
+ export function validateEnvironment() {
31
+ if (hasStaticToken() || hasRefreshCredentials()) {
32
+ return { valid: true, missing: [] };
33
+ }
34
+
35
+ const partialRefresh = missingRefreshVars().length < REFRESH_ENV_VARS.length;
36
+ if (partialRefresh) {
37
+ return { valid: false, missing: missingRefreshVars() };
38
+ }
39
+
40
+ return { valid: false, missing: ['PINTEREST_ADS_ACCESS_TOKEN'] };
41
+ }
42
+
43
+ function extractRefreshErrorMessage(payload) {
44
+ if (payload && typeof payload === 'object') {
45
+ return payload.message || payload.error_description || payload.error || 'Unknown OAuth error';
46
+ }
47
+
48
+ if (typeof payload === 'string' && payload) {
49
+ return payload;
50
+ }
51
+
52
+ return 'Unknown OAuth error';
53
+ }
54
+
55
+ async function refreshAccessToken(fetchImpl) {
56
+ const clientId = process.env.PINTEREST_ADS_CLIENT_ID;
57
+ const clientSecret = process.env.PINTEREST_ADS_CLIENT_SECRET;
58
+ const refreshToken = process.env.PINTEREST_ADS_REFRESH_TOKEN;
59
+ const basicAuth = Buffer.from(`${clientId}:${clientSecret}`).toString('base64');
60
+
61
+ const body = new URLSearchParams({
62
+ grant_type: 'refresh_token',
63
+ refresh_token: refreshToken
64
+ });
65
+
66
+ const response = await fetchImpl(TOKEN_URL, {
67
+ method: 'POST',
68
+ headers: {
69
+ Authorization: `Basic ${basicAuth}`,
70
+ 'Content-Type': 'application/x-www-form-urlencoded'
71
+ },
72
+ body: body.toString()
73
+ });
74
+
75
+ let payload;
76
+ try {
77
+ payload = await response.json();
78
+ } catch {
79
+ payload = null;
80
+ }
81
+
82
+ if (!response.ok || !payload?.access_token) {
83
+ const message = extractRefreshErrorMessage(payload);
84
+ throw new Error(`Pinterest Ads token refresh failed (${response.status}): ${message}`);
85
+ }
86
+
87
+ if (payload.refresh_token && payload.refresh_token !== refreshToken) {
88
+ console.error(
89
+ 'Warning: Pinterest rotated the refresh token (continuous refresh). '
90
+ + 'Update PINTEREST_ADS_REFRESH_TOKEN with the new value or future refreshes may fail.'
91
+ );
92
+ }
93
+
94
+ const expiresInMs = Number(payload.expires_in || 0) * 1000;
95
+ cachedAccessToken = String(payload.access_token);
96
+ cachedTokenExpiresAt = expiresInMs > 0 ? Date.now() + expiresInMs - REFRESH_MARGIN_MS : null;
97
+
98
+ return cachedAccessToken;
99
+ }
100
+
101
+ /**
102
+ * Return a Pinterest Ads access token with in-memory caching.
103
+ * Prefers the refresh-token flow when full OAuth credentials are configured;
104
+ * otherwise falls back to the static PINTEREST_ADS_ACCESS_TOKEN value.
105
+ * @param {{ fetchImpl?: typeof fetch }} [dependencies]
106
+ * @returns {Promise<string>}
107
+ */
108
+ export async function getAccessToken(dependencies = {}) {
109
+ const fetchImpl = dependencies.fetchImpl || fetch;
110
+
111
+ const { valid, missing } = validateEnvironment();
112
+ if (!valid) {
113
+ throw new Error(`Missing required environment variables: ${missing.join(', ')}`);
114
+ }
115
+
116
+ if (hasRefreshCredentials()) {
117
+ const cacheIsFresh = cachedAccessToken
118
+ && (cachedTokenExpiresAt === null || Date.now() < cachedTokenExpiresAt);
119
+ if (cacheIsFresh) {
120
+ return cachedAccessToken;
121
+ }
122
+
123
+ return refreshAccessToken(fetchImpl);
124
+ }
125
+
126
+ if (cachedAccessToken) {
127
+ return cachedAccessToken;
128
+ }
129
+
130
+ cachedAccessToken = String(process.env.PINTEREST_ADS_ACCESS_TOKEN);
131
+ cachedTokenExpiresAt = null;
132
+ return cachedAccessToken;
133
+ }
134
+
135
+ /**
136
+ * Clear the in-memory token cache (used by tests and local dev flows).
137
+ */
138
+ export function clearAuthCache() {
139
+ cachedAccessToken = null;
140
+ cachedTokenExpiresAt = null;
141
+ }
package/server/http.js ADDED
@@ -0,0 +1,198 @@
1
+ import { getAccessToken } from './auth.js';
2
+ import { invalidParamsError } from './utils/errors.js';
3
+
4
+ /**
5
+ * Base URL for all Pinterest REST API v5 requests used by this server.
6
+ */
7
+ export const PINTEREST_BASE_URL = 'https://api.pinterest.com/v5';
8
+ const DEFAULT_TIMEOUT_MS = 30_000;
9
+ const DEFAULT_RATE_LIMIT_SLEEP_MS = 60_000;
10
+
11
+ function sleep(ms) {
12
+ return new Promise((resolve) => {
13
+ setTimeout(resolve, ms);
14
+ });
15
+ }
16
+
17
+ function normalizePath(path) {
18
+ if (!path) {
19
+ throw new Error('API path is required');
20
+ }
21
+
22
+ return path.startsWith('/') ? path : `/${path}`;
23
+ }
24
+
25
+ function serializeParamValue(value) {
26
+ if (Array.isArray(value)) {
27
+ return value.map((entry) => String(entry).trim()).filter(Boolean).join(',');
28
+ }
29
+
30
+ return String(value);
31
+ }
32
+
33
+ function buildUrl(path, params = {}) {
34
+ const url = new URL(`${PINTEREST_BASE_URL}${normalizePath(path)}`);
35
+
36
+ for (const [key, value] of Object.entries(params)) {
37
+ if (value === undefined || value === null || value === '') {
38
+ continue;
39
+ }
40
+
41
+ const serialized = serializeParamValue(value);
42
+ if (serialized === '') {
43
+ continue;
44
+ }
45
+ url.searchParams.set(key, serialized);
46
+ }
47
+
48
+ return url;
49
+ }
50
+
51
+ async function parseResponse(response) {
52
+ const contentType = response.headers.get('content-type') || '';
53
+
54
+ if (contentType.includes('application/json')) {
55
+ return response.json();
56
+ }
57
+
58
+ const text = await response.text();
59
+ try {
60
+ return JSON.parse(text);
61
+ } catch {
62
+ return text;
63
+ }
64
+ }
65
+
66
+ function extractErrorMessage(payload) {
67
+ if (payload?.message) {
68
+ return payload.message;
69
+ }
70
+
71
+ if (typeof payload === 'string' && payload) {
72
+ return payload;
73
+ }
74
+
75
+ return 'Unknown Pinterest Ads API error';
76
+ }
77
+
78
+ function extractErrorCode(payload) {
79
+ return Number(payload?.code || 0);
80
+ }
81
+
82
+ function resolveTimeoutMs(timeoutMs) {
83
+ const candidate = timeoutMs ?? process.env.PINTEREST_ADS_REQUEST_TIMEOUT_MS ?? DEFAULT_TIMEOUT_MS;
84
+ const parsed = Number(candidate);
85
+ if (!Number.isFinite(parsed) || parsed <= 0) {
86
+ return DEFAULT_TIMEOUT_MS;
87
+ }
88
+ return Math.floor(parsed);
89
+ }
90
+
91
+ function getRateLimitSleepMs(response) {
92
+ const retryAfter = response?.headers?.get('retry-after');
93
+ if (!retryAfter) {
94
+ return DEFAULT_RATE_LIMIT_SLEEP_MS;
95
+ }
96
+
97
+ const seconds = Number(retryAfter);
98
+ if (Number.isFinite(seconds) && seconds > 0) {
99
+ return Math.floor(seconds * 1000);
100
+ }
101
+
102
+ const retryAtMs = Date.parse(retryAfter);
103
+ if (Number.isFinite(retryAtMs)) {
104
+ const delta = retryAtMs - Date.now();
105
+ if (delta > 0) {
106
+ return Math.floor(delta);
107
+ }
108
+ }
109
+
110
+ return DEFAULT_RATE_LIMIT_SLEEP_MS;
111
+ }
112
+
113
+ /**
114
+ * Execute an authenticated Pinterest REST API v5 request with timeout and retry handling.
115
+ * Array query parameter values are serialized as comma-separated strings per Pinterest conventions.
116
+ * @param {string} path
117
+ * @param {Record<string, unknown>} [params]
118
+ * @param {{
119
+ * method?: string,
120
+ * body?: unknown,
121
+ * timeoutMs?: number,
122
+ * retryThrottled?: boolean,
123
+ * sleep?: (ms: number) => Promise<void>
124
+ * }} [options]
125
+ * @returns {Promise<unknown>}
126
+ */
127
+ export async function pinterestRequest(
128
+ path,
129
+ params = {},
130
+ {
131
+ method = 'GET',
132
+ body,
133
+ timeoutMs,
134
+ retryThrottled = true,
135
+ sleep: sleepFn = sleep
136
+ } = {}
137
+ ) {
138
+ const token = await getAccessToken();
139
+ const url = buildUrl(path, params);
140
+ const resolvedTimeoutMs = resolveTimeoutMs(timeoutMs);
141
+ const controller = new AbortController();
142
+ const timeoutId = setTimeout(() => {
143
+ controller.abort();
144
+ }, resolvedTimeoutMs);
145
+
146
+ const requestOptions = {
147
+ method,
148
+ headers: {
149
+ 'Content-Type': 'application/json',
150
+ Authorization: `Bearer ${token}`
151
+ },
152
+ signal: controller.signal
153
+ };
154
+
155
+ if (body !== undefined && body !== null) {
156
+ requestOptions.body = typeof body === 'string' ? body : JSON.stringify(body);
157
+ }
158
+
159
+ let response;
160
+ try {
161
+ response = await fetch(url, requestOptions);
162
+ } catch (error) {
163
+ if (error?.name === 'AbortError') {
164
+ throw new Error(`Pinterest Ads API request timed out after ${resolvedTimeoutMs}ms`);
165
+ }
166
+ throw error;
167
+ } finally {
168
+ clearTimeout(timeoutId);
169
+ }
170
+
171
+ const payload = await parseResponse(response);
172
+
173
+ if (response.ok) {
174
+ return payload;
175
+ }
176
+
177
+ if (response.status === 401) {
178
+ const message = extractErrorMessage(payload);
179
+ throw invalidParamsError(`Pinterest Ads API request failed (${response.status}): ${message}`);
180
+ }
181
+
182
+ if (response.status === 429 && retryThrottled) {
183
+ await sleepFn(getRateLimitSleepMs(response));
184
+
185
+ return pinterestRequest(path, params, {
186
+ method,
187
+ body,
188
+ timeoutMs: resolvedTimeoutMs,
189
+ retryThrottled: false,
190
+ sleep: sleepFn
191
+ });
192
+ }
193
+
194
+ const message = extractErrorMessage(payload);
195
+ const code = extractErrorCode(payload);
196
+ const codeSuffix = code ? ` [code ${code}]` : '';
197
+ throw new Error(`Pinterest Ads API request failed (${response.status}): ${message}${codeSuffix}`);
198
+ }