upload-post 2.8.1 → 2.9.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.
Files changed (4) hide show
  1. package/README.md +102 -2
  2. package/index.d.ts +113 -4
  3. package/index.js +42 -2
  4. package/package.json +5 -2
package/README.md CHANGED
@@ -39,6 +39,38 @@ console.log(response);
39
39
  - ✅ **Analytics** - Get engagement metrics
40
40
  - ✅ **Full TypeScript Support**
41
41
 
42
+ ## Using this from an AI agent? Use the MCP server instead
43
+
44
+ If you are wiring Upload-Post into ChatGPT, Claude, Cursor, Claude Code or any
45
+ other MCP-compatible agent, you do not need to write a client on top of this
46
+ SDK. The official **[Model Context Protocol server](https://github.com/Upload-Post/upload-post-mcp)**
47
+ already wraps the whole API as 50 tools the agent can call directly.
48
+
49
+ ```jsonc
50
+ // Hosted (OAuth or API key) — nothing to install
51
+ {
52
+ "mcpServers": {
53
+ "upload-post": { "url": "https://mcp.upload-post.com/mcp" }
54
+ }
55
+ }
56
+ ```
57
+
58
+ ```jsonc
59
+ // Local stdio, built on this SDK
60
+ {
61
+ "mcpServers": {
62
+ "upload-post": {
63
+ "command": "npx",
64
+ "args": ["-y", "@upload-post/mcp"],
65
+ "env": { "UPLOAD_POST_API_KEY": "YOUR_API_KEY" }
66
+ }
67
+ }
68
+ }
69
+ ```
70
+
71
+ See the [MCP integration guide](https://docs.upload-post.com/guides/mcp-server-integration).
72
+ Keep using this SDK when you are writing your own application code.
73
+
42
74
  ## API Reference
43
75
 
44
76
  ### Upload Video
@@ -170,9 +202,16 @@ const jwt = await client.generateJwt('my-profile', {
170
202
  redirectUrl: 'https://yourapp.com/callback',
171
203
  platforms: ['tiktok', 'instagram'],
172
204
  // Optional: force the connection page language for this profile.
173
- // Supported: 'en' | 'es' | 'de' | 'fr' | 'pt'. When omitted, the page
174
- // auto-detects the visitor's browser language and falls back to English.
205
+ // Supported: 'en' | 'es' | 'de' | 'fr' | 'pt' | 'pl' | 'tr'. When omitted, the
206
+ // page auto-detects the visitor's browser language and falls back to English.
175
207
  language: 'es',
208
+ // Optional: override individual connection-page strings. Flat object of i18n
209
+ // dot-path keys to strings. Max 100 entries, keys ^[a-zA-Z0-9_.]+$, values
210
+ // up to 300 chars. Echoed back in the `profile` object of validateJwt.
211
+ uiLabels: {
212
+ 'connect.title': 'Link your accounts',
213
+ 'connect.subtitle': 'Publish everywhere from one place',
214
+ },
176
215
  });
177
216
  ```
178
217
 
@@ -183,6 +222,34 @@ const analytics = await client.getAnalytics('my-profile', {
183
222
  platforms: ['instagram', 'tiktok'],
184
223
  });
185
224
  console.log(analytics);
225
+
226
+ // Instagram returns two audience breakdowns with the same shape
227
+ // ({ age, gender, country, city }):
228
+ console.log(analytics.analytics.instagram.follower_demographics);
229
+ console.log(analytics.analytics.instagram.engaged_audience_demographics);
230
+ ```
231
+
232
+ ### Cached Post Analytics
233
+
234
+ Replays per-post metrics already fetched, instead of calling the platforms again. Only contains posts previously fetched through a live per-post endpoint; there is no background refresh, so captured_at is the last time that post was read live. Not subject to the live
235
+ calls, so it is not subject to the live post-analytics rate limit
236
+ (100 requests / 5 minutes). Use it to page through a profile's post history.
237
+
238
+ ```javascript
239
+ let cursor;
240
+ do {
241
+ const page = await client.getCachedPostAnalytics('my-profile', {
242
+ platform: 'youtube', // optional: instagram, tiktok, youtube, facebook, linkedin, threads, pinterest, reddit
243
+ limit: 50, // default 50, max 200
244
+ since: '2026-06-01', // defaults to 30 days ago
245
+ until: '2026-07-01', // defaults to today
246
+ cursor,
247
+ });
248
+ for (const post of page.posts) {
249
+ console.log(post.platform, post.post_id, post.metrics);
250
+ }
251
+ cursor = page.next_cursor;
252
+ } while (cursor);
186
253
  ```
187
254
 
188
255
  ### Get Media
@@ -201,6 +268,22 @@ await client.getMedia('linkedin', 'my-profile', { pageUrn: 'me' });
201
268
  await client.getMedia('linkedin', 'my-profile', { pageUrn: '12345' });
202
269
  ```
203
270
 
271
+ The response carries a `pagination` object — `{ limit, next_cursor, has_more }`,
272
+ with `next_cursor: null` and `has_more: false` on the last page:
273
+
274
+ ```javascript
275
+ let cursor;
276
+ do {
277
+ const page = await client.getMedia('instagram', 'my-profile', { limit: 50, cursor });
278
+ console.log(page.media.length);
279
+ cursor = page.pagination.next_cursor;
280
+ } while (cursor);
281
+ ```
282
+
283
+ `limit` defaults to 25 and is clamped to 1-100, with per-platform caps of 20 for
284
+ TikTok and 50 for YouTube. **LinkedIn, Discord and Telegram do not support
285
+ cursors** — they accept `limit` only, and passing a `cursor` returns HTTP 400.
286
+
204
287
  ### Helper Methods
205
288
 
206
289
  ```javascript
@@ -350,6 +433,22 @@ await client.uploadText({
350
433
 
351
434
  Also available: `gbpTopicType: 'EVENT'` with `gbpEventTitle` / `gbpEventStartDate` / `gbpEventStartTime` / `gbpEventEndDate` / `gbpEventEndTime`, a call-to-action via `gbpCtaType` + `gbpCtaUrl`, and `gbpMediaUrl` / `gbpMediaFormat`.
352
435
 
436
+ ### Gallery photos
437
+
438
+ Set `gbpPostType` to publish straight into the location's photo gallery instead of creating a Local Post:
439
+
440
+ ```javascript
441
+ await client.uploadPhotos(['storefront.jpg'], {
442
+ user: 'myprofile',
443
+ platforms: ['google_business'],
444
+ gbpLocationId: locations[0].name,
445
+ gbpPostType: 'GALLERY', // MEDIA | PHOTO | GALLERY
446
+ gbpMediaCategory: 'EXTERIOR', // defaults to ADDITIONAL
447
+ });
448
+ ```
449
+
450
+ Omitting `gbpPostType` (or sending any other value) keeps the existing Local Post behaviour. `gbpMediaCategory` accepts `COVER`, `PROFILE`, `LOGO`, `EXTERIOR`, `INTERIOR`, `PRODUCT`, `AT_WORK`, `FOOD_AND_DRINK`, `MENU`, `COMMON_AREA`, `ROOMS`, `TEAMS` and `ADDITIONAL`.
451
+
353
452
  ## Retrying an upload safely
354
453
 
355
454
  An upload that times out may still have been accepted by the API. Retrying it without an idempotency key publishes the post a second time.
@@ -407,6 +506,7 @@ try {
407
506
  - [Upload-Post Website](https://www.upload-post.com)
408
507
  - [API Documentation](https://docs.upload-post.com)
409
508
  - [Dashboard](https://app.upload-post.com)
509
+ - [MCP Server](https://www.upload-post.com/mcp) — connect ChatGPT, Claude, Cursor and any AI agent ([source](https://github.com/Upload-Post/upload-post-mcp), [npm](https://www.npmjs.com/package/@upload-post/mcp))
410
510
 
411
511
  ## License
412
512
 
package/index.d.ts CHANGED
@@ -358,9 +358,28 @@ declare module 'upload-post' {
358
358
 
359
359
  // ==================== Combined Upload Options ====================
360
360
 
361
+ export type GoogleBusinessMediaCategory =
362
+ | 'COVER'
363
+ | 'PROFILE'
364
+ | 'LOGO'
365
+ | 'EXTERIOR'
366
+ | 'INTERIOR'
367
+ | 'PRODUCT'
368
+ | 'AT_WORK'
369
+ | 'FOOD_AND_DRINK'
370
+ | 'MENU'
371
+ | 'COMMON_AREA'
372
+ | 'ROOMS'
373
+ | 'TEAMS'
374
+ | 'ADDITIONAL';
375
+
361
376
  export interface GoogleBusinessOptions {
362
377
  /** Google Business location id, e.g. "accounts/123/locations/456". Required when the account has more than one location; the API auto-selects only for single-location accounts. List them with getGoogleBusinessLocations(). */
363
378
  gbpLocationId?: string;
379
+ /** Publish to the location's gallery instead of creating a Local Post. Omitting it (or sending any other value) keeps the Local Post behaviour. */
380
+ gbpPostType?: 'MEDIA' | 'PHOTO' | 'GALLERY';
381
+ /** Gallery category for the uploaded photo. Only used with gbpPostType. Defaults to ADDITIONAL. */
382
+ gbpMediaCategory?: GoogleBusinessMediaCategory;
364
383
  /** Post type. Defaults to STANDARD. */
365
384
  gbpTopicType?: 'STANDARD' | 'EVENT' | 'OFFER';
366
385
  /** Media URL attached to the post */
@@ -496,9 +515,31 @@ declare module 'upload-post' {
496
515
  [key: string]: any;
497
516
  }
498
517
 
518
+ /**
519
+ * Instagram audience breakdown. Each key maps a bucket (age range, gender,
520
+ * country code, city name) to a follower/engager count.
521
+ */
522
+ export interface DemographicsBreakdown {
523
+ age?: Record<string, number>;
524
+ gender?: Record<string, number>;
525
+ country?: Record<string, number>;
526
+ city?: Record<string, number>;
527
+ }
528
+
529
+ export interface InstagramAnalytics {
530
+ /** Breakdown of the account's followers. */
531
+ follower_demographics?: DemographicsBreakdown;
532
+ /** Breakdown of the accounts that engaged with the profile's content. Same shape as follower_demographics. */
533
+ engaged_audience_demographics?: DemographicsBreakdown;
534
+ [key: string]: any;
535
+ }
536
+
499
537
  export interface AnalyticsResponse {
500
538
  success: boolean;
501
- analytics?: any;
539
+ analytics?: {
540
+ instagram?: InstagramAnalytics;
541
+ [platform: string]: any;
542
+ };
502
543
  [key: string]: any;
503
544
  }
504
545
 
@@ -654,6 +695,48 @@ declare module 'upload-post' {
654
695
  }>;
655
696
  }>;
656
697
 
698
+ /**
699
+ * Replay per-post metrics already fetched, instead of querying
700
+ * the platforms live. Not subject to the live post-analytics rate limit
701
+ * (100 requests / 5 minutes), so it is the right call for paging through a
702
+ * profile's whole post history.
703
+ *
704
+ * @param user - Profile username
705
+ * @param options - Query options
706
+ */
707
+ getCachedPostAnalytics(user: string, options?: {
708
+ platform?: 'instagram' | 'tiktok' | 'youtube' | 'facebook' | 'linkedin' | 'threads' | 'pinterest' | 'reddit';
709
+ /** Posts per page. Default 50, max 200. */
710
+ limit?: number;
711
+ /** Opaque cursor from a previous response's next_cursor. */
712
+ cursor?: string;
713
+ /** Start date, YYYY-MM-DD. Defaults to 30 days ago. */
714
+ since?: string;
715
+ /** End date, YYYY-MM-DD. Defaults to today. */
716
+ until?: string;
717
+ }): Promise<{
718
+ success: boolean;
719
+ profile_username: string;
720
+ platform: string | null;
721
+ since: string;
722
+ until: string;
723
+ source: 'snapshot_cache';
724
+ posts: Array<{
725
+ post_id: string;
726
+ platform: string;
727
+ profile_username: string;
728
+ date: string;
729
+ captured_at: string;
730
+ metrics: Record<string, number>;
731
+ post_url?: string | null;
732
+ media_type?: string | null;
733
+ upload_timestamp?: string | null;
734
+ }>;
735
+ limit: number;
736
+ next_cursor: string | null;
737
+ has_more: boolean;
738
+ }>;
739
+
657
740
  /**
658
741
  * Get available metrics configuration for all supported platforms
659
742
  */
@@ -667,11 +750,13 @@ declare module 'upload-post' {
667
750
  * Get recent media from a connected social account.
668
751
  *
669
752
  * @param options.pageUrn - LinkedIn only. Numeric org ID, full org URN, or "me" to force the personal profile.
753
+ * @param options.limit - Items per page. Default 25, clamped to 1-100. Per-platform caps: TikTok 20, YouTube 50, everything else 100.
754
+ * @param options.cursor - Opaque cursor from a previous response's `pagination.next_cursor`. LinkedIn, Discord and Telegram do not support cursors: passing one there returns HTTP 400.
670
755
  */
671
756
  getMedia(
672
757
  platform: 'instagram' | 'tiktok' | 'youtube' | 'linkedin' | 'facebook' | 'x' | 'threads' | 'pinterest' | 'bluesky' | 'reddit' | string,
673
758
  user: string,
674
- options?: { pageUrn?: string }
759
+ options?: { pageUrn?: string; limit?: number; cursor?: string }
675
760
  ): Promise<{
676
761
  success: boolean;
677
762
  media: Array<{
@@ -683,6 +768,12 @@ declare module 'upload-post' {
683
768
  timestamp: string | null;
684
769
  thumbnail_url: string | null;
685
770
  }>;
771
+ pagination?: {
772
+ limit: number;
773
+ /** null on the last page. */
774
+ next_cursor: string | null;
775
+ has_more: boolean;
776
+ };
686
777
  }>;
687
778
 
688
779
  // Scheduled Posts
@@ -736,14 +827,32 @@ declare module 'upload-post' {
736
827
  readonlyCalendar?: boolean;
737
828
  connectTitle?: string;
738
829
  connectDescription?: string;
739
- language?: 'en' | 'es' | 'de' | 'fr' | 'pt';
830
+ language?: 'en' | 'es' | 'de' | 'fr' | 'pt' | 'pl' | 'tr';
831
+ /**
832
+ * Flat map of i18n dot-path keys to replacement strings, e.g.
833
+ * `{ 'connect.title': 'Link your accounts' }`. Max 100 entries; keys must
834
+ * match `^[a-zA-Z0-9_.]+$` and values are strings of up to 300 characters.
835
+ * Returned back in the `profile` object of validateJwt.
836
+ */
837
+ uiLabels?: Record<string, string>;
740
838
  }): Promise<JwtResponse>;
741
839
 
742
840
  /**
743
841
  * Validate a JWT token
744
842
  * @param jwt - JWT token to validate
745
843
  */
746
- validateJwt(jwt: string): Promise<{ success: boolean; valid?: boolean }>;
844
+ validateJwt(jwt: string): Promise<{
845
+ success: boolean;
846
+ valid?: boolean;
847
+ profile?: {
848
+ username?: string;
849
+ language?: 'en' | 'es' | 'de' | 'fr' | 'pt' | 'pl' | 'tr';
850
+ /** The ui_labels sent to generateJwt, echoed back for the connection page. */
851
+ ui_labels?: Record<string, string>;
852
+ [key: string]: any;
853
+ };
854
+ [key: string]: any;
855
+ }>;
747
856
 
748
857
  /**
749
858
  * Get user preferences (including calendar settings)
package/index.js CHANGED
@@ -348,10 +348,16 @@ export class UploadPost {
348
348
  *
349
349
  * gbpLocationId is required for accounts with more than one location; the API
350
350
  * only auto-selects when exactly one location exists.
351
+ *
352
+ * gbpPostType switches the call from the default Local Post to a gallery
353
+ * photo upload (MEDIA / PHOTO / GALLERY). Any other value, or omitting it,
354
+ * keeps the Local Post behaviour.
351
355
  * @private
352
356
  */
353
357
  _addGoogleBusinessParams(form, options) {
354
358
  if (options.gbpLocationId) form.append('gbp_location_id', options.gbpLocationId);
359
+ if (options.gbpPostType) form.append('gbp_post_type', String(options.gbpPostType).toUpperCase());
360
+ if (options.gbpMediaCategory) form.append('gbp_media_category', String(options.gbpMediaCategory).toUpperCase());
355
361
  if (options.gbpTopicType) form.append('gbp_topic_type', String(options.gbpTopicType).toUpperCase());
356
362
  if (options.gbpMediaUrl) form.append('gbp_media_url', options.gbpMediaUrl);
357
363
  if (options.gbpMediaFormat) form.append('gbp_media_format', String(options.gbpMediaFormat).toUpperCase());
@@ -814,6 +820,33 @@ export class UploadPost {
814
820
  });
815
821
  }
816
822
 
823
+ /**
824
+ * Replay per-post metrics already fetched, instead of querying the
825
+ * platforms live.
826
+ *
827
+ * Because it never calls the platform APIs it is not subject to the live
828
+ * post-analytics rate limit (100 requests / 5 minutes), which makes it the
829
+ * right method for paging through a profile's whole post history.
830
+ *
831
+ * @param {string} user - Profile username
832
+ * @param {Object} [options] - Query options
833
+ * @param {string} [options.platform] - Filter by platform (instagram, tiktok, youtube, facebook, linkedin, threads, pinterest, reddit)
834
+ * @param {number} [options.limit=50] - Posts per page (max 200)
835
+ * @param {string} [options.cursor] - Opaque cursor from a previous response's next_cursor
836
+ * @param {string} [options.since] - Start date in YYYY-MM-DD format (defaults to 30 days ago)
837
+ * @param {string} [options.until] - End date in YYYY-MM-DD format (defaults to today)
838
+ * @returns {Promise<Object>} Cached post metrics with next_cursor and has_more
839
+ */
840
+ async getCachedPostAnalytics(user, options = {}) {
841
+ const params = { user };
842
+ if (options.platform) params.platform = options.platform;
843
+ if (options.limit !== undefined) params.limit = options.limit;
844
+ if (options.cursor) params.cursor = options.cursor;
845
+ if (options.since) params.since = options.since;
846
+ if (options.until) params.until = options.until;
847
+ return this._request('/uploadposts/post-analytics/cached', 'GET', params);
848
+ }
849
+
817
850
  /**
818
851
  * Get available metrics configuration for all supported platforms
819
852
  *
@@ -840,11 +873,16 @@ export class UploadPost {
840
873
  * @param {string} user - Profile username
841
874
  * @param {Object} [options]
842
875
  * @param {string} [options.pageUrn] - LinkedIn only. Numeric org ID, full URN, or "me" to force the personal profile.
843
- * @returns {Promise<Object>}
876
+ * @param {number} [options.limit=25] - Items per page, clamped to 1-100. Per-platform caps: TikTok 20, YouTube 50, everything else 100.
877
+ * @param {string} [options.cursor] - Opaque cursor from a previous response's `pagination.next_cursor`.
878
+ * LinkedIn, Discord and Telegram do not support cursors: passing one there returns HTTP 400.
879
+ * @returns {Promise<Object>} `{ success, media, pagination: { limit, next_cursor, has_more } }`
844
880
  */
845
881
  async getMedia(platform, user, options = {}) {
846
882
  const params = { platform, user };
847
883
  if (options.pageUrn) params.page_urn = options.pageUrn;
884
+ if (options.limit !== undefined) params.limit = options.limit;
885
+ if (options.cursor) params.cursor = options.cursor;
848
886
  return this._request('/uploadposts/media', 'GET', params);
849
887
  }
850
888
 
@@ -930,7 +968,8 @@ export class UploadPost {
930
968
  * @param {boolean} [options.readonlyCalendar] - Show only a read-only calendar (no editing, no account connection)
931
969
  * @param {string} [options.connectTitle] - Custom title for the connection page
932
970
  * @param {string} [options.connectDescription] - Custom description for the connection page
933
- * @param {('en'|'es'|'de'|'fr'|'pt')} [options.language] - Force the connection page language for this profile. When omitted, the page auto-detects the visitor's browser language and falls back to English.
971
+ * @param {('en'|'es'|'de'|'fr'|'pt'|'pl'|'tr')} [options.language] - Force the connection page language for this profile. When omitted, the page auto-detects the visitor's browser language and falls back to English.
972
+ * @param {Object.<string, string>} [options.uiLabels] - Flat map of i18n dot-path keys to replacement strings, e.g. `{ 'connect.title': 'Link your accounts' }`. Max 100 entries; keys must match `^[a-zA-Z0-9_.]+$` and values are strings of up to 300 characters. Returned back in the `profile` object of validateJwt.
934
973
  * @returns {Promise<Object>} JWT and connection URL
935
974
  */
936
975
  async generateJwt(username, options = {}) {
@@ -944,6 +983,7 @@ export class UploadPost {
944
983
  if (options.connectTitle) body.connect_title = options.connectTitle;
945
984
  if (options.connectDescription) body.connect_description = options.connectDescription;
946
985
  if (options.language) body.language = options.language;
986
+ if (options.uiLabels) body.ui_labels = options.uiLabels;
947
987
  return this._request('/uploadposts/users/generate-jwt', 'POST', body);
948
988
  }
949
989
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "upload-post",
3
- "version": "2.8.1",
3
+ "version": "2.9.0",
4
4
  "description": "Official client library for Upload-Post API - Cross-platform social media upload for TikTok, Instagram, YouTube, LinkedIn, Facebook, Pinterest, Threads, Reddit, Bluesky, and X (Twitter)",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -26,7 +26,10 @@
26
26
  "x",
27
27
  "api-client",
28
28
  "social-media-api",
29
- "upload-post"
29
+ "upload-post",
30
+ "mcp",
31
+ "model-context-protocol",
32
+ "ai-agent"
30
33
  ],
31
34
  "repository": {
32
35
  "type": "git",