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_urlis the account's picture, filled in byfinish_login(orresume_login, where you pick a page or channel). It defaults toNone, so connections saved before 0.9.0, and code that builds aConnectionwithout it, keep working. A missing, empty or garbled picture isNone, never an error.with_tokencarries it over.account.profile()asks the network for the account's current name and picture as anAccountProfile(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 newCanReadProfile.read_profile. RaisesNotSupportedErrorwhere there is no picture to read.socialchimp.picture_url(value)turns whatever a network sent into a cleanhttp(s)address orNone, for people writing their own platform.FakePlatform(avatar_url=...)puts that picture on every connection it makes, and hasread_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 |
the Page's picture{url}, added to the page fields already asked for |
GET /{page-id}?fields=name,picture{url} |
|
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 |
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 aPostDetails, not only what publishing it returned. One request on both networks:GET /api/v1/statuses/:idon Mastodon,app.bsky.feed.getPostson 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 withparent_id.Thread.completeisFalsewhendepth,limitor the network's own cap cut it short. Mastodon: one request toGET /api/v1/statuses/:idplus one to/context(no pagination; up to 4096 replies signed in; aMastodon-Async-Refreshheader, meaning remote replies are still arriving, also setscomplete=False). Bluesky: one request toapp.bsky.feed.getPostThread(depth 6 by default, 1000 at most); a deleted or blocked reply comes back as a placeholder withunavailableset 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 aPostResult- the recommended way to answer somebody now;publish(Post(reply_to=...))keeps working too. One request to read the parent, plus whateverpublishalready costs. Mastodon: a reply to adirector followers-only parent keeps that visibility, whateveroptionsasks 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 intext. Bluesky builds the reply's root and parent from the target with one lookup.Feature.REPLY_TO_COMMENTS.account.like(post_id)andaccount.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'screateRecordis not deduplicated by the network, solike()readsviewer.likefirst and hands back the existing like rather than making a second one;unlike()costs nothing extra when you passlike_idfromLikeResult, 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 aPage[Like]. Mastodon has noliked_at- alwaysNone- 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.LIKEwithout this one.Feature.READ_LIKES.
Added: picking up updates where you left off¶
account.fetch_updates_after(marker, *, limit=None)andaccount.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=Noneon the first call reads the latest page, which sets a starting point. A marker your app made up, or one from a different platform, raisesConfigErroron both Mastodon and Bluesky, rather than silently starting over and risking a dropped update - passNoneto start afresh instead. Mastodon: the marker is the newest notification id, fetched withmin_id=(one request, pages forward with no gaps);mark_seenisPOST /api/v1/markers. Bluesky: notifications only page backwards, so the marker is the newestindexedAtplus uri; the library pages back looking for it, up to five requests, and saysmore=Trueif it is still not found rather than holding a request open forever;mark_seenisapp.bsky.notification.updateSeen.Feature.READ_UPDATES_AFTER. The existingfetch_updates(since)andPollerare unchanged.Updategains five optional fields, all defaulting toNoneso 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, andconversation_id(forMESSAGE_RECEIVED). A Bluesky quote'sabout_post_idis the quoted post, read fromreasonSubjectthe same as a like or a repost - a quote's own record has norecord.replyto 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)andaccount.mark_read(conversation_id)read and answer direct messages. Messages come back inPages, newest first;aftergoes 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 offerCanMessagewithout offering this one.Feature.START_CONVERSATIONS.- Mastodon: conversations are
GET /api/v1/conversations, one request. Mastodon has no "every message in this conversation" call, soread_messagesreads the/contextof the conversation's last status and keeps only the direct-visibility statuses sent between this conversation's own participants - that is whyConversation.full_historyisFalse, and why it never returns anext.send_messageposts a direct status mentioning every participant, as a reply to the last one.start_conversationreturns 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, andsend_message,read_messagesandmark_readall accept that form too - treat it as opaque either way. - Bluesky:
chat.bsky.convo.listConvos/getMessages/sendMessage/updateRead, plusgetConvoForMembersforstart_conversation, every one sent with theatproto-proxyheader Bluesky's chat API needs. An app password made without "Allow access to your direct messages" raisesMissingPermissionError(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()andclient.features(platform)answer with theFeatureflags 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_UPDATESis 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), aNotAllowedError, names the one permission that is missing rather than only saying no - Bluesky's DM app-password gap raises it.BlockedError, aNotAllowedError- 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), aNotAllowedError- reserved for Meta's 24-hour reply window, in a later release.PostGoneError, aNotFoundError- the post or comment asked for was deleted, or never existed, more precise than a plainNotFoundErrorfor 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¶
FakePlatformnow implements every social-inbox protocol above, with nothing but socialchimp itself - no pytest needed.add_postandadd_replyseed whatread_postandread_threadhand back,add_likeseedsread_likes,add_updateseedsfetch_updates_after, andadd_conversationseeds direct messages.fetch_updates_after(None)returns the latest page -limit, orpage_sizewhen that is left out - the same as a real platform's first call, rather than every update ever queued.- An unrecognised marker raises
ConfigError, onfetch_updates_afterandmark_seenalike, 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, notREACTION_ADDED. A handler that only checkedREACTION_ADDEDfor "something happened to my post" now needsREPOST_ADDEDtoo. - A follow now arrives as
UpdateKind.FOLLOWED, notUNKNOWN. - 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 wherein_reply_to_account_idis the connected account comes out asCOMMENT_CREATED(about_post_idisin_reply_to_id); anything else staysMENTION. - Mastodon's
DEFAULT_SCOPESwiden 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) andRateLimit-Reset(Bluesky) whenRetry-Afteris missing, soRateLimitError.retry_afteris 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_idgoes 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.REPLYis on. Read this first: Threads only lets you reply where you own the root post, unless the app also holdsthreads_manage_mentionsorthreads_keyword_search- neither is in the default scopes, so add one toscopesat sign-in to answer a mention on somebody else's post.account.read_replies(post_id)is new, on every platform throughsocialchimp.platform.CanReadReplies: the top-level replies to one post, or every depth flattened withwhole_conversation=True. Threads is the first to implement it.account.fetch_updates()now works on Threads: the replies on the account's latest posts, asUpdateKind.COMMENT_CREATED. Sameupdate.idandupdate.rawas thereplieswebhook and asread_replies, so one handler and oneSeenUpdatesserve all three. Reads the latest 25 posts by default,ThreadsPlatform(recent_posts=...)changes that between 1 and 100; costs1 + recent_postsrequests at most, every poll.account.reply_to_update(update, text)now works on Threads, for arepliesor amentionsupdate - it publishes a reply throughpostitself, 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,repliesascomments, andrepostsasshares, from one request toGET /{post_id}/insights.views,quotesand the share button's own count are not modelled onPostStats; they are still onraw.Feature.READ_STATSis 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),commentsandshares, in one small request. A video'ssharesisNonerather than a made-up zero.account.fetch_updates()now works on Facebook: the comments on the page's latest posts, asUpdateKind.COMMENT_CREATED.update.rawandupdate.idmatch what the webhook produces, so one handler and oneSeenUpdatesserve 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_secondsandwait_up_to_seconds). - Error 9007 (subcode 2207027) is a
RateLimitError, withretry_after=30, instead of an unnamedPlatformError. It is the existing "wait, then try again" error, so an app that already treatsRateLimitErroras transient needs no change. Nothing was published when this comes back, so the same post can be sent again.publishalso asks again twice on its own, five seconds apart, before it raises it. No other refusal is asked again, including an ordinaryRateLimitError. - Error 36003 ("The aspect ratio is not supported") is an
InvalidPostErrorsaying feed pictures have to be between 4:5 and 1.91:1. - A picture whose status reply has no
status_codeat 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
statusat 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, insocialchimp.platform.fetch_updatesandreply_to_updatealready 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_updatespolls TikTok'scomment/listand hands backUpdateKind.COMMENT_CREATED, with the untouched comment onupdate.rawso nothing is lost even where TikTok's own field names had to be guessed at. - Read-only for posting.
tiktok_businessdeclares noFeature.*flags;post()refuses by name every time. Publish throughtiktokinstead. - What is deliberately not here: video-level stats (
business/video/listis not in TikTok's own SDK, and nothing here claims a number it cannot back with a source) and a confirmedrefreshendpoint (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, insocialchimp.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_updatereadsupdate.kindto 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
UpdateKindvalues: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,
secretis 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 - socheck_signaturealso checks the token'semailclaim against the one Pub/Sub service account your subscription authenticates as, the way Google's own push documentation says to.check_signatureandread_updatesstill take the same arguments as every other platform's; only whatsecretholds is different here, and that is documented onGoogleBusinessPlatformitself.fetch_updatesalso 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_appunder the platform nameinstagram, and updateINSTAGRAM_APP_ID/INSTAGRAM_APP_SECRETwherever 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 tograph.instagram.com, notgraph.facebook.com-api_basereflects this. finish_loginno longer answersChooseAccount. Signing in directly as an Instagram account has nothing to choose between, so it goes straight toFinished, the same shape asThreadsPlatform.InstagramPlatformno longer implementsCanResumeLogin, andresume_loginis gone.- The scopes changed.
pages_show_listandbusiness_managementare gone - there is no Facebook Page to find an account through any more.instagram_business_manage_messageswas added. SeeDEFAULT_SCOPES. Connection.extrano longer carriespage_idorpage_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 aPostStats: its replies, favourites and boosts, under socialchimp's own names for them -comments,likesandshares. One request, toGET /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.testingno longer imports pytest.from socialchimp.testing import FakePlatformraisedModuleNotFoundError: No module named 'pytest'on an install without thetestingextra, because the import sat at the top of the module. OnlyPlatformChecksever wanted it.FakePlatform,RecordingStorage,RecordingTransportandStorageCallare for building an app as much as for testing one - the sample projects inexamples/build a whole app againstFakePlatformand 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
seencheck throws away. - The failures come back to you, as an
ExceptionGroupholding 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 aforloop overidscallingawait sc.account(connection_id).post(post).job.succeededbecomes whatever you append aPostResultto in the loop.job.failedbecomes yourexceptblock. CatchSocialChimpErrorfor 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 withdataclasses.replace(post, options={**post.options, **extra}).- Anywhere you imported
PostJoborPostError, delete the import. The error types you catch -SocialChimpErrorand 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:
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:
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]becomesupdate.raw[k].- Threads'
update.raw["values"]["value"][k]becomesupdate.raw[k]. - The page id and the time that used to be on
raware on the newupdate.envelope, which holds the entry the change arrived in. Soupdate.raw["id"]becomesupdate.envelope["id"], andupdate.raw["time"]becomesupdate.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 aPostResultthat came backPROCESSINGis not the end of it. This asks how far they have got, renewing the token first the way every other call on anAccountdoes. Networks that finish before they answer raiseNotSupportedErrornaming themselves.Account.fetch_updates(since=None). The same for networks that have to be asked what has happened. Hand it toPollerand 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_stateand give it(connection, post_id).Account.check_statethen finds it, andPlatformCheckswill tell you if the shape is wrong. - If your network pushes and you only wrote
read_update, add aread_updates(body) -> list[Update]. Without one,SocialChimp .read_updatesrefuses with a message saying exactly that, becauseread_updatehands 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¶
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.