PostProxy 1.13.0
dotnet add package PostProxy --version 1.13.0
NuGet\Install-Package PostProxy -Version 1.13.0
<PackageReference Include="PostProxy" Version="1.13.0" />
<PackageVersion Include="PostProxy" Version="1.13.0" />
<PackageReference Include="PostProxy" />
paket add PostProxy --version 1.13.0
#r "nuget: PostProxy, 1.13.0"
#:package PostProxy@1.13.0
#addin nuget:?package=PostProxy&version=1.13.0
#tool nuget:?package=PostProxy&version=1.13.0
PostProxy .NET SDK
.NET client for the PostProxy API. Uses C# records for models, System.Net.Http.HttpClient for HTTP, and System.Text.Json for JSON. No external dependencies beyond Microsoft.Extensions for DI support.
Installation
Package Manager
dotnet add package PostProxy
PackageReference
<PackageReference Include="PostProxy" Version="1.0.0" />
Requires .NET 8+.
Quick start
using PostProxy;
using PostProxy.Parameters;
var client = PostProxyClient.Builder("your-api-key")
.ProfileGroupId("pg-abc")
.Build();
// List profiles
var profiles = await client.Profiles.ListAsync();
// Create a post
var post = await client.Posts.CreateAsync(new CreatePostParams
{
Body = "Hello from PostProxy!",
Profiles = [profiles.Data[0].Id],
});
Console.WriteLine($"{post.Id} {post.Status}");
Usage
Client
using PostProxy;
// Basic
var client = PostProxyClient.Builder("your-api-key").Build();
// With a default profile group (applied to all requests)
var client = PostProxyClient.Builder("your-api-key")
.ProfileGroupId("pg-abc")
.Build();
// With a custom base URL
var client = PostProxyClient.Builder("your-api-key")
.BaseUrl("https://custom.postproxy.dev")
.Build();
Idempotency
Every write (POST/PUT/PATCH/DELETE) accepts an idempotency key, sent as the
Idempotency-Key header. If the connection drops before you see the response, retry with
the same key and you get the original response back instead of a second post:
var key = Guid.NewGuid().ToString();
var post = await client.Posts.CreateAsync(new CreatePostParams
{
Body = "Hello",
Profiles = ["profile-id"],
IdempotencyKey = key,
});
// Retrying the same call with the same key replays the original response.
Methods that take a parameters record expose it as IdempotencyKey; the rest take an
idempotencyKey argument:
await client.Comments.CreateAsync("post-id", "profile-id", "Nice!", idempotencyKey: key);
await client.Profiles.BackfillPostsAsync("profile-id", "2025-01-01", idempotencyKey: key);
Generate a fresh key per logical operation — a GUID is ideal. Keys are scoped to your account and may be up to 255 characters. The SDK never generates keys or retries for you.
| Situation | Result |
|---|---|
| First request with the key | Runs normally |
| Retry after a success | Original status and body replayed |
| Retry while the first is still running | ConflictException (409) — wait and retry |
| Same key, different request body | ValidationException (422) |
| Retry after an error response | Runs normally — errors are not replayed |
Only successful (2xx) responses are stored, so a request that failed validation or hit a
quota leaves the key free — fix the payload and retry with the same key. Stored responses
are kept for 24 hours. Requests without a key are unaffected.
Dependency injection
services.AddPostProxy(options =>
{
options.ApiKey = "your-api-key";
options.ProfileGroupId = "pg-abc";
});
Then inject PostProxyClient wherever needed.
Posts
using PostProxy.Models;
using PostProxy.Parameters;
// List posts (paginated)
var page = await client.Posts.ListAsync(new ListPostsParams
{
Page = 0, PerPage = 10, Status = PostStatus.Draft,
});
Console.WriteLine($"{page.Total} {page.Data.Count}");
// Filter by platform and schedule
var page = await client.Posts.ListAsync(new ListPostsParams
{
Platforms = [Platform.Instagram, Platform.TikTok],
ScheduledAfter = DateTimeOffset.Parse("2025-06-01T00:00:00Z"),
});
// Get a single post
var post = await client.Posts.GetAsync("post-id");
// Create a post
var post = await client.Posts.CreateAsync(new CreatePostParams
{
Body = "Check out our new product!",
Profiles = ["profile-id-1", "profile-id-2"],
});
// Create a draft
var post = await client.Posts.CreateAsync(new CreatePostParams
{
Body = "Draft content",
Profiles = ["profile-id"],
Draft = true,
});
// Create with media URLs
var post = await client.Posts.CreateAsync(new CreatePostParams
{
Body = "Photo post",
Profiles = ["profile-id"],
Media = ["https://example.com/image.jpg"],
});
// Create with local file uploads
var post = await client.Posts.CreateAsync(new CreatePostParams
{
Body = "Posted with a local file!",
Profiles = ["profile-id"],
MediaFiles = ["./photo.jpg", "./video.mp4"],
});
// Create with platform-specific params
var post = await client.Posts.CreateAsync(new CreatePostParams
{
Body = "Cross-platform post",
Profiles = ["ig-profile", "tt-profile"],
Platforms = new PlatformParams
{
Instagram = new InstagramParams
{
Format = InstagramFormat.Reel,
Collaborators = ["@friend"],
},
TikTok = new TikTokParams
{
PrivacyStatus = TikTokPrivacy.PublicToEveryone,
},
},
});
// Schedule a post
var post = await client.Posts.CreateAsync(new CreatePostParams
{
Body = "Scheduled post",
Profiles = ["profile-id"],
ScheduledAt = DateTimeOffset.Parse("2025-12-25T09:00:00Z"),
});
// Create a thread post
var post = await client.Posts.CreateAsync(new CreatePostParams
{
Body = "Thread starts here",
Profiles = ["profile-id"],
Thread =
[
new ThreadChildInput { Body = "Second post in the thread" },
new ThreadChildInput { Body = "Third with media", Media = ["https://example.com/img.jpg"] },
],
});
foreach (var child in post.Thread!)
Console.WriteLine($"{child.Id}: {child.Body}");
// Update a post (only drafts or scheduled posts)
var post = await client.Posts.UpdateAsync("post-id", new UpdatePostParams
{
Body = "Updated content!",
});
// Update platform params only
var post = await client.Posts.UpdateAsync("post-id", new UpdatePostParams
{
Platforms = new PlatformParams
{
YouTube = new YouTubeParams { PrivacyStatus = "unlisted" },
},
});
// Replace profiles and media
var post = await client.Posts.UpdateAsync("post-id", new UpdatePostParams
{
Profiles = ["twitter", "threads"],
Media = ["https://example.com/new-image.jpg"],
});
// Replace thread children
var post = await client.Posts.UpdateAsync("post-id", new UpdatePostParams
{
Thread =
[
new ThreadChildInput { Body = "Updated first reply" },
new ThreadChildInput { Body = "Updated second reply", Media = ["https://example.com/img.jpg"] },
],
});
// Remove all media
var post = await client.Posts.UpdateAsync("post-id", new UpdatePostParams
{
Media = [],
});
// Publish a draft
var post = await client.Posts.PublishDraftAsync("post-id");
// Delete a post
var result = await client.Posts.DeleteAsync("post-id");
Console.WriteLine(result.Deleted); // true
// Delete a post and also remove it from social platforms
var result2 = await client.Posts.DeleteAsync("post-id", deleteOnPlatform: true, profileGroupId: null);
// Delete from platforms only (keeps DB record). Defaults to all platforms.
var r1 = await client.Posts.DeleteOnPlatformAsync("post-id");
// Target a single network
var r2 = await client.Posts.DeleteOnPlatformAsync("post-id", new DeleteOnPlatformParams { Network = "twitter" });
// Target a specific profile
var r3 = await client.Posts.DeleteOnPlatformAsync("post-id", new DeleteOnPlatformParams { ProfileId = "prof-abc" });
// Target a specific post profile (covers entire thread for that profile)
var r4 = await client.Posts.DeleteOnPlatformAsync("post-id", new DeleteOnPlatformParams { PostProfileId = "pp-abc" });
// Get post stats
var stats = await client.Posts.StatsAsync(new PostStatsParams
{
PostIds = ["post-id-1", "post-id-2"],
});
// Filter by profiles/networks and time range
var stats = await client.Posts.StatsAsync(new PostStatsParams
{
PostIds = ["post-id-1"],
Profiles = ["instagram", "twitter"],
From = DateTimeOffset.UtcNow.AddDays(-7),
To = DateTimeOffset.UtcNow,
});
// Access stats data
foreach (var (postId, postStats) in stats.Data)
{
foreach (var platform in postStats.Platforms)
{
Console.WriteLine($"{platform.Platform}: {platform.Records.Count} snapshots");
var latest = platform.Records.Last();
Console.WriteLine($" impressions: {latest.Stats["impressions"]}");
}
}
Queues
using PostProxy.Models;
// List all queues
var queues = await client.Queues.ListAsync();
// Get a queue
var queue = await client.Queues.GetAsync("queue-id");
// Get next available slot
var nextSlot = await client.Queues.NextSlotAsync("queue-id");
Console.WriteLine(nextSlot.NextSlot);
// Create a queue with timeslots
var queue = await client.Queues.CreateAsync(
"Morning Posts",
"pg-abc",
description: "Weekday morning content",
timezone: "America/New_York",
jitter: 10,
timeslots: new object[]
{
new Dictionary<string, object> { ["day"] = 1, ["time"] = "09:00" },
new Dictionary<string, object> { ["day"] = 2, ["time"] = "09:00" },
new Dictionary<string, object> { ["day"] = 3, ["time"] = "09:00" },
});
// Update a queue
var queue = await client.Queues.UpdateAsync("queue-id",
jitter: 15,
timeslots: new object[]
{
new Dictionary<string, object> { ["day"] = 6, ["time"] = "10:00" }, // add new timeslot
new Dictionary<string, object> { ["id"] = 1, ["_destroy"] = true }, // remove existing timeslot
});
// Pause/unpause a queue
await client.Queues.UpdateAsync("queue-id", enabled: false);
// Delete a queue
var result = await client.Queues.DeleteAsync("queue-id");
// Add a post to a queue
var post = await client.Posts.CreateAsync(new CreatePostParams
{
Body = "This post will be scheduled by the queue",
Profiles = ["profile-id"],
QueueId = "queue-id",
QueuePriority = "high",
});
Webhooks
// List webhooks
var webhooks = await client.Webhooks.ListAsync();
// Get a webhook
var webhook = await client.Webhooks.GetAsync("wh-id");
// Create a webhook
var webhook = await client.Webhooks.CreateAsync(new CreateWebhookParams
{
Url = "https://example.com/webhook",
Events = ["post.published", "post.failed"],
Description = "My webhook",
});
Console.WriteLine(webhook.Secret);
// Update a webhook
var webhook = await client.Webhooks.UpdateAsync("wh-id", new UpdateWebhookParams
{
Events = ["post.published"],
Enabled = false,
});
// Delete a webhook
await client.Webhooks.DeleteAsync("wh-id");
// List deliveries
var deliveries = await client.Webhooks.DeliveriesAsync("wh-id");
foreach (var d in deliveries.Data)
Console.WriteLine($"{d.EventType}: {d.Success}");
Signature verification
Verify incoming webhook signatures using HMAC-SHA256:
using PostProxy;
var isValid = WebhookSignature.Verify(
payload: requestBody,
signatureHeader: request.Headers["X-PostProxy-Signature"],
secret: "whsec_..."
);
Event types and typed payloads
Subscribe to any of these events (or pass ["*"] for all):
post.processed, post.imported, platform_post.published, platform_post.failed, platform_post.failed_waiting_for_retry, platform_post.insights, profile.connected, profile.disconnected, profile.stats, media.failed, comment.created, profile_comment.created, message.received, message.sent, message.delivered, message.read, message.edited, message.deleted, message.failed_waiting_for_retry, message.failed, reaction.received.
WebhookEvents.Parse validates the envelope; As* helpers decode Data into a typed payload. The eight message.* events decode to MessageEventData (AsMessageEvent), reaction.received to ReactionEventData (AsReactionEvent), and profile_comment.created to ProfileCommentCreatedData (AsProfileCommentCreated).
using PostProxy.Models;
using PostProxy.Webhooks;
var ev = WebhookEvents.Parse(requestBody);
switch (ev.Type)
{
case WebhookEventType.ProfileStats:
var stats = WebhookEvents.AsProfileStats(ev);
Console.WriteLine($"{stats.ProfileId}: {stats.Stats["followerCount"]}");
break;
case WebhookEventType.PlatformPostPublished:
var pp = WebhookEvents.AsPlatformPost(ev);
Console.WriteLine($"Published: {pp.PlatformId}");
break;
case WebhookEventType.CommentCreated:
var c = WebhookEvents.AsCommentCreated(ev);
Console.WriteLine($"{c.AuthorUsername}: {c.Body}");
break;
case WebhookEventType.MessageReceived:
var m = WebhookEvents.AsMessageEvent(ev);
Console.WriteLine($"New message: {m.Message.Body}");
break;
case WebhookEventType.ReactionReceived:
var r = WebhookEvents.AsReactionEvent(ev);
Console.WriteLine($"{r.SenderExternalId} {r.Action}: {r.Emoji}");
break;
case WebhookEventType.ProfileCommentCreated:
var pc = WebhookEvents.AsProfileCommentCreated(ev);
Console.WriteLine($"Review by {pc.AuthorUsername}: {pc.Body}");
break;
}
Comments
// List comments on a post (paginated)
var comments = await client.Comments.ListAsync("post-id", "profile-id");
foreach (var comment in comments.Data)
{
Console.WriteLine($"{comment.AuthorUsername}: {comment.Body}");
foreach (var reply in comment.Replies ?? [])
Console.WriteLine($" {reply.AuthorUsername}: {reply.Body}");
}
// List with pagination
var comments = await client.Comments.ListAsync("post-id", "profile-id", page: 2, perPage: 10);
// Filter by when PostProxy received the comment (created_at, not posted_at).
// A bare date means that date's start of day. Applies to top-level comments —
// one in range brings its full Replies list with it.
var recent = await client.Comments.ListAsync(
"post-id", "profile-id", from: "2026-03-25", to: "2026-03-26T12:00:00Z");
// Get a single comment
var comment = await client.Comments.GetAsync("post-id", "comment-id", "profile-id");
// Create a comment
var comment = await client.Comments.CreateAsync("post-id", "profile-id", "Great post!");
// Reply to a comment
var reply = await client.Comments.CreateAsync("post-id", "profile-id", "Thanks!", parentId: "comment-id");
// Delete a comment
var result = await client.Comments.DeleteAsync("post-id", "comment-id", "profile-id");
Console.WriteLine(result.Accepted); // true
// Hide / unhide a comment
await client.Comments.HideAsync("post-id", "comment-id", "profile-id");
await client.Comments.UnhideAsync("post-id", "comment-id", "profile-id");
// Like / unlike a comment
await client.Comments.LikeAsync("post-id", "comment-id", "profile-id");
await client.Comments.UnlikeAsync("post-id", "comment-id", "profile-id");
// Comments may carry media attachments and author metadata
foreach (var attachment in comment.Attachments ?? [])
Console.WriteLine($"{attachment.Type}: {attachment.Url} ({attachment.Status})");
if (comment.Metadata is not null)
Console.WriteLine($"author signals: {comment.Metadata.Count} fields");
// Privately reply to a comment — returns a direct Message (not a Comment)
var dm = await client.Comments.PrivateReplyAsync("post-id", "comment-id", "profile-id", "DM-ing you the details!");
Console.WriteLine($"Sent DM: {dm.Id}");
Comments across posts
Comments.ListAllAsync returns comments spanning every post in the profile group in one
request — the comments counterpart to Posts.StatsAsync. Every filter is optional.
This list is flat. Unlike the per-post list, replies are not nested: every comment,
top-level or reply, is its own entry linked to its parent by ParentExternalId, so Total
counts every comment and paging is exact. Entries are BulkComment, which adds PostId,
ProfileId, and Platform.
var all = await client.Comments.ListAllAsync(
profiles: ["instagram", "prof-abc"], // profile IDs or network names, mixed
postIds: ["post-1", "post-2"], // omit for every post in scope
from: "2026-03-25",
perPage: 50); // max 100
foreach (var c in all.Data)
{
// Each entry says where it came from, so you can act on it with the
// post-scoped methods above.
Console.WriteLine($"{c.Platform} {c.PostId} {c.ProfileId}: {c.Body}");
if (c.ParentExternalId is not null)
Console.WriteLine($" ↳ reply to {c.ParentExternalId}");
}
// Reply to one of them
var first = all.Data[0];
await client.Comments.CreateAsync(first.PostId, first.ProfileId, "Thanks!", parentId: first.Id);
Unknown or out-of-scope IDs in postIds and profiles are ignored rather than erroring.
Results are ordered newest first by receipt time.
Direct Messages
Manage private conversations (Chats and Messages) across Facebook, Instagram, Telegram, and Bluesky.
using PostProxy.Models;
using PostProxy.Parameters;
// List chats for a profile (paginated)
var chats = await client.Chats.ListAsync("profile-id", new ListChatsParams { PerPage = 20 });
foreach (var chat in chats.Data)
Console.WriteLine($"{chat.Id}: {chat.ParticipantName ?? chat.ParticipantExternalId}");
// Create (or look up) a chat with a participant
var newChat = await client.Chats.CreateAsync("profile-id", new CreateChatParams
{
ParticipantExternalId = "igsid_8675309",
ParticipantUsername = "jane_doe",
});
// Get a single chat
var chat = await client.Chats.GetAsync("chat-id");
// Archive / unarchive a chat (Bluesky)
await client.Chats.ArchiveAsync("chat-id");
await client.Chats.UnarchiveAsync("chat-id");
// List messages in a chat (filter by direction/status)
var messages = await client.Messages.ListAsync("chat-id", new ListMessagesParams
{
Direction = MessageDirection.Inbound,
});
foreach (var msg in messages.Data)
{
Console.WriteLine($"[{msg.Direction}] {msg.Body}");
foreach (var reaction in msg.Reactions)
Console.WriteLine($" {reaction.Emoji} ({reaction.ReactionType})");
}
// Send a text message
var sent = await client.Messages.SendAsync("chat-id", "Yes, we ship worldwide!");
// Send media (URLs and/or local files), with optional tag / reply / reply markup
await client.Messages.SendAsync("chat-id", new SendMessageParams
{
Body = "Here's the photo.",
Media = new[] { "https://cdn.example.com/photo.jpg" },
MediaFiles = new[] { "/path/to/photo.png" },
Tag = "HUMAN_AGENT",
});
// Get / edit a message
var message = await client.Messages.GetAsync("message-id");
await client.Messages.EditAsync("message-id", new EditMessageParams { Body = "Updated text" });
// React / unreact (defaults to "love" server-side when no reaction given)
await client.Messages.ReactAsync("message-id", new ReactParams { Reaction = "love", Emoji = "❤️" });
await client.Messages.UnreactAsync("message-id");
Quick replies and buttons (Facebook & Instagram)
Meta's two interactive primitives. Quick replies are chips above the participant's
composer that disappear once tapped; buttons are attached to the message and stay in
the thread. Telegram's equivalent is ReplyMarkup — passing QuickReplies or Buttons on
a Telegram or Bluesky chat returns 422.
// Quick replies — up to 13. Title ≤ 20 chars, Payload ≤ 1000.
await client.Messages.SendAsync("chat-id", new SendMessageParams
{
Body = "What can I help with?",
QuickReplies = new[]
{
new QuickReply { Title = "Track order", Payload = "TRACK" },
new QuickReply { Title = "Talk to support", Payload = "HELP" },
},
});
// Buttons — up to 3, each either web_url or postback. Card is optional and
// requires Buttons.
await client.Messages.SendAsync("chat-id", new SendMessageParams
{
Body = "Your order shipped",
Buttons = new[]
{
MessageButton.WebUrl("Track", "https://shop.example.com/o/123"),
MessageButton.Postback("Cancel", "CANCEL:123"),
},
Card = new MessageCard
{
Subtitle = "Arriving Friday",
ImageUrl = "https://cdn.example.com/shoe.png",
DefaultAction = CardDefaultAction.WebUrl("https://shop.example.com/o/123"),
},
});
Buttons are delivered as a Meta generic template and your Body becomes the template's
element title — so Body is capped at 80 characters when buttons are present. That is
Meta's limit, not PostProxy's, and a longer body is rejected with a 422 naming the
length. Buttons cannot be combined with media. Instagram is stricter than Messenger: it
delivers quick replies only on a plain-text message, so QuickReplies with media or with
Buttons returns 422 on Instagram while both are accepted on Facebook.
Validation happens server-side and names the offending index — buttons[1].url must be an https:// URL — surfacing as the SDK's usual exception for a 422.
The new parameters are sent on the JSON path only. To combine quick replies with an attachment, pass
Mediaas a hosted URL rather than uploading viaMediaFiles.
A tap comes back as an inbound message carrying TappedAction:
var inbound = await client.Messages.ListAsync("chat-id",
new ListMessagesParams { Direction = MessageDirection.Inbound });
foreach (var msg in inbound.Data)
{
if (msg.TappedAction is not null)
// TappedActionKind.QuickReply / Postback / CallbackQuery
Console.WriteLine($"{msg.TappedAction.Kind}: {msg.TappedAction.Payload}");
}
Subscribe to message.received to react to taps as they happen — the same field is on the
webhook payload. TappedAction is derived rather than stored, so it also resolves for taps
recorded before PostProxy exposed it, including Instagram ice-breaker taps and Telegram
callback queries (TappedActionKind.CallbackQuery). A tap also opens the 24h window.
Profile comments (Google Business reviews)
Profile-level comments expose Google Business reviews and replies. Reviews are user-generated — the SDK lets you list/get them and reply to or delete your own replies. Reviews sync twice daily.
// List reviews for a profile (paginated)
var reviews = await client.ProfileComments.ListAsync("profile-id");
foreach (var review in reviews.Data)
{
Console.WriteLine($"{review.AuthorUsername}: {review.Body}");
foreach (var reply in review.Replies ?? [])
Console.WriteLine($" reply: {reply.Body}");
}
// Filter by placement (location)
var reviews = await client.ProfileComments.ListAsync(
"profile-id",
placementId: "accounts/123/locations/456");
// Get a single review
var review = await client.ProfileComments.GetAsync("profile-id", "review-id");
// Reply to a review (parentId is the review id)
var reply = await client.ProfileComments.CreateAsync("profile-id", "review-id", "Thanks for visiting!");
// Delete your reply
await client.ProfileComments.DeleteAsync("profile-id", "reply-id");
Profiles
// List all profiles
var profiles = await client.Profiles.ListAsync();
// List profiles in a specific group (overrides client default)
var profiles = await client.Profiles.ListAsync("pg-other");
// Get a single profile
var profile = await client.Profiles.GetAsync("profile-id");
Console.WriteLine($"{profile.Name} {profile.Platform} {profile.Status}");
// Get available placements for a profile
var placements = await client.Profiles.PlacementsAsync("profile-id");
foreach (var p in placements.Data)
Console.WriteLine($"{p.Id} {p.Name}");
// Move a placement (e.g. a Facebook Page or Telegram channel) to another group
var placement = await client.Profiles.AssignPlacementToGroupAsync(
"profile-id", "placement-external-id", "pg-other");
Console.WriteLine(placement.ProfileGroupId); // "pg-other"
// Ice breakers (Instagram DMs): FAQ prompts shown when a user opens a chat
var iceBreakers = await client.Profiles.GetIceBreakersAsync("profile-id");
foreach (var ib in iceBreakers.IceBreakers)
Console.WriteLine(ib.Question);
await client.Profiles.SetIceBreakersAsync("profile-id",
[
new IceBreaker { Question = "What services do you offer?", Payload = "services" },
new IceBreaker { Question = "What are your hours?", Payload = "hours" },
]); // 1-4 items
await client.Profiles.DeleteIceBreakersAsync("profile-id");
// Delete a profile
var result = await client.Profiles.DeleteAsync("profile-id");
Console.WriteLine(result.Success); // true
// Profile stats timeseries — placement_id required for facebook, linkedin, telegram
var stats = await client.Profiles.GetProfileStatsAsync(
"prof_li_001",
placementId: "108520199",
from: "2026-04-01T00:00:00Z");
foreach (var r in stats.Data.Records)
{
Console.WriteLine($"{r.RecordedAt}: {r.Stats["followerCount"]}");
}
// Bluesky — no placements
var bsky = await client.Profiles.GetProfileStatsAsync("prof_bsky_001");
var last = bsky.Data.Records[^1];
Console.WriteLine(last.Stats["followersCount"]);
Every stats record (post stats and profile stats alike) carries RawStats alongside the
normalized Stats, exposing each metric under its original platform name:
var stats = await client.Posts.StatsAsync(new PostStatsParams { PostIds = ["post-id"] });
var record = stats.Data["post-id"].Platforms[0].Records[0];
Console.WriteLine(record.Stats["impressions"]); // normalized
Console.WriteLine(record.RawStats?["views"]); // Instagram's own name
Console.WriteLine(record.RawStats?["impression_count"]); // Twitter/X's own name
LinkedIn post stats now normalize likes, comments, shares, and clicks alongside
impressions — previously only impressions was normalized.
Post syncs & backfill
PostProxy mirrors posts published natively on a platform into your account. Every one of those pulls is recorded as a post sync: the one fired when the profile connects, the recurring poll, and any backfill you start.
// Start a backfill — walks the feed backwards from the newest post in batches
// of 25 until it reaches `from` or the platform stops returning posts.
var sync = await client.Profiles.BackfillPostsAsync("profile-id", "2025-01-01");
Console.WriteLine($"{sync.Id} {sync.Status}"); // "sync456def Pending"
// Poll it to completion — finished when Status is Completed or Failed
var run = await client.Profiles.GetPostSyncAsync("profile-id", sync.Id);
Console.WriteLine($"{run.PostsImported} of {run.PostsSeen}, back to {run.OldestPostedAt}");
// List recent runs (kept for 30 days), newest first
var runs = await client.Profiles.ListPostSyncsAsync(
"profile-id",
trigger: PostSyncTrigger.Backfill, // Connect | Scheduled | Backfill
status: PostSyncStatus.Completed, // Pending | Running | Completed | Failed
perPage: 25);
PostSync property |
Description |
|---|---|
Id |
Sync identifier |
ProfileId |
Profile this run belongs to |
Kind |
Always posts today |
Trigger |
Connect, Scheduled, or Backfill |
Status |
Pending, Running, Completed, or Failed |
StartedAt / CompletedAt |
DateTimeOffset? |
PostsSeen |
Posts the platform returned across the run |
PostsImported |
Posts that were new and got created |
BackfillFrom |
The date floor requested; null for Connect/Scheduled |
OldestPostedAt |
Publish date of the oldest post the run reached |
Error |
Platform error message when Status is Failed |
CreatedAt |
DateTimeOffset |
How far back a backfill reaches depends on the platform's API, not on PostProxy: where
history is pageable we follow it, otherwise the run ends early with whatever it got and
still reports PostSyncStatus.Completed.
Only one backfill runs per profile at a time — starting a second throws ConflictException
carrying the running one's id:
try
{
await client.Profiles.BackfillPostsAsync("profile-id", "2025-01-01");
}
catch (ConflictException e)
{
var runningId = e.Response!["profile_sync_id"].ToString();
// Poll the run that's already going.
}
Posts you already have are skipped, so overlapping backfills are safe. Imported posts
behave exactly like ones the poll picks up (source: "imported", post.imported webhook),
but a backfill's follow-up work is queued at a lower priority so a deep run can't slow down
publishing.
Profile Groups
using PostProxy.Models;
// List all groups
var groups = await client.ProfileGroups.ListAsync();
// Get a single group
var group = await client.ProfileGroups.GetAsync("pg-id");
Console.WriteLine($"{group.Name} {group.ProfilesCount}");
// Create a group
var group = await client.ProfileGroups.CreateAsync("My New Group");
// Delete a group (must have no profiles)
var result = await client.ProfileGroups.DeleteAsync("pg-id");
Console.WriteLine(result.Deleted); // true
// Initialize a social platform OAuth connection
var conn = await client.ProfileGroups.InitializeConnectionAsync(
"pg-id",
Platform.Instagram,
"https://yourapp.com/callback");
Console.WriteLine(conn.Url); // Redirect the user to this URL
// BlueSky — app password (synchronous, no OAuth)
var bsky = await client.ProfileGroups.ConnectBlueskyAsync(
"pg-id", "yourname.bsky.social", "xxxx-xxxx-xxxx-xxxx");
Console.WriteLine(bsky.Profile.Id);
// Telegram — bring-your-own-bot. Channels populate asynchronously; poll
// placements until non-empty.
var tg = await client.ProfileGroups.ConnectTelegramAsync(
"pg-id", "123456789:ABCdef-GhIJklMnOpQrStUvWxYz");
Console.WriteLine(tg.NextStep);
ListResponse<Placement> placements;
do
{
placements = await client.Profiles.PlacementsAsync(tg.Profile.Id);
if (placements.Data.Count == 0) await Task.Delay(3000);
} while (placements.Data.Count == 0);
Error handling
All errors extend PostProxyException, which includes the HTTP status code and raw response:
using PostProxy.Exceptions;
try
{
await client.Posts.GetAsync("nonexistent");
}
catch (NotFoundException e)
{
Console.WriteLine(e.StatusCode); // 404
Console.WriteLine(e.Response); // {error: Not found}
}
catch (PostProxyException e)
{
Console.WriteLine($"API error {e.StatusCode}: {e.Message}");
}
Exception hierarchy:
| Exception | HTTP Status |
|---|---|
PostProxyException |
Base class |
AuthenticationException |
401 |
BadRequestException |
400 |
NotFoundException |
404 |
ConflictException |
409 — duplicate submission (Response["duplicate_post_id"]), a backfill already running (Response["profile_sync_id"]), or an in-flight Idempotency-Key |
ValidationException |
422 |
A 429 (posting rate limit reached) surfaces as the base PostProxyException.
Types
All list methods return a response object with a Data list:
var profiles = (await client.Profiles.ListAsync()).Data;
var posts = await client.Posts.ListAsync(); // PaginatedResponse also has Total, Page, PerPage
Key types:
| Type | Fields |
|---|---|
Post |
Id, Body, Status, ScheduledAt, CreatedAt, Media, Thread, Platforms, QueueId, QueuePriority |
Profile |
Id, Name, Status, Platform, ProfileGroupId, ExpiresAt, PostCount |
ProfileGroup |
Id, Name, ProfilesCount |
Media |
Id, Type, Url, Status |
ThreadChild |
Id, Body, Media |
ThreadChildInput |
Body, Media |
Webhook |
Id, Url, Events, Secret, Enabled, Description, CreatedAt |
WebhookDelivery |
Id, EventId, EventType, ResponseStatus, AttemptNumber, Success, AttemptedAt, CreatedAt |
PlatformResult |
Platform, Status, Params, Error, AttemptedAt, Insights |
StatsResponse |
Data (dictionary keyed by post ID) |
PostStats |
Platforms |
PlatformStats |
ProfileId, Platform, Records |
StatsRecord |
Stats (dictionary of metric name to value), RawStats (metrics under their platform-native names), RecordedAt |
Queue |
Id, Name, Description, Timezone, Enabled, Jitter, ProfileGroupId, Timeslots, PostsCount |
Timeslot |
Id, Day, Time |
NextSlotResponse |
NextSlot |
ListResponse<T> |
Data |
Comment |
Id, ExternalId, Body, Status, AuthorUsername, AuthorAvatarUrl, AuthorExternalId, ParentExternalId, LikeCount, IsHidden, Permalink, PlatformData, PostedAt, CreatedAt, Replies |
BulkComment |
Every Comment property except Replies, plus PostId, ProfileId, Platform — returned by Comments.ListAllAsync |
PostSync |
Id, ProfileId, Kind, Trigger, Status, StartedAt, CompletedAt, PostsSeen, PostsImported, BackfillFrom, OldestPostedAt, Error, CreatedAt |
AcceptedResponse |
Accepted |
PaginatedResponse<T> |
Data, Total, Page, PerPage |
Platform parameter types
| Type | Platform |
|---|---|
FacebookParams |
Format (Post, Story), FirstComment, PageId |
InstagramParams |
Format (Post, Reel, Story), FirstComment, Collaborators, CoverUrl, AudioName, TrialStrategy, ThumbOffset, UserTags |
InstagramUserTag |
Username, X, Y, MediaIndex |
TikTokParams |
Format (Video, Image), PrivacyStatus, PhotoCoverIndex, AutoAddMusic, MadeWithAi, DisableComment, DisableDuet, DisableStitch, BrandContentToggle, BrandOrganicToggle |
LinkedInParams |
Format (Post), OrganizationId |
YouTubeParams |
Format (Post), Title, PrivacyStatus, CoverUrl, MadeForKids, Tags, CategoryId, ContainsSyntheticMedia |
PinterestParams |
Format (Pin), Title, BoardId, DestinationLink, CoverUrl, ThumbOffset |
ThreadsParams |
Format (Post) |
TwitterParams |
Format (Post, Poll), PollOptions (2-4 choices, max 25 chars each; required for Poll), PollDurationMinutes (5-10080; required for Poll) |
BlueskyParams |
Format (Post) |
TelegramParams |
Format (Post), ChatId (required), ParseMode (Html, MarkdownV2), DisableLinkPreview, DisableNotification |
Supported platforms: Facebook, Instagram, TikTok, LinkedIn, YouTube, Twitter, Threads, Pinterest, Bluesky, Telegram, GoogleBusiness. Telegram requires a ChatId per post — list channels with client.Profiles.PlacementsAsync(profileId).
Google Business
Google Business posts use the GoogleBusiness property on PlatformParams, a Dictionary<string, object>. The location_id is the location resource path returned by client.Profiles.PlacementsAsync(). Supported formats: standard, event, offer. CTA actions: LEARN_MORE, BOOK, ORDER, SHOP, SIGN_UP, CALL. Media is limited to one image (≤5 MB).
var platforms = new PlatformParams
{
GoogleBusiness = new Dictionary<string, object>
{
["format"] = "standard",
["location_id"] = "accounts/123/locations/456",
["cta_action_type"] = "LEARN_MORE",
["cta_url"] = "https://example.com",
},
};
Instagram user tags
Tag public Instagram accounts in a post — feed post, reel, or story:
var platforms = new PlatformParams
{
Instagram = new InstagramParams
{
Format = InstagramFormat.Post,
UserTags =
[
new InstagramUserTag { Username = "natgeo", X = 0.5, Y = 0.4 }, // slide 0
new InstagramUserTag { Username = "nasa", X = 0.2, Y = 0.8, MediaIndex = 1 }, // slide 1
new InstagramUserTag { Username = "spacex", MediaIndex = 2 }, // video — username only
],
},
};
- Images require
XandY— floats0.0–1.0measured from the top-left corner. - Reels and video slides are tagged by username only; coordinates are ignored and dropped.
- Stories accept coordinates but don't need them.
MediaIndexpicks the carousel slide (0-based, defaults to0, video slides included).- A leading
@on a username is stripped for you.
Coordinates outside 0.0–1.0, a MediaIndex past the last media item, or an image tag
missing X/Y are rejected with a ValidationException naming the offending entry.
Accounts that are private or have tagging turned off are silently skipped by Instagram at
publish time.
Wrap them in PlatformParams when passing to Posts.CreateAsync().
Examples
Run examples from the repo root:
dotnet run --project examples -p:Example=CreatePost
dotnet run --project examples -p:Example=InitializeConnection
dotnet run --project examples -p:Example=PostStats
dotnet run --project examples -p:Example=ManageQueues
Replace the API key and profile group ID in the example files before running.
Development
dotnet build
dotnet test
License
MIT
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 8.0.2)
- Microsoft.Extensions.Http (>= 8.0.1)
- Microsoft.Extensions.Options (>= 8.0.2)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 1.13.0 | 92 | 8/17/2026 |
| 1.12.0 | 91 | 8/6/2026 |
| 1.11.0 | 103 | 7/14/2026 |
| 1.10.0 | 110 | 6/3/2026 |
| 1.9.0 | 108 | 5/15/2026 |
| 1.8.0 | 110 | 5/12/2026 |
| 1.6.0 | 109 | 4/20/2026 |
| 1.5.0 | 123 | 3/31/2026 |
| 1.4.0 | 120 | 3/19/2026 |
| 1.3.1 | 113 | 3/13/2026 |
| 1.3.0 | 122 | 3/10/2026 |
| 1.2.0 | 116 | 3/4/2026 |
| 1.1.0 | 121 | 2/24/2026 |
| 1.0.1 | 127 | 2/23/2026 |
| 1.0.0 | 117 | 2/23/2026 |