Skip to content

Changelog

Notable changes, newest first. Versions follow semantic versioning: while this is 0.x, a change to the middle number may break something.

0.9.0 - 2026-09-25

Added: the connected account's picture

  • Connection.avatar_url is the account's picture, filled in by finish_login (or resume_login, where you pick a page or channel). It defaults to None, so connections saved before 0.9.0, and code that builds a Connection without it, keep working. A missing, empty or garbled picture is None, never an error. with_token carries it over.
  • account.profile() asks the network for the account's current name and picture as an AccountProfile(name, avatar_url). One request, token renewed first like every other account call, and nothing saved - some networks' picture addresses expire (Facebook, Instagram), so this is how you get a fresh one. Networks that plug in do it through the new CanReadProfile.read_profile. Raises NotSupportedError where there is no picture to read.
  • socialchimp.picture_url(value) turns whatever a network sent into a clean http(s) address or None, for people writing their own platform.
  • FakePlatform(avatar_url=...) puts that picture on every connection it makes, and has read_profile.

Where the picture comes from:

Network At login profile()
Mastodon avatar from verify_credentials, already fetched same request
Bluesky avatar from one added app.bsky.actor.getProfile; if that request fails, sign-in still finishes with None getProfile
Facebook the Page's picture{url}, added to the page fields already asked for GET /{page-id}?fields=name,picture{url}
Instagram profile_picture_url, added to the account fields GET /{account-id}?fields=username,profile_picture_url
Threads threads_profile_picture_url, added to the /me fields GET /{account-id}?fields=username,threads_profile_picture_url
TikTok avatar_url, added to /v2/user/info/ fields (no new scope) same request
Pinterest profile_image from /v5/user_account, already fetched same request
YouTube the channel's snippet.thumbnails from the channel list already fetched; resume tokens from before 0.9.0 still work, with None GET /channels?part=snippet&id=...
X profile_image_url, added to /users/me as user.fields same request
TikTok Business None - login reads no identity at all NotSupportedError
Google Business None - no documented picture field NotSupportedError

0.8.0 - 2026-09-25

Added: read a post and its thread, reply to a comment, and like

  • account.read_post(post_id) reads any post or comment back in full - text, author, links, attachments, the counts, and whether it is a reply - as a PostDetails, not only what publishing it returned. One request on both networks: GET /api/v1/statuses/:id on Mastodon, app.bsky.feed.getPosts on Bluesky. Feature.READ_POST.
  • account.read_thread(post_id, *, depth=None, limit=None) reads a post together with its replies, flat and oldest first - build the tree yourself with parent_id. Thread.complete is False when depth, limit or the network's own cap cut it short. Mastodon: one request to GET /api/v1/statuses/:id plus one to /context (no pagination; up to 4096 replies signed in; a Mastodon-Async-Refresh header, meaning remote replies are still arriving, also sets complete=False). Bluesky: one request to app.bsky.feed.getPostThread (depth 6 by default, 1000 at most); a deleted or blocked reply comes back as a placeholder with unavailable set instead of vanishing. Feature.READ_THREAD.
  • account.reply(post_id, text, *, media=(), options=None) replies to any post or comment at any depth and hands back a PostResult - the recommended way to answer somebody now; publish(Post(reply_to=...)) keeps working too. One request to read the parent, plus whatever publish already costs. Mastodon: a reply to a direct or followers-only parent keeps that visibility, whatever options asks for; otherwise nothing is sent, so the account's own default applies. It also names the parent's author and anyone else the parent mentions, the way Mastodon's own web app does, skipping the connected account and anyone already named in text. Bluesky builds the reply's root and parent from the target with one lookup. Feature.REPLY_TO_COMMENTS.
  • account.like(post_id) and account.unlike(post_id, *, like_id=None) like and unlike a post or a comment. Both are idempotent: liking something twice, or unliking something not liked, succeeds and does nothing. Mastodon's favourite and unfavourite are already idempotent on the server - one request each. Bluesky's createRecord is not deduplicated by the network, so like() reads viewer.like first and hands back the existing like rather than making a second one; unlike() costs nothing extra when you pass like_id from LikeResult, or one lookup plus the delete when you do not. Feature.LIKE.
  • account.read_likes(post_id, *, after=None, limit=None) lists who liked a post, as a Page[Like]. Mastodon has no liked_at - always None - Bluesky does. A network that can like something but cannot list who did - a Facebook Page can like a comment but only ever sees the count
  • lists Feature.LIKE without this one. Feature.READ_LIKES.

Added: picking up updates where you left off

  • account.fetch_updates_after(marker, *, limit=None) and account.mark_seen(marker) poll with an opaque marker your app stores and passes back, rather than a moment in time - a moment can miss or repeat updates at the edges, a marker cannot. marker=None on the first call reads the latest page, which sets a starting point. A marker your app made up, or one from a different platform, raises ConfigError on both Mastodon and Bluesky, rather than silently starting over and risking a dropped update - pass None to start afresh instead. Mastodon: the marker is the newest notification id, fetched with min_id= (one request, pages forward with no gaps); mark_seen is POST /api/v1/markers. Bluesky: notifications only page backwards, so the marker is the newest indexedAt plus uri; the library pages back looking for it, up to five requests, and says more=True if it is still not found rather than holding a request open forever; mark_seen is app.bsky.notification.updateSeen. Feature.READ_UPDATES_AFTER. The existing fetch_updates(since) and Poller are unchanged.
  • Update gains five optional fields, all defaulting to None so existing code keeps working: actor (who did it), post_id (the thing that happened - the reply, the mention, the message itself), about_post_id (the connected account's own post this concerns), thread_root_id, and conversation_id (for MESSAGE_RECEIVED). A Bluesky quote's about_post_id is the quoted post, read from reasonSubject the same as a like or a repost - a quote's own record has no record.reply to read instead, because quoting is not replying.

Added: direct messages

  • account.read_conversations(*, after=None, limit=None), account.read_messages(conversation_id, *, after=None, limit=None), account.send_message(conversation_id, text, *, options=None) and account.mark_read(conversation_id) read and answer direct messages. Messages come back in Pages, newest first; after goes further back in time. Feature.MESSAGES.
  • account.start_conversation(person_ids, text) opens a new conversation. Meta cannot do this - the customer has to write first - so a platform can offer CanMessage without offering this one. Feature.START_CONVERSATIONS.
  • Mastodon: conversations are GET /api/v1/conversations, one request. Mastodon has no "every message in this conversation" call, so read_messages reads the /context of the conversation's last status and keeps only the direct-visibility statuses sent between this conversation's own participants - that is why Conversation.full_history is False, and why it never returns a next. send_message posts a direct status mentioning every participant, as a reply to the last one. start_conversation returns the real Mastodon conversation id once it can find one; in the rare case Mastodon has not listed it yet, it returns an opaque "status:<id>" marker instead, and send_message, read_messages and mark_read all accept that form too - treat it as opaque either way.
  • Bluesky: chat.bsky.convo.listConvos / getMessages / sendMessage / updateRead, plus getConvoForMembers for start_conversation, every one sent with the atproto-proxy header Bluesky's chat API needs. An app password made without "Allow access to your direct messages" raises MissingPermissionError(needs="direct messages") - a new app password has to be made with the box ticked, because an existing one cannot have it turned on afterwards.

Added: asking what a network supports

  • await account.features() and client.features(platform) answer with the Feature flags a network supports. The first looks the connection up lazily, so it is safe to call before deciding which button to show; the second takes a platform name and needs no connection, for deciding what to show before anyone has connected an account. New flags: READ_POST, READ_THREAD, REPLY_TO_COMMENTS, LIKE, READ_LIKES, READ_UPDATES_AFTER, MESSAGES, START_CONVERSATIONS (SUBSCRIBE_UPDATES is reserved for a later release - see the push note below). Each flag matches one protocol above, and the tests enforce that the two always agree, on every platform.

Added: new errors

  • MissingPermissionError(needs, suggestion=None), a NotAllowedError, names the one permission that is missing rather than only saying no - Bluesky's DM app-password gap raises it.
  • BlockedError, a NotAllowedError - the other person blocked us, or we blocked them; nothing to retry until a person undoes it on the network itself.
  • ReplyWindowClosedError(closed_at=None), a NotAllowedError - reserved for Meta's 24-hour reply window, in a later release.
  • PostGoneError, a NotFoundError - the post or comment asked for was deleted, or never existed, more precise than a plain NotFoundError for the one thing an app asks for by id constantly: a post to reply to, to like, to read the thread of.

All four subclass an error that already existed, so an except NotAllowedError or except NotFoundError written before 0.8.0 still catches them.

Added: FakePlatform for testing apps

  • FakePlatform now implements every social-inbox protocol above, with nothing but socialchimp itself - no pytest needed. add_post and add_reply seed what read_post and read_thread hand back, add_like seeds read_likes, add_update seeds fetch_updates_after, and add_conversation seeds direct messages.
  • fetch_updates_after(None) returns the latest page - limit, or page_size when that is left out - the same as a real platform's first call, rather than every update ever queued.
  • An unrecognised marker raises ConfigError, on fetch_updates_after and mark_seen alike, matching Bluesky.

Push is not in 0.8.0. Mastodon and Bluesky are both polled with fetch_updates_after, the same as fetch_updates always has been. Push delivery - Mastodon Web Push and Meta's webhooks - is planned for a later release, built together, because both need the same Update-shaped payload and lifecycle handling. push is already in Mastodon's DEFAULT_SCOPES (see Changed, below) so a connection made today is ready for it without anyone having to reconnect.

Changed

  • Mastodon and Bluesky reposts now arrive as UpdateKind.REPOST_ADDED, not REACTION_ADDED. A handler that only checked REACTION_ADDED for "something happened to my post" now needs REPOST_ADDED too.
  • A follow now arrives as UpdateKind.FOLLOWED, not UNKNOWN.
  • Mastodon's mention notification is classified more precisely. A direct-visibility status is checked first and always comes out as MESSAGE_RECEIVED, even when it also replies to the connected account. Otherwise, a reply where in_reply_to_account_id is the connected account comes out as COMMENT_CREATED (about_post_id is in_reply_to_id); anything else stays MENTION.
  • Mastodon's DEFAULT_SCOPES widen to ("read", "write", "push"), so a freshly connected account already has the scope Web Push will need. Nothing already working is affected - "read write" still covers everything except Web Push.
  • Rate-limit waits also read X-RateLimit-Reset (Mastodon) and RateLimit-Reset (Bluesky) when Retry-After is missing, so RateLimitError.retry_after is filled in more often than before.

0.7.3 - unreleased

Added: reply to a Thread, read its replies, and read a post's numbers

  • account.post(Post(text=..., reply_to=post_id)) now works on Threads: reply_to_id goes on the top-level container - the whole post, or a carousel's parent, never one of its pieces - and it is checked against the 1,000-a-day reply allowance rather than the 250-a-day post one, so answering somebody never spends one of your 250 posts. Feature.REPLY is on. Read this first: Threads only lets you reply where you own the root post, unless the app also holds threads_manage_mentions or threads_keyword_search - neither is in the default scopes, so add one to scopes at sign-in to answer a mention on somebody else's post.
  • account.read_replies(post_id) is new, on every platform through socialchimp.platform.CanReadReplies: the top-level replies to one post, or every depth flattened with whole_conversation=True. Threads is the first to implement it.
  • account.fetch_updates() now works on Threads: the replies on the account's latest posts, as UpdateKind.COMMENT_CREATED. Same update.id and update.raw as the replies webhook and as read_replies, so one handler and one SeenUpdates serve all three. Reads the latest 25 posts by default, ThreadsPlatform(recent_posts=...) changes that between 1 and 100; costs 1 + recent_posts requests at most, every poll.
  • account.reply_to_update(update, text) now works on Threads, for a replies or a mentions update - it publishes a reply through post itself, so the allowance check and the waiting both happen. Anything else is refused by name.
  • account.set_comment_visibility(update, hidden=True) now works on Threads: POST /{id}/manage_reply. Meta says it only works on a top-level reply, and hiding one hides whatever was said back to it along with it. account.delete_comment(update) always refuses on Threads - there is no call for removing somebody else's reply, only for hiding it.
  • account.read_stats(post_id) now works on Threads: likes, replies as comments, and reposts as shares, from one request to GET /{post_id}/insights. views, quotes and the share button's own count are not modelled on PostStats; they are still on raw. Feature.READ_STATS is on.

Not confirmed against a live account yet. The reply, read_replies, fetch_updates and insights shapes here are built from Meta's own published reference pages rather than watched against a real response - see the links in src/socialchimp/platforms/threads.py and in docs/platforms.md's Threads section.

0.7.2 - 2026-09-22

Added: read comments and likes on a Facebook Page

  • account.read_stats(post_id) now works on Facebook: likes (every kind of reaction added together), comments and shares, in one small request. A video's shares is None rather than a made-up zero.
  • account.fetch_updates() now works on Facebook: the comments on the page's latest posts, as UpdateKind.COMMENT_CREATED. update.raw and update.id match what the webhook produces, so one handler and one SeenUpdates serve both. It reads the latest 25 posts, one request each; FacebookPlatform(recent_posts=...) changes that, between 1 and 100.
  • Read this first: Facebook now asks for pages_read_user_content. It is added to the default scopes, because reading what other people wrote on a Page needs it. It is a permission Meta reviews, so add it to your app review. Anybody who connected a Page before this has to connect again to grant it; their posting keeps working in the meantime.
  • socialchimp.platforms._meta.read_edge, one page of any list Meta keeps and the cursor for the next, for Instagram and Threads to use next.

Not confirmed against a live Page yet. Meta's own reference pages could not be read in full when this was written. The comment and reaction endpoints, filter, order and the comment field names are confirmed from Meta's SDK source. Not confirmed: that pages_read_user_content is what gates other people's comments, the exact shape of the counts reply (including that a post nobody shared has no shares), the timestamp format on a comment, that limit and after page the comments edge as they do elsewhere on the Graph API, and the limit(0).summary(true) form used to ask for counts alone. The code reads defensively and keeps the whole reply on raw, and examples/facebook_django/page_live.py now prints both so you can check them against your own Page.

0.7.1 - unreleased

Fixed: Instagram published a picture before it was ready

A single picture, one piece of a carousel, or a carousel of pictures was published the instant Instagram answered the request that started it, on the assumption that only video needs waiting for. It does not hold: a picture was published 0.27 seconds after its container was made and Instagram refused with error 9007 ("Media ID is not available"). Only video was ever checked.

  • Every container is checked before it is published - pictures, each piece of a carousel, and the carousel itself. The first look is immediate, so a container that is already ready costs one request and no waiting. After that a picture is looked at again after 1, 2, 4... seconds, never more than 30 apart. Video is unchanged: once a minute, up to five minutes (check_every_seconds and wait_up_to_seconds).
  • Error 9007 (subcode 2207027) is a RateLimitError, with retry_after=30, instead of an unnamed PlatformError. It is the existing "wait, then try again" error, so an app that already treats RateLimitError as transient needs no change. Nothing was published when this comes back, so the same post can be sent again. publish also asks again twice on its own, five seconds apart, before it raises it. No other refusal is asked again, including an ordinary RateLimitError.
  • Error 36003 ("The aspect ratio is not supported") is an InvalidPostError saying feed pictures have to be between 4:5 and 1.91:1.
  • A picture whose status reply has no status_code at all is published rather than waited on for five minutes. Meta's guide does not say a picture has one; the retry above covers it if it turns out not to be ready.

Fixed: the same assumption, on Threads

Threads made the identical assumption for the identical reason - a container answers the request that starts it long before Threads may be finished with it, and only video was ever checked. The container shape is the same as Instagram's, so it is fixed the same way:

  • Every container is checked before it is published - words, a picture, each piece of a carousel, and the carousel itself. The first look is immediate, so a container that is already ready costs one request and no waiting. After that, anything that is not video is looked at again after 1, 2, 4... seconds, never more than 30 apart. Video is unchanged: every 30 seconds, up to five minutes.
  • A non-video container whose reply has no status at all is published rather than waited on for five minutes, the same reasoning as Instagram's picture.

Not done for Threads, and this is worth reading before you rely on it. Nothing here names a Threads "not ready" error the way Instagram's 9007 is named. Two rounds of research looked for one: the first found a number (code 24, subcode 4279009) from unofficial, non-Meta repositories; the second could not confirm that number anywhere in Meta's own documentation, called it likely wrong, and could not find any Threads-documented code for this at all. Rather than name something unverified in a released library, _put_it_out here has no retry, and a "not ready" reply - if Threads sends one - surfaces as a plain PlatformError with the whole reply on it, the same as any code socialchimp has no better name for yet. The polling above should make this rare in practice, the same as it did for Instagram, but it is not closed off the way Instagram's is. If you see one, the error's raw has exactly what Threads said - that is what would let this be named properly.

Likewise, no numeric code is named here for an unsupported aspect ratio on Threads (Instagram's 36003); Meta's own error list gives Threads' aspect ratio problem a name (INVALID_ASPEC_RATIO, sic) but no number, and it comes back already readable, on error_message, through the existing "Threads gave up" message when it is caught by polling.

0.7.0 - 2026-09-18

Added: TikTok Business

The eleventh network, and the first one that cannot post at all. tiktok publishes video; tiktok_business reads and answers the comments that show up underneath it once it is live - a separate TikTok product, its own app, its own sign-in, and a token exchanged through TikTok's own OAuth flow rather than Login Kit's. See docs/platforms.md for the whole of it, including which parts of this are confirmed against TikTok's own SDK source and which are best-effort because TikTok never documented them.

  • CanModerateComments, in socialchimp.platform. fetch_updates and reply_to_update already covered reading and answering a comment; nothing covered taking one down or hiding it. await account.delete_comment(update) removes one outright; await account.set_comment_visibility(update, hidden=True) hides or shows one again. TikTok Business is the first network to need either.
  • Comments arrive as Updates, the same shape a webhook would hand you - fetch_updates polls TikTok's comment/list and hands back UpdateKind.COMMENT_CREATED, with the untouched comment on update.raw so nothing is lost even where TikTok's own field names had to be guessed at.
  • Read-only for posting. tiktok_business declares no Feature.* flags; post() refuses by name every time. Publish through tiktok instead.
  • What is deliberately not here: video-level stats (business/video/list is not in TikTok's own SDK, and nothing here claims a number it cannot back with a source) and a confirmed refresh endpoint (TikTok's docs say a token needs renewing daily; its SDK documents no call that does it).

0.6.0 - 2026-09-18

Added: Google Business Profile

The tenth network, and the first one that is a place rather than a feed. Signing in gets you a location - one business's listing - and posting is the smallest part of what there is to do with it: a location also has a name, a phone number, an address, a category, and a verification process with nothing to do with signing in at all. See docs/platforms.md for the whole of it; the shape of it is worth knowing even if you never touch this network, because three genuinely new things were added to the platform contract to carry it.

  • CanReplyToUpdates, in socialchimp.platform. A review or a question is not a post, so there was nothing to answer one with. await account.reply_to_update(update, "Thank you!") answers a review or a question - reply_to_update reads update.kind to work out which.
  • CanEditBusinessInfo. A location's name, phone, address and category are not a post either. await account.get_location() reads them back; await account.update_location({"title": "New Name"}) changes only the fields named, the way a field mask does. New model: BusinessLocation.
  • CanManageVerification. Google will not show a location fully, or let every field be edited, until it is verified - and verifying it is a process with its own steps. await account.verification_options() lists the ways; await account.start_verification(method) makes Google act - mail a postcard, place a call, send a text or an email; await account.complete_verification(id, pin) finishes it with the code the business owner was sent. socialchimp never sees that code otherwise. New models: Verification, VerificationOption.
  • Four new UpdateKind values: REVIEW_CREATED, REVIEW_UPDATED, QUESTION_CREATED, ANSWER_CREATED.
  • Pub/Sub, not a plain webhook. Every other pushing network here signs a request with a shared secret checked by plain HMAC, entirely offline. Google's Pub/Sub instead signs with a Google-issued OIDC token whose signature can only really be checked against Google's own rotating public keys - fetching them on every check would put a network call inside what is supposed to be a cheap, offline one. So on this platform only, secret is not a password: it is {"keys": [...Google's public keys as JWKs...], "audience": "...", "service_account": "...@gcp-sa-pubsub.iam.gserviceaccount.com"}, a small JSON document your app keeps refreshed and hands over on every check. The audience alone is not proof of anything - Google will sign a token for any audience a caller names - so check_signature also checks the token's email claim against the one Pub/Sub service account your subscription authenticates as, the way Google's own push documentation says to. check_signature and read_updates still take the same arguments as every other platform's; only what secret holds is different here, and that is documented on GoogleBusinessPlatform itself. fetch_updates also works, polling reviews and questions on a timer, for an app that cannot receive a push at all.

None of this changes anything for the nine networks already here. All three extras are optional - discovered the same way CanCheckState and CanReadUpdates already were, by isinstance rather than a Feature flag - so a platform written against 0.1.0 needed no changes for any of them, which is exactly what the promise about changes says should happen.

What Google Business Profile deliberately does not do here. No read_stats - Google's Performance API reports how the location is doing in search and Maps, not how one post did, and there is no honest number to hand back for a post id. No scheduling, and no check_state - a post is live by the time Google answers, and only a rejected one is reported as anything other than done. No event or offer posts - only ordinary ones; asking for either is refused by name rather than silently becoming something else.

Before any of this works, Google's Business Profile API access is a separate, manual approval on top of the OAuth client - it can take weeks, and an unapproved project's quota on these APIs is zero whatever the OAuth client says. Begin it early. See docs/platforms.md.

0.5.0 - 2026-09-17

Read this first: Instagram no longer signs in through Facebook

Instagram's adapter now implements Meta's other, separate product - "Business Login for Instagram" - exclusively. There is no Facebook Page anywhere in it any more, and it is not backwards compatible with the old flow. If you have people already connected the old way, keep reading before you upgrade.

  • A different, separate app id. Facebook Login for Instagram used your Facebook App ID and Secret. This flow needs an Instagram App ID and Instagram App Secret instead, from the "Instagram > API setup with Instagram login" section of the Meta App Dashboard - a different section of the same dashboard, not the same pair. Add the product to your app, save the new pair with Storage.save_app under the platform name instagram, and update INSTAGRAM_APP_ID/INSTAGRAM_APP_SECRET wherever you keep them. Using the old Facebook pair here gets past the sign-in page and then fails at the token swap with a message that mentions none of this.
  • Sign-in is Instagram's own page, instagram.com/oauth/authorize, not Facebook's dialog. Every request after sign-in goes to graph.instagram.com, not graph.facebook.com - api_base reflects this.
  • finish_login no longer answers ChooseAccount. Signing in directly as an Instagram account has nothing to choose between, so it goes straight to Finished, the same shape as ThreadsPlatform. InstagramPlatform no longer implements CanResumeLogin, and resume_login is gone.
  • The scopes changed. pages_show_list and business_management are gone - there is no Facebook Page to find an account through any more. instagram_business_manage_messages was added. See DEFAULT_SCOPES.
  • Connection.extra no longer carries page_id or page_name. There is no Page in this flow, so there is nothing to put there.
  • Refresh is real now. The old flow had no refresh token and extended a token by trading it in; this one has a genuine refresh endpoint, the same shape as ThreadsPlatform.refresh. socialchimp calls it once a token has thirty days or less left, and does nothing before that.
  • A personal Instagram account still cannot sign in at all - that has not changed - but the account no longer needs a Facebook Page linked to it either. Only that it be a Business or Creator account.

Publishing itself did not change: the same container-then-publish shape, the same carousel, caption and hashtag limits, the same daily allowance, the same error codes for a file Instagram could not fetch or a video in the wrong format. That part belongs to Instagram's API, not to whichever login got you a token.

What to do about people already connected. A Connection saved under the old flow carries a Facebook Page token, which this code no longer knows what to do with - Connection.extra["instagram_id"] still resolves to the right account, but publishing and refreshing both now expect a token this flow issued, and a Page token was never that. Send everyone through start_login again once you are on 0.5.0; there is no in-place migration, because the two flows use different apps.

0.4.0 - 2026-09-14

Added

  • Mastodon can tell you how a post is doing. await account.read_stats(post_id) reads a published status back and hands you a PostStats: its replies, favourites and boosts, under socialchimp's own names for them - comments, likes and shares. One request, to GET /api/v1/statuses/:id.

A number a server does not send - an older one, or a fork - comes back as None, which is not the same as 0. Reach, impressions and clicks have no field at all, because Mastodon publishes none of them and a field that can never be filled in reads like one that is always zero.

Mastodon now lists Feature.READ_STATS, which it did not before. Code that checks that flag before calling gets True where it used to get False. Nothing that already worked behaves differently - the flag was false because there was no method behind it, and now there is one.

New public names: PostStats from socialchimp, CanReadStats from socialchimp.platform, and Account.read_stats. Every other network still leaves READ_STATS off, and account.read_stats refuses there with a NotSupportedError naming the network rather than returning empty numbers.

If you write your own platform: listing Feature.READ_STATS now means you must have read_stats to back it up, and PlatformChecks checks that, the same as it already did for CREATE_APP and DELETE_POST. A platform that does not list the flag is unaffected.

0.3.1 - 2026-08-31

Fixed

  • socialchimp.testing no longer imports pytest. from socialchimp.testing import FakePlatform raised ModuleNotFoundError: No module named 'pytest' on an install without the testing extra, because the import sat at the top of the module. Only PlatformChecks ever wanted it. FakePlatform, RecordingStorage, RecordingTransport and StorageCall are for building an app as much as for testing one - the sample projects in examples/ build a whole app against FakePlatform and nothing else - so a fake social network no longer asks for a test framework.

pytest is now imported the first time a check fails or skips, and subclassing PlatformChecks without it raises a ConfigError naming pip install "socialchimp[testing]" rather than a bare ModuleNotFoundError. Nothing changes for anyone who has pytest, and the extra still installs it.

0.3.0 - 2026-08-31

Read this first: Dispatcher.deliver raises now, and stops losing updates

Dispatcher.deliver used to log a handler that raised and then carry on as though nothing had happened - including writing the update down as handled. So when every handler failed, the update was still recorded as done, the network's retry was skipped by the seen check, and the update was gone. A log line was all that was left of it.

It still runs every handler, and one that raises still does not stop the rest. What changed is what happens afterwards:

  • The update is remembered as handled only if every handler succeeded. If any raised, nothing is written down, so the network's retry is a real second chance instead of something the seen check throws away.
  • The failures come back to you, as an ExceptionGroup holding what each failed handler raised.

It is always a group, even when only one handler failed. That way there is one shape to catch, and registering a second handler tomorrow does not change what your code has to catch today.

# Before - this could not raise, so there was nothing to write.
await dispatcher.deliver(update)

# Now, if you want to carry on regardless.
try:
    await dispatcher.deliver(update)
except* Exception:
    logger.exception("a handler for %s failed", update.id)

What to change. If you call deliver yourself and relied on it never raising, decide what should happen and write it, as above.

If you use the Django, FastAPI or Flask routes there is nothing to change, but the behaviour is different on purpose: a webhook whose handlers all failed no longer answers 200 {"ok": true}. The group goes up, your framework answers 500, and the network sends the update again - to a dispatcher that did not write it down as handled either. See handlers that fail.

Why. socialchimp raises, your app handles. A handler failing is your code failing, and only your app knows whether that deserves a log line, an alert, or a row in a table for a worker to retry tonight. The old docstring even defended the ordering by saying that a crash gives the network a second chance - while the code underneath made sure that crash never happened.

Webhook routes: no silent drops, and set-up mistakes are not 500s

Two changes to socialchimp.contrib.shared.Routes, and so to the Django, FastAPI and Flask helpers built on it.

Routes with webhook secrets and no deliver is refused when it is built. It used to be accepted, and then every properly signed update was logged, thrown away, and answered 200 {"ok": true} - so the network believed it had been handled and never sent it again. That is knowable at set-up time, so it is now a ConfigError from Routes(...), next to the one for a missing webhook secret. Routes that only sign people in are unaffected: no secrets means no webhooks, so there is nothing to hand on.

# Refused now.
Routes(sc, redirect_uri=..., secrets={"facebook": SECRET})

# Hand updates somewhere,
Routes(
    sc,
    redirect_uri=...,
    secrets={"facebook": SECRET},
    deliver=dispatcher.deliver,
)


# or, while you are only getting the URL verified, say so out loud.
async def note_it(update: Update) -> None:
    logger.info("dropping %s for now", update.id)

ConfigError is raised rather than answered. Every Routes method used to catch it two lines from where it was raised and turn it into a 500 with a JSON body, on every request, for ever. A missing webhook secret, a missing setup token, an app that was never registered with the network - these are mistakes to fix, not conditions to retry, and they now reach your own error handling instead of being reported like a network problem.

Everything else is unchanged: whatever a network said no to, and whatever was wrong with the request itself, still becomes a Reply with the status it deserves.

What to change. If you have a test asserting a 500 and a JSON body for one of these, it should expect a ConfigError now. If you wrap these routes in something of your own and would rather answer than raise, status_for and Reply.for_error still map ConfigError to 500 - it is one except ConfigError away.

Fixed: Facebook said PROCESSING and gave you no way to ask

FacebookPlatform.publish has always answered a video with PostState.PROCESSING, because Facebook takes the bytes and carries on encoding after it replies. But FacebookPlatform had no check_state, so account.check_state(post_id) raised NotSupportedError — the state said "ask again later" and there was nothing to ask.

There is now a check_state here, the same shape as YouTube's and TikTok's:

result = await account.post(Post(text="Watch this", media=(clip,)))
if result.state is PostState.PROCESSING:
    later = await account.check_state(result.id)

It reads the video's status — one field, one cheap request — and maps Facebook's three words onto ours: ready is DONE, processing is PROCESSING, error is FAILED. Anything else, including a word Meta adds next year, comes back PROCESSING, so an unfamiliar answer means "ask again" rather than "it is live". The address on the result is the one publish gave, so results from either can be treated the same way.

Only a video needs it. Words and pictures are on the page the moment publish returns, and asking about one of those raises a PlatformError that says so.

Instagram and Threads do not need the same thing and have not gained it. Both publish in two steps, and both do their waiting inside publish — they poll the container until Meta says FINISHED and only then publish it. So neither ever hands back PostState.PROCESSING, and a check_state there would have nothing to answer.

Fixed: Media.alt_text was thrown away on X

Media.alt_text reached Bluesky, Facebook, Instagram, Mastodon and Threads, and X dropped it on the floor — silently, with no error and nothing in the result to notice it by. An app that sets alt text everywhere was publishing pictures on X that a screen reader could not describe.

X does support it; it is just a request of its own, because the upload has nowhere to carry a description. XPlatform now sends one:

Post(media=(Media.from_file("shop.jpg", alt_text="A shop front"),))
# ... POST /2/media/metadata  {"id": ..., "metadata": {"alt_text": {"text": ...}}}

It goes out after the file is finalised — and, for video, after X has finished encoding it — and always before the file is named on a post, because X will not take a description for one that is already published. A file with no alt_text sends nothing extra, so nothing costs a request that did not before.

Nothing to change in your code. If you were already setting alt_text, it now arrives.

The two networks that still do not take it are honest about why, and docs/platforms.md now says so in one place: Pinterest hangs alt text off the whole pin rather than off one picture, so it stays options={"alt_text": ...} there; YouTube and TikTok take video only, and neither has alt text for a video.

Documented: which networks stream a video, and which read it whole

docs/platforms.md said that YouTube, TikTok and X send a video in pieces, and said nothing about the two that do not. Facebook and Pinterest read the whole file into memory first — Facebook's chunked upload is not written yet, and Pinterest hands out one upload form for one request — so a video there really does cost its own size in memory on your own server. That is now written down next to the claims about the ones that stream. No behaviour changed; Facebook's biggest_video_bytes was always the lever, and now the page says what it is for.

Read this first: post_to_many is gone. Write the loop yourself.

SocialChimp.post_to_many, PostJob and PostError have been removed. There is no replacement call, because posting to several accounts is now your loop.

# Before.
job = await sc.post_to_many([mastodon_id, bluesky_id], Post(text="Hi"))

for result in job.succeeded:
    print("posted:", result.url)
for failure in job.failed:
    print("failed:", failure.connection_id, failure.error)

# Now.
from socialchimp import SocialChimpError

for connection_id in (mastodon_id, bluesky_id):
    try:
        result = await sc.account(connection_id).post(Post(text="Hi"))
        print("posted:", result.url)
    except SocialChimpError as refused:
        print("failed:", connection_id, refused)

What to change.

  • await sc.post_to_many(ids, post) becomes a for loop over ids calling await sc.account(connection_id).post(post).
  • job.succeeded becomes whatever you append a PostResult to in the loop.
  • job.failed becomes your except block. Catch SocialChimpError for everything socialchimp raises, or something narrower - RateLimitError, AuthError, NotSupportedError - to treat one kind differently.
  • post_to_many(..., options_per_platform={"youtube": {...}}) has no replacement argument. Build the post each network needs inside your loop with dataclasses.replace(post, options={**post.options, **extra}).
  • Anywhere you imported PostJob or PostError, delete the import. The error types you catch - SocialChimpError and everything under it - are unchanged, and they are the part that was doing the work.

Why. socialchimp raises and stops; your app catches and decides. Whether one network refusing should stop the others, whether the failure belongs in a row for a worker to retry tonight, whether somebody needs telling - those are answers only your app has. post_to_many had to pick one of them for you, and "write it down and carry on" is the wrong answer often enough to matter. The loop is four lines and it is honest about who is deciding.

Nothing else changed. Retries, rate-limit handling and token renewal are exactly as they were, and every error type is where it was.

There is a runnable version, with one network refusing and the app carrying on, in examples/post_to_each.py.

Fixed: Bluesky would not start a login without credentials it cannot have

sc.start_login("bluesky", redirect_uri="unused") raised ConfigError saying no app credentials were stored. Bluesky has no developer portal and no app — a person signs in with their handle and an app password they made themselves — so there was nothing to store, and no way to make the message come true. The example in getting started raised on the line it was printed on, and apps worked around it by saving a placeholder id and secret that nothing ever read.

There is a new feature flag, and Bluesky lists it:

Feature.NEEDS_NO_APP in BlueskyPlatform.features  # True

Where it is on, start_login, finish_login and choose ask your storage for nothing and hand the platform a LoginRequest with app=None. Where it is off — every other network, Mastodon included, because Mastodon registers a real app for you and the sign-in needs it — a sign-in with none saved is refused exactly as before, naming Storage.save_app.

sc.create_app("bluesky", ...) was telling the same untruth from the other side, sending people to a developer portal that does not exist. It now says there is no app to register and that start_login works with nothing saved.

What to change. Delete any placeholder credentials you saved for Bluesky; nothing reads them. If you wrote your own platform for a network with no app of its own, add Feature.NEEDS_NO_APP to its features — see adding a platform. A platform that does not list it behaves exactly as it did.

Fixed: every FakePlatform connection had the same id

FakePlatform.connection() handed back "fake-connection" whatever the fake was called, so an app testing across nine fake networks got nine connections sharing one primary key — and the docs tell people to match pushed updates on that id. The default is now the network's name and the account's id joined by a colon, which is what every real platform does:

FakePlatform(name="bluesky").connection().id  # "bluesky:42"
FakePlatform().connection(account_id="7").id  # "fake:7"
FakePlatform().connection(connection_id="mine").id  # "mine", as before

What to change. A test that expects the literal "fake-connection" should expect "fake:42", or pass connection_id="fake-connection" to keep the old one.

Added: FakePlatform answers Meta's setup check

Testing your hub.challenge route against a fake meant subclassing FakePlatform and calling socialchimp.events.answer_setup_check yourself. The fake now has an answer_setup_check of its own, the same function underneath that Facebook, Instagram and Threads use:

sc = SocialChimp(storage=storage, platforms={"fake": FakePlatform()})
challenge = sc.answer_setup_check(
    "fake",
    {"hub.mode": "subscribe", "hub.verify_token": TOKEN, "hub.challenge": "1158201444"},
    verify_token=TOKEN,
)

FakePlatform(answers_setup_checks=False) leaves it off entirely, so the fake is not a CanAnswerSetupCheck and sc.answer_setup_check refuses against it — the way it refuses against TikTok, which pushes without asking anything first. That is the same knob-decides-the-ability pattern accounts uses for resume_login and states uses for check_state.

Fixed: the refusal for a network that never pushes said something untrue

SocialChimp.answer_setup_check refused with one fixed sentence — "It starts sending as soon as you point it at a URL" — whoever asked. That is right for TikTok. For Pinterest, which never pushes anything to a URL of yours, it describes something that will not happen and leaves somebody waiting for it.

The message now depends on what the network can actually do. A network that pushes but asks nothing first is told what it was told before. A network with no Feature.PUSH_UPDATES is told that it never pushes and pointed at Account.fetch_updates and socialchimp.events.Poller, which is the same answer check_signature already gave for the same networks.

Read this first: SocialChimpError really does catch everything now

The rule is that socialchimp raises and your app handles, and the whole of that rests on one line in the docs: catch SocialChimpError and you have caught everything socialchimp reports. It was not true. Every refusal in socialchimp.models came out as a bare ValueError and walked straight past except SocialChimpError:

friday = datetime(2026, 9, 4, 9, 0)  # no timezone on it

Post()  # neither text nor media
Post(text="hi", publish_at=friday)  # a time with no timezone
Token(access_token="abc", expires_at=friday)  # and again
Media.from_bytes(b"...", filename="cat.xyz")  # an ending we do not know
Media.from_url("https://...").read()  # no bytes to read yet
Media.from_url("https://...").piece(0, 1)  # nor here

The first four are the everyday ones. The last two are the ones that bit: a Media.from_url handed to a network that will not fetch it crashed the app rather than being reported like every other bad post.

What they raise now. A bad post or a picture we cannot read is an InvalidPostError. A datetime with no timezone is a ConfigError - it is a mistake in your code rather than a post any network would refuse, and the same check guards a token's expiry and an update's timestamp, where "post" means nothing.

Nothing you have written stops working. ConfigError and InvalidPostError are now a ValueError as well as a SocialChimpError, so an app that noticed the old behaviour and caught ValueError catches these exactly as before. Being both is unusual, and there is a comment in socialchimp/errors.py saying why: these are the two raised for a value your code handed us, and they are the two that used to be a plain ValueError.

What to change. Nothing, unless you would rather catch one thing than two - in which case delete the except ValueError you wrote to work around this and let except SocialChimpError do it.

No built-in network was relying on the old behaviour, and none of their messages has changed. Bluesky, Mastodon, TikTok, X and YouTube each refuse a Media.from_url before reading it, naming themselves and saying to download the file first; Facebook and Instagram fetch the address themselves; and Pinterest fetches a picture and refuses a video the same way. Those are still the messages you will see. What changed is the answer underneath them - which is what a platform written by somebody else, or a Media.read() of your own, runs into.

Added: PlatformChecks holds you to Feature.NEEDS_NO_APP

Feature.NEEDS_NO_APP arrived earlier in this release for Bluesky, which has no developer portal and no app. A platform claiming the flag and then refusing a sign-in without credentials would be the worst of both: nothing to save, and a login that will not start, with a message telling somebody to save credentials that do not exist.

There is a check for it now, so a platform published by anyone inherits it:

class TestMyPlatform(PlatformChecks):
    def make_platform(self) -> Platform:
        return MyPlatform()

It starts a login with LoginRequest.app as None and fails if the platform refuses. A platform that does not list the flag skips it, and a network that really does need credentials is refused without them exactly as before.

0.2.0 - 2026-08-31

Read this first: Update.raw from Facebook, Instagram and Threads changed

Update.raw is now the one thing that happened, not the message it arrived in.

One message from Meta holds a list of pages, and under each page a list of changes. read_updates has always given you one Update per change - but it put the whole page entry on every one of them, so a handler could not tell which change its update was about and had to go looking for it again:

# Before - and it had to be written this way, because raw was the entry.
for change in update.raw.get("changes", []):
    value = change.get("value", {})
    if value.get("item") != "comment":
        continue
    print(value.get("message"))

# Now.
print(update.raw.get("message"))

What to change. Anywhere you read update.raw on a Facebook, Instagram or Threads update:

  • update.raw["changes"][n]["value"][k] becomes update.raw[k].
  • Threads' update.raw["values"]["value"][k] becomes update.raw[k].
  • The page id and the time that used to be on raw are on the new update.envelope, which holds the entry the change arrived in. So update.raw["id"] becomes update.envelope["id"], and update.raw["time"] becomes update.envelope["time"].

Nothing is lost - envelope keeps everything raw used to hold - and nothing else changed. Update.raw on TikTok, X, Mastodon, Bluesky and YouTube was already the thing that happened and is untouched.

This is a break in a minor release, and it is deliberate. The old shape made every Meta handler wrong in the same way: it looped, it re-filtered, and if a busy moment put two comments in one message it printed both of them twice.

Added

  • Account.check_state(post_id). YouTube and TikTok keep working after they accept a post, so a PostResult that came back PROCESSING is not the end of it. This asks how far they have got, renewing the token first the way every other call on an Account does. Networks that finish before they answer raise NotSupportedError naming themselves.
  • Account.fetch_updates(since=None). The same for networks that have to be asked what has happened. Hand it to Poller and it runs on a timer.

Both of these were reachable before only by building the platform yourself and passing it to SocialChimp(platforms=...), because platform_for hands back the Platform protocol and neither method is on it. That works and still works; it is no longer the only way. Both also close a seam: they take a post id and a marker, where the platform methods behind them take a Connection, which is not a thing an app was holding. - SocialChimp.answer_setup_check(platform, params, verify_token=...), SocialChimp.check_signature(platform, body, headers, secret=...) and SocialChimp.read_updates(platform, body). The same three calls for the requests a network pushes to you. They take the network's name rather than going through Account, the way start_login does, because a pushed request arrives before you know whose account it concerns - read_updates is what tells you that. All three are plain functions, so a synchronous Django view calls them without a bridge. - CanCheckState, CanAnswerSetupCheck and CanReadPushedUpdates in socialchimp.platform, beside CanResumeLogin. These say the exact shape of check_state, answer_setup_check and read_updates, and they are what the calls above look for. - Update.envelope. The message an update arrived in, where a network wraps things up. Empty everywhere else. - TikTokPlatform.read_updates(body). TikTok sends one event per message, so it is always a list of one - it is there so SocialChimp.read_updates reaches every network that pushes. - PlatformChecks now checks check_state. If your platform has one, it must be async def check_state(self, connection, post_id), because that is how Account.check_state calls it. - testing.FakePlatform takes states= - what check_state says, one call after another, with the last repeating. Leave it out and the fake has no check_state at all, the same as most networks. It also has a read_updates now, so a fake standing in for a pushing network works with SocialChimp.read_updates. - testing.FakePlatform built with accounts now satisfies CanResumeLogin. Before, a fake with accounts to choose between still had no resume_login, so calling sc.choose(...) against it raised NotSupportedError even though finish_login had just answered ChooseAccount. It now carries a real resume_login, the same as Facebook, Instagram and YouTube do, so choose() succeeds. If your own tests relied on that NotSupportedError to prove your app's error handling worked, they will now see the login finish instead.

If you wrote your own platform

Nothing here forces a change. Platform is untouched, and so are CanCreateApp, CanResumeLogin, CanDeletePosts, CanReadUpdates and CanCheckSignature.

Two things are worth doing anyway:

  • If your network keeps working after it accepts a post, name the method check_state and give it (connection, post_id). Account.check_state then finds it, and PlatformChecks will tell you if the shape is wrong.
  • If your network pushes and you only wrote read_update, add a read_updates(body) -> list[Update]. Without one, SocialChimp .read_updates refuses with a message saying exactly that, because read_update hands back the first change and drops the rest.

0.1.0 - 2026-08-31

The first release. Nine networks work end to end.

Networks

Mastodon, Bluesky, Facebook Pages, Instagram, YouTube, TikTok, X, Pinterest, Threads.

Each one signs people in, keeps their token working, posts, and reports what happened. What a network cannot do it says so, rather than approximating: Bluesky has no scheduling, YouTube has no post of words alone, Pinterest has no comments, Instagram cannot take an upload and needs a web address.

What is in it

  • One way to work with every network, and direct access to any of them when the shared way is not enough. Direct access still renews your token, retries, and respects rate limits - only the request is yours.
  • Your app keeps its own database. No models, no migrations. Five methods in a class you write.
  • Tokens renewed before they run out, under a lock so two workers cannot renew at once. That matters on Bluesky, Pinterest and TikTok, which replace the refresh token every time it is used: without the lock, whichever worker loses is left holding a token the network has already thrown away, and that account is disconnected until the person signs in again.
  • Updates the same shape either way - pushed where a network supports it, found by checking on a timer where it does not.
  • Helpers for Django, FastAPI and Flask. Django works on ordinary synchronous views and lets you write your storage as plain Django ORM code.
  • A test kit so a network you write yourself can prove it behaves like the ones here. You do not need a pull request to this repository to add a network; publish a package, and socialchimp finds it.

Getting started

pip install socialchimp

Then docs/getting-started.md, which goes from nothing to a post on Mastodon in six steps.

Worth knowing

  • Facebook, Instagram, Threads, YouTube, TikTok, X and Pinterest all need you to create the app by hand, and several review it before it works. That review is the slowest part of getting started, so begin it early. docs/platforms.md says what each one needs.
  • Three networks have a trap that makes working code look broken. An unaudited TikTok app posts everything as private. Pinterest on Trial shows your pins only to you. X answers 403 when your plan does not allow something. All three are called out where you will meet them.
  • The way platforms are written is now settled. See the promise about changes.

Quality

1725 tests, of which 19 skip because they need credentials for a network that nobody has in CI. 100% of lines and branches covered, enforced - the suite fails below it. mypy --strict with no ignores anywhere. Checked on Python 3.11, 3.12 and 3.13.