Profiles API Reference
The Profiles API allows you to retrieve and manage connected social media profiles. Profiles represent authenticated connections to social media platforms.
Endpoints
Section titled “Endpoints”| Method | Endpoint | Description |
|---|---|---|
GET | /api/profiles | List all profiles |
GET | /api/profiles/:id | Get a single profile (with latest stats) |
GET | /api/profiles/:id/placements | List placements for a profile |
PATCH | /api/profiles/:id/assign_placement_to_group | Assign a placement to another profile group |
PATCH | /api/profiles/:id/assign_to_group | Move a profile to another profile group |
GET | /api/profiles/:id/stats | Get profile stats timeseries |
GET | /api/profiles/:id/follower_history | Daily follower series for a date range |
GET | /api/profiles/:id/daily_stats | Per-day account metrics for a date range |
GET | /api/profiles/:id/participants/:user_id | Follow/verified status of an Instagram user (Instagram only) |
GET | /api/profiles/:id/resolve_mention | Resolve a LinkedIn company page to its mention URN (LinkedIn only) |
POST | /api/profiles/:id/backfill_posts | Backfill older posts from the platform |
GET | /api/profiles/:id/post_syncs | List post sync runs |
GET | /api/profiles/:id/post_syncs/:post_sync_id | Get one post sync run |
DELETE | /api/profiles/:id | Delete/disconnect a profile |
Profile object
Section titled “Profile object”A profile represents a connected social media account.
| Field | Type | Description |
|---|---|---|
id | string | Unique profile identifier (id) |
name | string | Display name of the connected account |
username | string|null | Handle on the platform (without @). null when the platform does not expose one |
status | string | Platform connection status: active, expired, inactive (might be disconnected or suspended on a platform) |
platform | string | Platform identifier |
profile_group_id | string | ID of the profile group this belongs to |
expires_at | string|null | ISO 8601 timestamp when the connection expires (if applicable) |
post_count | integer | Number of posts made through this profile |
avatar_url | string|null | URL to the profile’s avatar image (resized, hosted by Postproxy). null if not yet downloaded |
platform_url | string|null | Public URL of the account on the platform. null when the platform has no stable public profile URL (e.g. linkedin, google_business) |
placements_sync_status | string|null | State of the latest pull of this profile’s placements: syncing, synced, or failed. The pull runs in the background right after connecting and can take several minutes on profiles with many pages/locations. null for platforms without placements and for telegram, whose channels arrive via webhook |
Platform values
Section titled “Platform values”| Platform | Account type |
|---|---|
facebook | Facebook Page |
instagram | Instagram Business/Creator Account |
tiktok | TikTok Account |
linkedin | LinkedIn Profile or Company Page |
youtube | YouTube Channel |
twitter | X (Twitter) Account |
threads | Threads Account |
pinterest | Pinterest Account |
bluesky | Bluesky Account |
telegram | Telegram Bot (publishes to channels via placements) |
google_business | Google Business Profile (publishes to locations via placements) |
List profiles
Section titled “List profiles”GET /api/profiles
Retrieves your profiles. Pass profile_group_id to restrict to a single group; omit it to return profiles across every group your API key or OAuth user can access.
Query parameters
Section titled “Query parameters”| Name | Type | Required | Default | Description |
|---|---|---|---|---|
profile_group_id | string | No | - | Restrict to profiles in this group. When omitted, returns profiles across every profile group your API key or OAuth user can access. |
Example
Section titled “Example”curl -X GET "https://api.postproxy.dev/api/profiles" \ -H "Authorization: Bearer YOUR_API_KEY"import PostProxy from "postproxy-sdk";
const client = new PostProxy("YOUR_API_KEY");const profiles = await client.profiles.list();console.log(profiles);from postproxy import PostProxy
client = PostProxy("YOUR_API_KEY")profiles = await client.profiles.list()print(profiles)package main
import ( "fmt" postproxy "github.com/postproxy/postproxy-go")
func main() { client := postproxy.New("YOUR_API_KEY") profiles, _ := client.Profiles.List() fmt.Println(profiles)}require "postproxy"
client = PostProxy::Client.new("YOUR_API_KEY")profiles = client.profiles.listputs profilesuse PostProxy\PostProxy;
$client = new PostProxy("YOUR_API_KEY");$profiles = $client->profiles->list();print_r($profiles);import dev.postproxy.PostProxy;
PostProxy client = new PostProxy("YOUR_API_KEY");var profiles = client.profiles().list();System.out.println(profiles);using PostProxy;
var client = new PostProxyClient("YOUR_API_KEY");var profiles = await client.Profiles.ListAsync();Console.WriteLine(profiles);Response:
{ "data": [ { "id": "prof123abc", "name": "My Company Page", "username": null, "platform": "facebook", "status": "active", "profile_group_id": "grp456xyz", "expires_at": null, "post_count": 42, "avatar_url": "https://cdn.postproxy.dev/uploads/avatar_prof123abc.jpg", "platform_url": "https://www.facebook.com/123456789012345" }, { "id": "prof789def", "name": "@mycompany", "username": "mycompany", "platform": "instagram", "status": "expired", "profile_group_id": "grp456xyz", "expires_at": "2024-03-15T00:00:00.000Z", "post_count": 38, "avatar_url": "https://cdn.postproxy.dev/uploads/avatar_prof789def.jpg", "platform_url": "https://www.instagram.com/mycompany/" }, { "id": "prof321ghi", "name": "John Doe", "username": null, "platform": "linkedin", "status": "inactive", "profile_group_id": "grp456xyz", "expires_at": null, "post_count": 15, "avatar_url": null, "platform_url": null }, { "id": "prof654jkl", "name": "@mycompany", "username": "mycompany", "platform": "twitter", "status": "active", "profile_group_id": "grp456xyz", "expires_at": null, "post_count": 127, "avatar_url": "https://cdn.postproxy.dev/uploads/avatar_prof654jkl.jpg", "platform_url": "https://x.com/mycompany" } ]}Profile resolution
Section titled “Profile resolution”The same scoping rule applies to the :id endpoints below (GET /api/profiles/:id, /placements, /stats, /follower_history, /daily_stats, PATCH /api/profiles/:id/assign_placement_to_group, PATCH /api/profiles/:id/assign_to_group, and DELETE /api/profiles/:id):
profile_group_id sent? | Profile ID lookup scope |
|---|---|
| Yes | The profile must belong to that group |
| No | Resolved across every profile group your API key or OAuth user can access |
This mirrors the resolution rule already used by POST /api/posts.
Get profile
Section titled “Get profile”GET /api/profiles/:id
Retrieves a single profile by its ID. The response includes the profile fields plus the latest stats snapshot per placement and (for placement networks) a summary_stats rollup.
For non-placement networks (e.g. bluesky, twitter), latest_stats contains a single entry with placement_id: null and summary_stats is null.
Snapshots are typically refreshed every 23 hours per profile. If latest_stats is empty, the profile has been connected but has not yet been polled for stats.
Path parameters
Section titled “Path parameters”| Name | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Profile id |
Response fields
Section titled “Response fields”| Field | Type | Description |
|---|---|---|
latest_stats | array | Latest snapshot per placement. One entry for non-placement networks (with placement_id: null). Empty array if no snapshots have been recorded yet. |
latest_stats[].placement_id | string|null | Platform-specific placement ID. null for non-placement networks. |
latest_stats[].stats | object | Platform-specific metrics. See Stats fields by network. |
latest_stats[].recorded_at | string | ISO 8601 timestamp when the snapshot was captured. |
summary_stats | object|null | For placement networks: numeric values summed across the latest snapshot of every placement. null for non-placement networks and when no snapshots exist. Non-numeric values (e.g. channel_title) are omitted from the summary. |
summary_stats.stats | object | Summed metrics. |
summary_stats.recorded_at | string | ISO 8601 timestamp of the most recent placement snapshot included in the summary. |
Example
Section titled “Example”curl -X GET "https://api.postproxy.dev/api/profiles/prof123abc" \ -H "Authorization: Bearer YOUR_API_KEY"import PostProxy from "postproxy-sdk";
const client = new PostProxy("YOUR_API_KEY");const profile = await client.profiles.get("prof123abc");console.log(profile);from postproxy import PostProxy
client = PostProxy("YOUR_API_KEY")profile = await client.profiles.get("prof123abc")print(profile)package main
import ( "fmt" postproxy "github.com/postproxy/postproxy-go")
func main() { client := postproxy.New("YOUR_API_KEY") profile, _ := client.Profiles.Get("prof123abc") fmt.Println(profile)}require "postproxy"
client = PostProxy::Client.new("YOUR_API_KEY")profile = client.profiles.get("prof123abc")puts profileuse PostProxy\PostProxy;
$client = new PostProxy("YOUR_API_KEY");$profile = $client->profiles->get("prof123abc");print_r($profile);import dev.postproxy.PostProxy;
PostProxy client = new PostProxy("YOUR_API_KEY");var profile = client.profiles().get("prof123abc");System.out.println(profile);using PostProxy;
var client = new PostProxyClient("YOUR_API_KEY");var profile = await client.Profiles.GetAsync("prof123abc");Console.WriteLine(profile);Response:
{ "id": "prof_li_001", "name": "Acme Inc", "username": null, "platform": "linkedin", "status": "active", "profile_group_id": "grp456xyz", "expires_at": null, "post_count": 42, "avatar_url": "https://cdn.postproxy.dev/uploads/avatar_prof_li_001.jpg", "platform_url": null, "latest_stats": [ { "placement_id": "108520199", "stats": { "followerCount": 4567, "shareCount": 10, "likeCount": 99, "allPageViews": 12728, "overviewPageViews": 6348, "aboutPageViews": 1533, "careersPageViews": 1378, "peoplePageViews": 3370, "insightsPageViews": 99 }, "recorded_at": "2026-05-11T08:00:00Z" }, { "placement_id": "110131347", "stats": { "followerCount": 1200, "shareCount": 4, "likeCount": 22, "allPageViews": 3100 }, "recorded_at": "2026-05-11T08:00:01Z" } ], "summary_stats": { "stats": { "followerCount": 5767, "shareCount": 14, "likeCount": 121, "allPageViews": 15828, "overviewPageViews": 6348, "aboutPageViews": 1533, "careersPageViews": 1378, "peoplePageViews": 3370, "insightsPageViews": 99 }, "recorded_at": "2026-05-11T08:00:01Z" }}List placements
Section titled “List placements”GET /api/profiles/:id/placements
For an account-wide listing across every profile — including which connected profile actively serves each placement and how to switch it — see Placements.
Retrieves the available placements for a profile. For Facebook profiles, placements are business pages. For LinkedIn profiles, placements include the personal profile and organizations. For Pinterest profiles, placements are boards. For Telegram profiles, placements are the channels the bot has been added to. For Google Business profiles, placements are the locations associated with the connected Business Profile account(s).
This endpoint is available for facebook, linkedin, pinterest, telegram, and google_business profiles.
Placements are account-level records: your account holds exactly one record per placement, shared by every profile that can see it on the platform, and this endpoint returns all of them for the profile — so two profiles that both admin the same Facebook Page both list it, under the same id. A placement and its history survive its connecting profile’s disconnect; any other connected profile with access serves it from then on. The listing follows the profile-group scoping below: a group-scoped key or profile_group_id returns that group’s placements; an unscoped account key also includes placements left unassigned by a deleted group.
Placements are pulled in the background right after a profile connects, which can take several minutes for profiles with many pages or locations. An empty list right after connecting usually means that pull is still running: check placements_sync_status on the Profile object (syncing → synced / failed), or subscribe to the profile.placements_synced webhook instead of polling.
If no placement is specified when creating a post:
- LinkedIn: defaults to the personal profile
- Facebook: it fails —
page_idis always required - Pinterest: it fails
- Telegram: it fails —
chat_idis always required - Google Business: it fails —
location_id(the location resource path) is always required
For Telegram, each placement is a channel the bot has been added to. The placement id is the Telegram chat_id you pass as chat_id when creating a post. The list is empty until the user adds the bot as administrator to a channel — Telegram pushes a my_chat_member event for each one and we record it. Poll this endpoint after connecting Telegram until the expected channels appear.
For Google Business, each placement is a location resource managed by the connected account(s). The placement id is the full Business Profile resource path (e.g. accounts/123456789/locations/987654321) you pass as location_id when creating a post. Each location’s metadata.place_id is its Google Maps Place ID — use it to cross-reference the location against Google Maps / Places or build a reviews permalink; it’s omitted when Google returns no Place ID for the location. Listing locations issues one call per Google Business account on the profile, so responses may be slower than other networks when many accounts/locations are linked.
Path parameters
Section titled “Path parameters”| Name | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Profile id |
Placement object
Section titled “Placement object”| Field | Type | Description |
|---|---|---|
id | string|null | Platform-specific placement ID. null for personal profile (LinkedIn) |
name | string | Display name of the placement |
metadata | object | Extra per-placement data Postproxy stores. For Google Business this carries the location’s place_id (Google Maps Place ID). {} when there’s nothing extra. |
Example
Section titled “Example”curl -X GET "https://api.postproxy.dev/api/profiles/prof123abc/placements" \ -H "Authorization: Bearer YOUR_API_KEY"import PostProxy from "postproxy-sdk";
const client = new PostProxy("YOUR_API_KEY");const placements = await client.profiles.placements("prof123abc");console.log(placements);from postproxy import PostProxy
client = PostProxy("YOUR_API_KEY")placements = await client.profiles.placements("prof123abc")print(placements)package main
import ( "fmt" postproxy "github.com/postproxy/postproxy-go")
func main() { client := postproxy.New("YOUR_API_KEY") placements, _ := client.Profiles.Placements("prof123abc") fmt.Println(placements)}require "postproxy"
client = PostProxy::Client.new("YOUR_API_KEY")placements = client.profiles.placements("prof123abc")puts placementsuse PostProxy\PostProxy;
$client = new PostProxy("YOUR_API_KEY");$placements = $client->profiles->placements("prof123abc");print_r($placements);import dev.postproxy.PostProxy;
PostProxy client = new PostProxy("YOUR_API_KEY");var placements = client.profiles().placements("prof123abc");System.out.println(placements);using PostProxy;
var client = new PostProxyClient("YOUR_API_KEY");var placements = await client.Profiles.PlacementsAsync("prof123abc");Console.WriteLine(placements);Response:
{ "data": [ { "id": null, "name": "Personal Profile", "metadata": {} }, { "id": "108520199", "name": "Acme Marketing", "metadata": {} }, { "id": "110131347", "name": "Acme Labs", "metadata": {} } ]}Assign placement to group
Section titled “Assign placement to group”PATCH /api/profiles/:id/assign_placement_to_group
Moves a single placement (a Facebook Page, LinkedIn organization, Telegram channel, Google Business location, …) into a different profile group, independent of where its parent connection is homed. This is the API equivalent of the group selector on a profile’s page in the app. Group-scoped surfaces (profile lists, post attribution, composer, queues) follow the placement’s group; publishing itself is untouched.
A connection shows a placement in exactly one group at a time. On accounts allowed to connect the same platform account more than once, each connection carries its own view: this endpoint moves the placement only for the connection in the URL, and other connections seeing the same placement keep their groups. A placement lands in the group of the profile that connected it and only becomes unassigned when that group is deleted; an unassigned placement can’t be posted to until it’s assigned again. Account-wide API keys can assign unassigned placements; a group-scoped key stays confined to its own group’s placements.
Path parameters
Section titled “Path parameters”| Name | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Profile ID (a connection that can see the placement; the move applies to this connection’s view) |
Body parameters
Section titled “Body parameters”| Name | Type | Required | Description |
|---|---|---|---|
placement_id | string | Yes | Platform-specific placement ID, as returned by List placements |
target_profile_group_id | string | Yes | ID of the profile group to move the placement into. Must be in the same environment (live/sandbox) as your API key |
Example
Section titled “Example”curl -X PATCH "https://api.postproxy.dev/api/profiles/prof123abc/assign_placement_to_group" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "placement_id": "967627696429808", "target_profile_group_id": "grp456def" }'Response — the moved placement in the Placement object shape, plus its new group:
{ "id": "967627696429808", "name": "Acme Store", "metadata": { "username": "acmestore" }, "profile_group_id": "grp456def"}Scoping
Section titled “Scoping”An API key scoped to a single profile group cannot move placements across groups — both the placement and the target group must be within the key’s group, so any cross-group attempt returns 404. Use an account-wide API key to move placements between groups.
Error responses
Section titled “Error responses”Profile, placement, or target group not found / not accessible (404):
{ "error": "Not found. Make sure you pass the correct profile_group_id"}Missing placement_id or target_profile_group_id (400):
{ "status": 400, "error": "Bad Request", "message": "param is missing or the value is empty or invalid: placement_id"}Assign profile to group
Section titled “Assign profile to group”PATCH /api/profiles/:id/assign_to_group
Moves a profile (the connection itself) into a different profile group, without disconnecting and reconnecting it. This is the API equivalent of the group selector next to a profile’s name in the app, and the counterpart of Assign placement to group, which moves a single placement rather than the whole connection. The connection keeps its ID, tokens, and post history, so nothing scheduled against it breaks.
The profile’s placements that sit in its current home group move with it. Placements you had already assigned to another group stay where they are.
The target group must be able to take the connection: on group-billed plans a group holds one profile per network, so moving a profile into a group that already has one on the same network returns 422. The target group must be in the same environment (live/sandbox) as the profile.
Path parameters
Section titled “Path parameters”| Name | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Profile ID |
Body parameters
Section titled “Body parameters”| Name | Type | Required | Description |
|---|---|---|---|
target_profile_group_id | string | Yes | ID of the profile group to move the profile into. Must be in the same environment (live/sandbox) as your API key |
Example
Section titled “Example”curl -X PATCH "https://api.postproxy.dev/api/profiles/prof123abc/assign_to_group" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "target_profile_group_id": "grp456def" }'Response — the moved profile, in the same shape as Get profile, with profile_group_id set to the new group. Moving a profile into the group it is already in is a no-op and returns 200:
{ "id": "prof123abc", "name": "Acme Corp", "username": null, "platform": "linkedin", "status": "active", "profile_group_id": "grp456def", "expires_at": "2026-12-01T10:00:00Z", "post_count": 42, "avatar_url": "https://...", "platform_url": null, "placements_sync_status": null, "latest_stats": [], "summary_stats": null}Scoping
Section titled “Scoping”An API key scoped to a single profile group cannot move profiles across groups — both the profile and the target group must be within the key’s group, so any cross-group attempt returns 404. Use an account-wide API key to move profiles between groups.
Error responses
Section titled “Error responses”Profile or target group not found / not accessible (404):
{ "error": "Not found. Make sure you pass the correct profile_group_id"}Target group already has a profile on this network (422):
{ "error": "Another linkedin profile is already connected to this profile group. Connect it to a different profile group, or move to a plan billed per profile to keep several here."}Missing target_profile_group_id (400):
{ "status": 400, "error": "Bad Request", "message": "param is missing or the value is empty or invalid: target_profile_group_id"}Profile stats
Section titled “Profile stats”GET /api/profiles/:id/stats
Retrieves the full stats timeseries for a profile. Mirrors Post Stats in shape (records[].stats + recorded_at) — use this to plot follower growth and engagement trends over time.
This is the raw snapshot series — one record per polling cycle, keys straight from the platform. For clean one-record-per-day series, see Follower history and Daily stats.
Snapshots are captured roughly every 23 hours. For networks with multiple placements (Facebook pages, LinkedIn organizations, Telegram channels, Google Business locations), each placement has its own timeseries — placement_id is required so the response is scoped to a single placement.
Path parameters
Section titled “Path parameters”| Name | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Profile id |
Query parameters
Section titled “Query parameters”| Name | Type | Required | Description |
|---|---|---|---|
placement_id | string | Conditional | Required for facebook, linkedin, telegram, and google_business profiles. The platform-specific ID returned by List placements. Omit (or ignored) for other networks. |
from | string | No | ISO 8601 timestamp — only include snapshots recorded at or after this time. |
to | string | No | ISO 8601 timestamp — only include snapshots recorded at or before this time. |
Example
Section titled “Example”curl -X GET "https://api.postproxy.dev/api/profiles/prof_li_001/stats?placement_id=108520199&from=2026-04-01T00:00:00Z" \ -H "Authorization: Bearer YOUR_API_KEY"import { PostProxy } from "postproxy-sdk";
const client = new PostProxy("YOUR_API_KEY");const stats = await client.profiles.getProfileStats("prof_li_001", { placementId: "108520199", from: "2026-04-01T00:00:00Z",});console.log(stats);from postproxy import PostProxy
client = PostProxy("YOUR_API_KEY")stats = await client.profiles.get_profile_stats( "prof_li_001", placement_id="108520199", from_="2026-04-01T00:00:00Z",)print(stats)package main
import ( "context" "fmt" "github.com/postproxy/postproxy-go")
func main() { client := postproxy.NewClient("YOUR_API_KEY") placementID := "108520199" from := "2026-04-01T00:00:00Z" stats, _ := client.Profiles.GetProfileStats(context.Background(), "prof_li_001", &postproxy.ProfileStatsOptions{ PlacementID: &placementID, From: &from, }) fmt.Println(stats)}require "postproxy"
client = PostProxy::Client.new("YOUR_API_KEY")stats = client.profiles.get_profile_stats( "prof_li_001", placement_id: "108520199", from: "2026-04-01T00:00:00Z")puts statsuse PostProxy\Client;
$client = new Client(apiKey: 'YOUR_API_KEY');$stats = $client->profiles()->getProfileStats( 'prof_li_001', placementId: '108520199', from: '2026-04-01T00:00:00Z',);print_r($stats);import dev.postproxy.sdk.PostProxy;
var client = PostProxy.builder("YOUR_API_KEY").build();var stats = client.profiles().getProfileStats( "prof_li_001", "108520199", "2026-04-01T00:00:00Z", null);System.out.println(stats);using PostProxy;
var client = PostProxyClient.Builder("YOUR_API_KEY").Build();var stats = await client.Profiles.GetProfileStatsAsync( "prof_li_001", placementId: "108520199", from: "2026-04-01T00:00:00Z");Console.WriteLine(stats);Response:
{ "data": { "profile_id": "prof_li_001", "platform": "linkedin", "placement_id": "108520199", "records": [ { "stats": { "followerCount": 4500, "shareCount": 8, "likeCount": 80, "allPageViews": 12000 }, "recorded_at": "2026-05-09T08:00:00Z" }, { "stats": { "followerCount": 4520, "shareCount": 9, "likeCount": 90, "allPageViews": 12400 }, "recorded_at": "2026-05-10T08:00:00Z" }, { "stats": { "followerCount": 4567, "shareCount": 10, "likeCount": 99, "allPageViews": 12728 }, "recorded_at": "2026-05-11T08:00:00Z" } ] }}Response fields
Section titled “Response fields”| Field | Type | Description |
|---|---|---|
data.profile_id | string | Profile ID. |
data.platform | string | Network name (facebook, linkedin, bluesky, etc.). |
data.placement_id | string|null | The placement filter that was applied (echo of the request). null for non-placement networks. |
data.records | array | Snapshots ordered by recorded_at ascending. |
records[].stats | object | Platform-specific metrics. See Stats fields by network. |
records[].recorded_at | string | ISO 8601 timestamp when the snapshot was captured. |
Error responses
Section titled “Error responses”Missing placement_id for a placement network (400):
{ "error": "placement_id is required for linkedin profiles"}Profile not found (404):
{ "error": "Not found"}Follower history
Section titled “Follower history”GET /api/profiles/:id/follower_history
Daily follower movement for a date range, one record per day. This reads a daily rollup refreshed once a day per network — distinct from the raw snapshot timeseries served by Profile stats.
Follower data is collected for facebook, instagram, youtube, and tiktok profiles.
Path parameters
Section titled “Path parameters”| Name | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Profile id |
Query parameters
Section titled “Query parameters”| Name | Type | Required | Description |
|---|---|---|---|
placement_id | string | Conditional | Required for placement networks (facebook, linkedin, telegram, google_business) — the placement ID from List placements. Of these, only Facebook reports follower data. |
from | string | No | ISO 8601 date. Defaults to 29 days before to. |
to | string | No | ISO 8601 date. Defaults to yesterday. |
The range is capped at 366 days.
Example
Section titled “Example”curl -X GET "https://api.postproxy.dev/api/profiles/prof_fb_001/follower_history?placement_id=108520199&from=2026-08-01&to=2026-08-24" \ -H "Authorization: Bearer YOUR_API_KEY"Response:
{ "data": { "profile_id": "prof_fb_001", "platform": "facebook", "placement_id": "108520199", "from": "2026-08-01", "to": "2026-08-24", "records": [ { "date": "2026-08-01", "followers_gained": 12, "followers_lost": 3, "followers_change": 9, "followers_total": 5210 } ] }}Response fields
Section titled “Response fields”| Field | Type | Description |
|---|---|---|
data.placement_id | string|null | The placement filter that was applied (echo of the request). null for non-placement networks. |
data.from / data.to | string | The date range that was applied, including defaults. |
records[].date | string | ISO 8601 date the record describes. |
records[].followers_gained | integer|null | New followers that day. |
records[].followers_lost | integer|null | Unfollows that day. |
records[].followers_change | integer|null | Net movement. Derived as followers_gained − followers_lost when the platform reports both but no net figure. |
records[].followers_total | integer|null | Total follower count as of that day. |
Metrics a platform does not expose are null; days with no follower data at all are omitted rather than returned as all-null records.
| Platform | followers_gained | followers_lost | followers_change | followers_total |
|---|---|---|---|---|
| ✓ | ✓ | computed | most recent day only | |
| ✓ (gains only) | — | — | most recent day only | |
| YouTube | ✓ | ✓ | — | most recent day only |
| TikTok | — | — | ✓ (net day-over-day change) | ✓ (daily) |
Notes:
- Days are bucketed as the platform reports them — Meta and YouTube use US Pacific time, TikTok uses UTC.
- History depth varies by platform: YouTube reaches back years, Facebook roughly 90 days, Instagram 30 days, and TikTok accumulates forward from when daily collection started for the profile.
- Google Business has no follower data — a Business Profile has no audience count.
Error responses
Section titled “Error responses”Missing placement_id for a placement network (400):
{ "error": "placement_id is required for facebook profiles"}from after to (400):
{ "error": "from must be on or before to"}Range longer than 366 days (422):
{ "error": "date range cannot exceed 366 days"}Daily stats
Section titled “Daily stats”GET /api/profiles/:id/daily_stats
Per-day account metrics for a date range, with a choice of attribution:
attribution=received(default) — engagement bucketed by the day it arrived. A like landing today on last month’s post counts today. This is the right mode for “activity during this period”.attribution=publish— each post’s lifetime totals summed onto its publish day, computed from the latest post snapshots at read time.
Account-level daily metrics (views, platform_stats) are collected for facebook, instagram, youtube, tiktok, and google_business profiles; the received-attribution engagement metrics (*_received) work for every network with post insights.
Path parameters
Section titled “Path parameters”| Name | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Profile id |
Query parameters
Section titled “Query parameters”| Name | Type | Required | Description |
|---|---|---|---|
placement_id | string | Conditional | Required for placement networks (facebook, linkedin, telegram, google_business) — the placement ID from List placements. For Google Business it’s the location’s placement id. |
attribution | string | No | received (default) or publish. |
from | string | No | ISO 8601 date. Defaults to 29 days before to. |
to | string | No | ISO 8601 date. Defaults to yesterday. |
The range is capped at 366 days.
Example
Section titled “Example”curl -X GET "https://api.postproxy.dev/api/profiles/prof_fb_001/daily_stats?placement_id=108520199&from=2026-08-01&to=2026-08-24" \ -H "Authorization: Bearer YOUR_API_KEY"Response (attribution=received):
{ "data": { "profile_id": "prof_fb_001", "platform": "facebook", "placement_id": "108520199", "attribution": "received", "from": "2026-08-01", "to": "2026-08-24", "data_timezone": "America/Los_Angeles", "records": [ { "date": "2026-08-01", "views": 12556, "viewers": 8210, "viewers_7d": 31400, "viewers_28d": 90210, "likes_received": 41, "comments_received": 7, "shares_received": 3, "views_received": 9800, "platform_stats": { "page_views_total": 120, "page_post_engagements": 210 } } ], "metrics": { "views": { "supported": true, "source": "native", "platform_metric": "page_media_view", "additive": true, "missing_day": "unavailable", "coverage_start": "2026-06-09", "provisional_from": "2026-08-23" }, "viewers": { "supported": true, "source": "native", "platform_metric": "page_total_media_view_unique", "window": "1d", "additive": false, "missing_day": "unavailable", "coverage_start": "2026-06-09", "provisional_from": "2026-08-23" }, "viewers_7d": { "supported": true, "source": "native", "platform_metric": "page_total_media_view_unique", "window": "7d", "additive": false, "missing_day": "unavailable", "coverage_start": "2026-06-09", "provisional_from": "2026-08-23" }, "viewers_28d": { "supported": true, "source": "native", "platform_metric": "page_total_media_view_unique", "window": "28d", "additive": false, "missing_day": "unavailable", "coverage_start": "2026-06-09", "provisional_from": "2026-08-23" }, "likes_received": { "supported": true, "source": "derived", "additive": true, "missing_day": "zero", "coverage_start": "2026-07-14", "provisional_from": "2026-08-23" }, "comments_received": { "supported": true, "source": "derived", "additive": true, "missing_day": "zero", "coverage_start": "2026-07-14", "provisional_from": "2026-08-23" }, "shares_received": { "supported": true, "source": "derived", "additive": true, "missing_day": "zero", "coverage_start": "2026-07-14", "provisional_from": "2026-08-23" }, "views_received": { "supported": true, "source": "derived", "additive": true, "missing_day": "zero", "coverage_start": "2026-07-14", "provisional_from": "2026-08-23" } } }}Response fields (attribution=received)
Section titled “Response fields (attribution=received)”| Field | Type | Description |
|---|---|---|
records[].date | string | ISO 8601 date the record describes. |
records[].views | integer|null | Account-level views that day, as the platform reports them. |
records[].viewers / viewers_7d / viewers_28d | integer|null | Unique viewers as computed by the platform for the window ending on that day: the day itself, the trailing 7 days, the trailing 28 days. null where the platform has no unique-viewer metric (see the table below). |
records[].likes_received / comments_received / shares_received / views_received | integer|null | That day’s increase in engagement, summed across the profile’s tracked posts. |
records[].platform_stats | object | Network-native daily extras (open map, varies by platform — see below). |
data_timezone | string|null | The timezone the platform buckets days in (null for networks without a daily series). |
metrics | object | Per-key metadata — see Metric metadata. |
Unique counts are not additive — never sum viewers* values across days; read the _7d/_28d value on the last day of your range instead. An unavailable value is always null, never 0.
On placement networks the *_received metrics are profile-level (summed across all of the connection’s placements) and are merged into the requested placement’s records by date, while views, viewers*, and platform_stats are scoped to the requested placement.
Posts are polled at full cadence for their first week, then daily up to 30 days old; beyond that a post’s later engagement no longer registers in the *_received series.
Viewers by platform
Section titled “Viewers by platform”| Platform | viewers (1d) | viewers_7d | viewers_28d |
|---|---|---|---|
✓ page_total_media_view_unique (period=day) | ✓ (period=week) | ✓ (period=days_28) | |
✓ reach (period=day, last 30 days) | ✓ (period=week) | ✓ (period=days_28) | |
| YouTube | — (the Analytics API has no unique-viewer metric) | — | — |
| TikTok | — | — | — |
| Google Business | — | — | — |
Metric metadata
Section titled “Metric metadata”metrics has one entry per top-level record key (views, viewers*, *_received, or *_published under attribution=publish):
| Field | Meaning |
|---|---|
supported | Whether this profile can ever carry a value for the key. When false the record value is always null and reason says why: platform (the network has no such metric), plan (the account’s plan doesn’t include insights, or insights are switched off), permission (the connected account didn’t grant the insights permission). |
source | native — stored as the platform reports it. derived — computed by Postproxy (TikTok views from cumulative video totals, every *_received / *_published key from post snapshots). |
platform_metric | The platform metric the key is read from (native keys only). |
window | For unique-viewer keys, the window the value covers, ending on the record’s day: 1d, 7d, 28d. |
additive | true when values can be summed across days; false for unique-viewer keys. |
missing_day | What a day missing from records, or a null value, means on or after coverage_start: unavailable (not reported by the platform for that day) or zero (*_received — an absent day had no tracked activity). Before coverage_start everything is unavailable. |
coverage_start | First day Postproxy holds a value for this key on this profile; null when supported but nothing has been stored yet (for example an Instagram account under 100 followers, which Meta refuses to serve series for). |
provisional_from | First day whose values may still be revised on a later pull — Meta finalises daily numbers within ~48 hours, YouTube Analytics within ~72 hours, *_received for the trailing 2 days. null when values are final as soon as they appear (TikTok). |
Response fields (attribution=publish)
Section titled “Response fields (attribution=publish)”records[] instead carries likes_published, comments_published, shares_published, and views_published — each post’s latest lifetime totals bucketed onto its publish day. platform_stats is empty in this mode. metrics describes those keys: all derived, missing_day: "zero", coverage_start is the profile’s earliest publish day, and provisional_from reaches back 30 days because a post’s totals keep moving for as long as it is polled.
platform_stats by network
Section titled “platform_stats by network”| Network | Typical fields |
|---|---|
facebook | page_views_total, page_post_engagements |
instagram | — |
youtube | estimatedMinutesWatched, likes, comments, shares |
tiktok | total_video_views_cum, total_video_likes_cum, total_video_comments_cum, total_video_shares_cum, video_count (cumulative totals across the account’s video list) |
google_business | The raw Business Profile Performance metrics: business_impressions_desktop_search, business_impressions_desktop_maps, business_impressions_mobile_search, business_impressions_mobile_maps, website_clicks, call_clicks, business_direction_requests, business_conversations, business_bookings, business_food_orders, business_food_menu_clicks |
A key only appears when the platform returned a value for it, so fields can come and go between days.
Error responses
Section titled “Error responses”Same as Follower history, plus:
Invalid attribution (400):
{ "error": "attribution must be received or publish"}Stats fields by network
Section titled “Stats fields by network”The stats object’s keys come straight from each platform’s API — they are not normalized into a common schema, so each network exposes a different set.
| Network | Placement-scoped? | Typical fields |
|---|---|---|
facebook | Yes (per page) | fan_count, followers_count, plus daily page insights: page_media_view (Meta’s unified “Views”), page_views_total, page_post_engagements, page_daily_follows, page_daily_unfollows, page_daily_follows_unique, page_daily_unfollows_unique |
linkedin | Yes (per organization) | followerCount, shareCount, likeCount, commentCount, clickCount, engagement, allPageViews, overviewPageViews, aboutPageViews, careersPageViews, peoplePageViews, insightsPageViews |
telegram | Yes (per channel) | followers_count, channel_title, channel_username |
instagram | No | followers_count, follows_count, media_count, plus per-window insights suffixed with _1d, _7d, _14d, _30d: views_*, reach_*, profile_views_*, accounts_engaged_*, total_interactions_*, website_clicks_* |
threads | No | followers_count, views, likes, replies, reposts, quotes |
youtube | No | subscriberCount, viewCount, videoCount, plus per-window Analytics metrics suffixed with _7d, _28d, _90d: views_*, estimatedMinutesWatched_*, averageViewDuration_*, averageViewPercentage_*, subscribersGained_*, subscribersLost_*, likes_*, dislikes_*, comments_*, shares_*, videosAddedToPlaylists_*, videosRemovedFromPlaylists_* |
twitter | No | followers_count, following_count, tweet_count, listed_count, like_count |
tiktok | No | follower_count, following_count, likes_count, video_count |
pinterest | No | follower_count, following_count, pin_count, board_count, monthly_views, analytics_30d (nested 30-day rollup) |
bluesky | No | followersCount, followsCount, postsCount |
google_business | Yes (per location) | views (all four impression metrics summed), plus the raw Business Profile Performance metrics: business_impressions_desktop_search, business_impressions_desktop_maps, business_impressions_mobile_search, business_impressions_mobile_maps, website_clicks, call_clicks, business_direction_requests, business_conversations, business_bookings, business_food_orders, business_food_menu_clicks |
Notes:
- LinkedIn page-view metrics are filtered down to the rollups (we drop redundant mobile/desktop splits and dead sections like
productsPageViews/lifeAtPageViews). - Non-numeric fields (e.g. Telegram’s
channel_title) appear inlatest_stats[].statsbut are omitted fromsummary_stats.stats, which sums numeric values only. - A stats key only appears in a snapshot if the platform returned a value for it on that polling cycle, so fields can come and go between records.
- Instagram metrics vary by account. Instagram serves a different account-level metric set per account, so an absent key means that account can’t report it — not that the metric is gone.
- Google Business has no followers. A Business Profile has no audience count, so no follower field appears. Snapshots are trailing 30-day totals per location, and Google exposes no per-post analytics for local posts. See Google Business stats.
Participant status
Section titled “Participant status”GET /api/profiles/:id/participants/:user_id
Instagram only. Returns whether a user follows the profile’s Instagram account (and vice versa), plus their verified badge and follower count. The data comes live from Instagram’s messaging user lookup.
The user_id is the Instagram-scoped user id you receive when the user interacts with your account — the sender id on a DM chat (participant_external_id) or the author id on a comment (author_external_id). Instagram only resolves users who have messaged or commented on your account; arbitrary Instagram accounts can’t be looked up.
Path parameters
Section titled “Path parameters”| Name | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Profile id (must be an Instagram profile) |
user_id | string | Yes | Instagram-scoped user id (DM sender id or comment author id) |
Example
Section titled “Example”curl -X GET "https://api.postproxy.dev/api/profiles/prof123abc/participants/1254459754837075" \ -H "Authorization: Bearer YOUR_API_KEY"Response:
{ "is_verified_user": false, "is_user_follow_business": true, "is_business_follow_user": false, "follower_count": 1234, "fetched_at": "2026-08-28T10:00:00Z"}Response fields
Section titled “Response fields”| Field | Type | Description |
|---|---|---|
is_user_follow_business | boolean | The user follows your Instagram account |
is_business_follow_user | boolean | Your Instagram account follows the user |
is_verified_user | boolean | The user has a verified badge |
follower_count | integer | The user’s follower count |
fetched_at | string | ISO 8601 timestamp of when the data was fetched from Instagram |
Caching
Section titled “Caching”Lookups are rate-limited to one Instagram call per user per second. Within that window, repeat requests return the same payload from cache. The X-Cached response header says which happened: false — fetched live from Instagram; true — served from cache (fetched_at shows the original fetch time).
Error responses
Section titled “Error responses”Non-Instagram profile (400):
{ "error": "participant status is only supported for Instagram profiles"}Unknown user id (404) — Instagram can’t resolve the id, or the user is outside your account’s messaging scope:
{ "error": "Not found"}Instagram rate limit hit (429): retry after the Retry-After header.
Resolve mention
Section titled “Resolve mention”GET /api/profiles/:id/resolve_mention
LinkedIn only. Turns a LinkedIn company page URL into the URN LinkedIn needs to render an @-mention. Company, showcase, and school pages all resolve; the page does not have to be one this connection administers.
Mentions are written into post text as @[Display Name](urn) — for example @[Postproxy](urn:li:organization:110810308). Without the URN the mention publishes as plain text: no link, no notification. Postproxy resolves the URN; composing the text is yours. See Mentions.
Person mentions (linkedin.com/in/...) can’t be resolved yet — person lookup is coming soon.
Path parameters
Section titled “Path parameters”| Name | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Profile id (must be a LinkedIn profile) |
Query parameters
Section titled “Query parameters”| Name | Type | Required | Description |
|---|---|---|---|
url | string | Yes | Company page URL (https://www.linkedin.com/company/postproxy) or just its name (postproxy) |
Example
Section titled “Example”curl -X GET "https://api.postproxy.dev/api/profiles/prof_li_001/resolve_mention?url=https://www.linkedin.com/company/postproxy" \ -H "Authorization: Bearer YOUR_API_KEY"Response:
{ "urn": "urn:li:organization:110810308", "type": "organization", "name": "Postproxy", "vanity_name": "postproxy"}Response fields
Section titled “Response fields”| Field | Type | Description |
|---|---|---|
urn | string | LinkedIn URN to put inside @[...](...) |
type | string | Always organization today |
name | string | The page’s name — a sensible default for the mention’s display text |
vanity_name | string | The slug that matched, to confirm the right page came back |
Caching
Section titled “Caching”Results are cached for 1 hour per company page, shared across profiles — a page’s URN doesn’t change, and the lookup draws on the same LinkedIn quota as publishing. Pages that resolve to nothing are cached for the same hour, so a bad URL in a loop won’t hammer LinkedIn.
Error responses
Section titled “Error responses”Non-LinkedIn profile (400):
{ "error": "mention resolution is only supported for LinkedIn profiles"}Person page URL (400):
{ "error": "only organization mentions can be resolved; person pages are not supported"}Unknown company page (404):
{ "error": "Not found"}Post syncs
Section titled “Post syncs”Postproxy mirrors posts published natively on a platform into your account, so they show up in history and can receive insights and comments. Every one of those pulls is recorded as a post sync: the sync fired when the profile connects, the recurring poll (every 3 hours; 23 hours on X), and any backfill you start.
Post sync object
Section titled “Post sync object”| Field | Type | Description |
|---|---|---|
id | string | Unique sync identifier |
profile_id | string | Profile this run belongs to |
kind | string | Always posts today |
trigger | string | connect, scheduled, or backfill |
status | string | pending, running, completed, or failed |
started_at | string|null | ISO 8601 timestamp when the run began |
completed_at | string|null | ISO 8601 timestamp when it finished or failed |
posts_seen | integer | Posts the platform returned across the run |
posts_imported | integer | Posts that were new and got created. Lower than posts_seen whenever the run re-read posts you already have |
backfill_from | string|null | The date floor requested. null for connect and scheduled runs |
oldest_posted_at | string|null | Publish date of the oldest post the run reached — how far back it has walked |
error | string|null | Platform error message when status is failed |
created_at | string | ISO 8601 timestamp |
Runs are kept for 30 days. A profile’s page in the app shows the same list in its Post sync section.
Backfill posts
Section titled “Backfill posts”POST /api/profiles/:id/backfill_posts
Walks the profile’s feed backwards from the newest post, importing in batches of 25, until it reaches from or the platform stops returning posts. Runs in the background — the response is the post sync record, which you poll for progress.
Only one backfill runs per profile at a time. A backfill always walks from now backwards, so an in-flight one already covers any window a second request could ask for; starting another returns 409 with the running one’s id.
Body parameters
Section titled “Body parameters”| Parameter | Type | Required | Description |
|---|---|---|---|
from | string | Yes | ISO 8601 date/time, or a bare date meaning that date’s start of day. The run stops at the first post published before this |
Example
Section titled “Example”curl -X POST "https://api.postproxy.dev/api/profiles/prof123abc/backfill_posts" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "from": "2025-01-01" }'Response (202 Accepted):
{ "id": "sync456def", "profile_id": "prof123abc", "kind": "posts", "trigger": "backfill", "status": "pending", "started_at": null, "completed_at": null, "posts_seen": 0, "posts_imported": 0, "backfill_from": "2025-01-01T00:00:00.000Z", "oldest_posted_at": null, "error": null, "created_at": "2026-08-06T09:15:00.000Z"}- How far back you can reach depends on the platform, not on Postproxy. Where a platform’s API pages through history, the backfill follows it; where it doesn’t, the run ends early with whatever it got and
status: "completed". - Imported posts arrive with
source: "imported"and fire thepost.importedwebhook, the same as posts picked up by the recurring poll. - Posts you already have are skipped, so overlapping backfills are safe — they just report a
posts_importedlower thanposts_seen.
Error responses
Section titled “Error responses”Missing or unparseable from (400):
{ "status": 400, "error": "Bad Request", "message": "from"}Backfill already running (409):
{ "error": "A posts backfill is already running for this profile", "profile_sync_id": "sync456def"}Profile can’t be backfilled (422) — disconnected, in a rate-limit cooldown, or an X profile on a plan without pull access:
{ "error": "This profile can't be backfilled right now"}List post syncs
Section titled “List post syncs”GET /api/profiles/:id/post_syncs
Retrieves post sync runs for a profile, newest first.
Query parameters
Section titled “Query parameters”| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
trigger | string | No | - | Filter by connect, scheduled, or backfill |
status | string | No | - | Filter by pending, running, completed, or failed |
page | integer | No | 1 | Page number (1-based). Passing 0 returns page 1 |
per_page | integer | No | 25 | Results per page (maximum 100) |
Example
Section titled “Example”curl -X GET "https://api.postproxy.dev/api/profiles/prof123abc/post_syncs?trigger=backfill" \ -H "Authorization: Bearer YOUR_API_KEY"Response:
{ "total": 1, "page": 1, "per_page": 25, "data": [ { "id": "sync456def", "profile_id": "prof123abc", "kind": "posts", "trigger": "backfill", "status": "running", "started_at": "2026-08-06T09:15:02.000Z", "completed_at": null, "posts_seen": 150, "posts_imported": 143, "backfill_from": "2025-01-01T00:00:00.000Z", "oldest_posted_at": "2025-11-04T18:22:00.000Z", "error": null, "created_at": "2026-08-06T09:15:00.000Z" } ]}Get post sync
Section titled “Get post sync”GET /api/profiles/:id/post_syncs/:post_sync_id
Retrieves a single run — poll this to follow a backfill to completion. Returns a post sync object; the run is finished when status is completed or failed.
curl -X GET "https://api.postproxy.dev/api/profiles/prof123abc/post_syncs/sync456def" \ -H "Authorization: Bearer YOUR_API_KEY"Delete profile
Section titled “Delete profile”DELETE /api/profiles/:id
Disconnects and removes a profile from the account. This does not affect posts already published through this profile.
Path parameters
Section titled “Path parameters”| Name | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Profile id |
Example
Section titled “Example”curl -X DELETE "https://api.postproxy.dev/api/profiles/prof123abc" \ -H "Authorization: Bearer YOUR_API_KEY"import PostProxy from "postproxy-sdk";
const client = new PostProxy("YOUR_API_KEY");const result = await client.profiles.delete("prof123abc");console.log(result);from postproxy import PostProxy
client = PostProxy("YOUR_API_KEY")result = await client.profiles.delete("prof123abc")print(result)package main
import ( "fmt" postproxy "github.com/postproxy/postproxy-go")
func main() { client := postproxy.New("YOUR_API_KEY") result, _ := client.Profiles.Delete("prof123abc") fmt.Println(result)}require "postproxy"
client = PostProxy::Client.new("YOUR_API_KEY")result = client.profiles.delete("prof123abc")puts resultuse PostProxy\PostProxy;
$client = new PostProxy("YOUR_API_KEY");$result = $client->profiles->delete("prof123abc");print_r($result);import dev.postproxy.PostProxy;
PostProxy client = new PostProxy("YOUR_API_KEY");var result = client.profiles().delete("prof123abc");System.out.println(result);using PostProxy;
var client = new PostProxyClient("YOUR_API_KEY");var result = await client.Profiles.DeleteAsync("prof123abc");Console.WriteLine(result);Response:
{ "success": true}Token expiration
Section titled “Token expiration”Some platforms issue access tokens that expire. The expires_at field indicates when the connection will expire and require re-authentication.
| Behavior | Description |
|---|---|
expires_at: null | Token does not expire or has a refresh token |
expires_at: "2024-..." | Token expires at the specified time |
When a token expires:
- Posts to that profile will fail
- The user needs to reconnect the profile through the web dashboard
- Use the Initialize Connection endpoint to generate a new connection URL
Connecting new profiles
Section titled “Connecting new profiles”Profiles cannot be created directly via the API. To connect a new social media account:
- Use the Initialize Connection endpoint to get an OAuth URL
- Redirect the user to that URL to authenticate
- User is redirected back to your
redirect_urlafter authentication - The profile is automatically created and associated with the profile group
Using profiles in posts
Section titled “Using profiles in posts”When creating posts, reference profiles by:
- Profile ID: Use the
idid directly - Platform name: Use the platform string (e.g.,
"twitter") to automatically select the profile for that platform
{ "profiles": ["prof123abc", "twitter", "linkedin"]}If multiple profiles exist for the same platform in a profile group, using the platform name selects the first one. Use the profile ID for explicit selection.