Dropping a social scheduler and writing the Buffer client instead
Most of the port survived the swap. The two methods that did not are the two that decided how the whole feature has to work from now on.

We removed the self-hosted social scheduler this product ran on and wrote a thin client against the Buffer API instead. Five of the seven methods the old port carried moved across with the same shape: list channels, create a post, list posts, delete a post, and read metrics. The two that did not move are the interesting part, because neither has an equivalent anywhere in Buffer's surface. There is no API for connecting a social channel, and there is no endpoint for uploading media. Both absences changed the design of the feature, not just its implementation.
The publishing services in this product do not depend on a client class; they depend on a port, which is a structural description of the handful of methods they actually call. That choice is what made a vendor swap a bounded piece of work rather than an archaeology project. Tests inject a plain mock that satisfies the port, so no test has ever reached a live social API, and the code that composes a post never learned which service was on the other side.
A port is only worth having if it is narrow. Ours lists the operations the publishing path needs and deliberately excludes the ones only the account side needs, so the code that schedules a post cannot read the account behind an API key. That separation cost nothing to write and would have been almost impossible to retrofit under time pressure during a migration.
The old scheduler was a single instance we ran, so a single client could be constructed once and injected everywhere. Buffer is the opposite shape: one personal API key per Buffer account, each with its own channels and its own request budget, and one app may hold several accounts. That means there is no such thing as "the" Buffer client in this codebase. There is only "the client for this account", which is what a resolver hands out.
Putting a resolver in front of the client also settled where secrets live. The transport layer knows which database to hand over and touches neither the database nor a key. Reading the stored key and decrypting it happens once, behind the resolver, so the services that publish posts never see raw credentials. Clients are cached per account with an upper bound, because one process holding an unbounded number of HTTP clients is a leak waiting for a busy afternoon.
| Capability | Under the old scheduler | Under Buffer |
|---|---|---|
| Listing channels | Port method | Port method |
| Creating and scheduling a post | Port method | Port method |
| Listing and deleting posts | Port methods | Port methods |
| Reading post and account metrics | Port methods | Port methods, polled rather than pushed |
| Connecting a social channel | An OAuth journey the port could start | No API; the user connects on buffer.com |
| Attaching media | Uploaded to the scheduler | A public URL Buffer fetches itself, later |
| Client lifetime | One shared instance | One per stored account key |

Publishing a post sends two independent announcements. One IndexNow request reaches Bing, Yandex, Seznam, Naver and Yep. Google takes no part in IndexNow, so it is told by re-submitting the sitemap.

Market4 turns one release note into a changelog page, a blog post, a mail-out and a week of social posts — and then tells you which of them brought anyone back.
Connecting a social channel is the step a user does once and remembers forever, and Buffer exposes no API for it. The old port could produce a connect URL and start an authorisation journey inside our own interface. Nothing can reproduce that now, so the honest replacement is a written sequence: sign in on buffer.com with the account whose key is stored here, choose to connect a channel and authorise the network there, then come back and re-sync the account, at which point the new channel appears in the channel list within one sync.
Writing that down as steps rather than pretending to automate it turned out to matter more than it sounds. A missing capability that is documented is a small annoyance; a missing capability that is implied to exist produces support conversations where somebody hunts for a button. The same applies to the account biography, which no Buffer endpoint writes — a limit worth stating plainly rather than working around.
Buffer has no media upload endpoint, so an image or video is always a URL that Buffer fetches itself, at publish time. Publish time may be days after the post was accepted. That single fact makes a signed or expiring URL actively dangerous: it works when the post is submitted, the submission succeeds, and the asset is dead by the time it matters. The post then fails on the network, where nobody is watching.
The client refuses an expiring URL at submission, because that is the last moment a person can still be told. The check is deliberately crude and readable: the address must be http or https, and its query string must not carry the markers that expiring links use — an Amazon signature or expiry, a Google signature or expiry, a plain expires or signature parameter, or the Azure shared-access expiry. A crude check that refuses early beats a precise one that discovers the problem after publication, because a social publish that fails after acceptance is silent by default.
Buffer does not push results, so a worker has to ask whether a scheduled post went out and what it did. Asking costs requests against the same budget publishing uses, which makes the polling schedule a real design decision rather than a configuration detail. The rule we settled on is to poll sparingly and to record what each call consumed, so that a busy publishing day and an over-eager monitor cannot quietly starve each other.
Because running the scheduler meant running a service, its database, its upgrades and its outages, in exchange for capabilities we were mostly not using. A hosted API removes that operational surface and replaces it with a different constraint: you get exactly the operations the vendor exposes, and no others. That trade is worth making when the operations you actually need are on the list, which is the thing to check before starting rather than after.
The publish fails at the network, after the submission was already accepted, which is the worst place for it to happen because nothing in the earlier flow reported a problem. That is why the client refuses expiring URLs at submission instead: it is the last point at which a person is still present to be told. Use an address that will still resolve days later, without a signature or an expiry in its query string.
No. Buffer exposes no channel-connect API, so authorising a social network happens on buffer.com and nowhere else. What the product can do is state the sequence clearly and make re-syncing one action, so the round trip is short: connect on Buffer, come back, sync, and the new channel appears in the channel list. Pretending otherwise would only send people looking for a button that cannot exist.
Because a Buffer API key belongs to a single Buffer account, and each account has its own channels and its own request budget. One app can hold several accounts, so a single shared client would have to choose a key, and any choice it made would be wrong for the other accounts. A resolver that returns the client for a named account keeps the budget accounting honest and keeps key handling in one place.
The publishing services survived essentially intact, because they were written against a port rather than against a vendor client. Five of the seven methods the old port carried had direct equivalents. What had to be rewritten was the client itself, the credential resolution, and the two features whose capability disappeared: channel connection, which became a documented manual journey, and media handling, which became a URL contract with a validation rule attached.
We split one long numbered prompt into two. Every step after the cut changed number, and the sentences that navigate by those numbers went on pointing at whatever now wore them.

The link measured 297 by 44 pixels and reported a pass. Tapping its centre hit the paragraph behind it. An inline box that wraps is not one rectangle, and the pseudo-element meant to enlarge it was positioned against the same broken geometry.