Channels · a page is not an inbox
The channel with no webhook.
Every messaging integration you have written assumes the platform tells you when something happens. LinkedIn does not. This is how a Page became a first-class channel anyway, and why the polling interval turned out to be the whole design.
Section 01 · the modelling decision
A Page is not an inbox, and pretending otherwise costs you twice.
The first instinct when adding LinkedIn to a customer support platform is to treat it like every other channel: a vendor sends you events, you turn them into conversations, an operator answers. That model is wrong here in two ways, and each one bites at a different time.
A Page has no thread with an individual. Nobody direct messages a company Page through this API. What exists is a post the business published and the members who commented underneath it, in public, where their reply and yours are visible to everyone who scrolls past. The conversation is real, but it hangs off a post rather than off a person.
And the platform never tells you anything. There is no comment webhook in the Community Management API. If you want to know that somebody asked a question under this morning's post, you have to go and ask.
flowchart LR
subgraph MSG["A messaging channel"]
W["vendor webhook"] --> P1["parse"] --> C1["conversation
per contact"]
C1 --> R1["reply to the person"]
end
subgraph PAGE["A page channel"]
T["tenant publishes"] --> POST["post on the page"]
POST --> CMT["members comment"]
CMT -.->|"nobody tells you"| POLL["you go and look"]
POLL --> P2["parse"] --> C2["conversation
per commenter"]
C2 --> R2["reply as the page"]
end
The decision that made the rest cheap: a comment becomes an ordinary inbound message. Not a LinkedIn comment object with its own table and its own screen. The same canonical event every other channel emits, persisted through the same pipeline. Conversations, agent takeoff, journeys, the inbox and the CRM all work with no LinkedIn branch anywhere, because none of them can tell.
Section 02 · where the vendor code is allowed to live
One narrow interface, and a second one for the things a page can do that an inbox cannot.
Every channel in this platform sits behind one interface resolved from a name-keyed factory. Inbound verifies and parses, outbound sends. Adding a channel is one file and one factory case; callers never learn a vendor name. That worked for LinkedIn until it did not, because two of the four methods have no meaning for a Page.
| Method | For a messaging channel | For a Page |
|---|---|---|
| Challenge | Answer the vendor's webhook handshake | Nothing arrives. Never answers |
| Verify | Check the signature on a delivery | Rejects everything. Anything hitting that URL is not the vendor |
| Parse | Turn a webhook body into events | Same job, driven by the poller instead |
| Send | Deliver a message to a person | Publish a post, or comment on a thread |
Rejecting rather than silently succeeding is the useful part. A no-op Verify that returns nil would mean anyone who found the URL could inject messages into a tenant's inbox. Honest values in a capability descriptor are a security property, not documentation.
What was genuinely missing was reading. A tenant asking "did it post, and did anyone reply" cannot be answered from our database, because a Page with no comments yet has no conversation to open a screen from. So there is a second, optional interface that a channel may implement: list the page's posts, list one post's comments, and probe whether the credential still works. Callers type-assert for it, the same way the inbound pipeline type-asserts for contact enrichment. The vendor branch stays inside the driver.
If you are integrating a page-shaped platform: resist adding a third method to your core interface for it. The optional-capability pattern costs one type assertion at the call site and keeps every existing channel untouched. The moment "does this channel support X" becomes a switch on a vendor name in a caller, you have started building the thing the interface existed to prevent.
Section 03 · the flow that has to be watchable
We deliberately made the connect flow longer than it needed to be.
Our Meta connector binds the first Page it finds. One redirect out, one redirect back, connected. It is less code and fewer screens, and for LinkedIn it would have been a mistake we could not undo.
LinkedIn grants API access in two tiers. The first arrives from a form. The second, the one that lifts the rate limits into production territory, arrives only after a reviewer watches a screen recording demonstrating each use case you claimed. One of the required test cases is a user approving access to a Page they own. If the software picks the Page for them, that moment does not exist on camera, and there is no way to film it later without rewriting the flow.
sequenceDiagram participant A as page admin participant C as console participant M as middleware participant L as LinkedIn A->>C: connect C->>M: GET connect/linkedin/start M-->>C: authorize url, signed state C->>L: consent screen L->>M: callback with code M->>L: exchange code M->>L: organizationAcls, role ADMINISTRATOR L-->>M: the pages this member administers M->>M: park them, 15 minute key M-->>C: redirect with the selection key A->>C: picks one page C->>M: POST connect/linkedin/select M->>M: seal the token, bind the org urn
Parking the result introduces state that a stateless OAuth callback did not have, and two rules keep it honest. A selection key belongs to the tenant that started the connect, and a request from anywhere else gets told the connect expired rather than that it exists, so probing keys teaches nothing. And binding refuses any organisation URN that was not in the parked list, so a hand-crafted request cannot attach a Page the member never authorised.
The multi-tenant story is one sentence: access is restricted to Pages where the connecting member holds an approved administrator role, so a token we hold reaches exactly the Pages that person could already manage by hand, and revoking their role revokes ours.
Section 04 · the number that turned out to be the design
The polling interval is not a setting. It is a division.
Development Tier allows 500 requests per app per day. Not per tenant. Per application, shared across every Page every customer has connected. That single sentence decides more about this integration than any architectural choice in it.
The first draft of our design document confidently claimed a ten minute poll for one Page. Checking the arithmetic before writing the code: a poll that lists recent posts and then reads comments on each of five watched posts costs six requests. Every ten minutes is 144 polls, which is 864 requests against a ceiling of 500. The plan was not aggressive, it was impossible, and nothing in the code would have said so. It would simply have started failing at some point in the afternoon, every day, for whichever tenant polled last.
Two things fixed it. First, the cost model: comment counts for many posts come back from one batched call, so the common case where nothing has changed costs two requests rather than one per post. That is not an optimisation, it is the difference between an eight minute answer and a twenty-four minute one on a single Page. Second, the interval became a derived value with the number of connected Pages as its input.
flowchart TB B["500 requests per app per day
shared by every tenant"] --> S["three quarters to polling"] S --> D{"how many pages
are connected?"} D -->|"1"| I1["every 8 min"] D -->|"3"| I2["every 24 min"] D -->|"10"| I3["every 80 min"] I1 --> Q["quiet page?
three empty sweeps"] I2 --> Q I3 --> Q Q -->|"yes"| SLOW["read at a quarter of the rate
until it publishes again"] Q -->|"no"| KEEP["keep the derived interval"]
| Connected Pages | Requests per poll | Interval that fits |
|---|---|---|
| 1 | 2, batched summaries | every 8 min |
| 3 | 2 | every 24 min |
| 10 | 2 | every 80 min |
| 1 | 6, naive, five posts each | every 24 min |
Three rules fall out of the same arithmetic. Watch a window, not a history: only posts from the last fourteen days stay in the rotation, capped per Page. Back off on quiet Pages: three consecutive sweeps with nothing new drops that Page to a quarter of the rate until it publishes again. And spend only part of the allowance on reading, because publishing, replying, token refresh and the daily statistics job come out of the same 500.
The line that stops this being a support ticket: the console shows "comments are checked every 24 min: 3 Pages connected, sharing 500 LinkedIn requests a day". A comment that surfaces twenty minutes late with no explanation is a bug report. The same delay next to that sentence is a tradeoff somebody can reason about, and an argument for the tier upgrade.
Section 05 · the bug that would have been public
Making comments look like messages almost published a support reply to twelve thousand followers.
The best thing about turning comments into ordinary inbound messages is that every existing feature works on them. The worst thing about turning comments into ordinary inbound messages is that every existing feature works on them.
Once a comment is a conversation, an operator answering it in the inbox goes through the generic outbound path, which resolves the contact's provider id and calls the driver's send method. That path was written for messaging channels, so it builds an envelope with no thread id, because a direct message does not need one.
The LinkedIn driver branches on exactly that field. A thread id means comment on this thread. No thread id means publish a post. So a one-line answer typed into a support inbox would have been published to the Page as a brand new post, in front of every follower, with no indication anything unusual had happened.
flowchart LR OP["operator types a reply
in the inbox"] --> OUT["generic outbound path"] OUT --> Q{"does this driver
read a page back?"} Q -->|"no"| DM["send as a direct message"] Q -->|"yes"| TH["resolve the comment
it is answering"] TH -->|"found"| CM["comment as the page"] TH -->|"not found"| FAIL["refuse and say so"] FAIL -.->|"what this prevents"| BAD["a private reply published
to every follower"]
The general lesson, for anyone unifying channels behind one model: the field a driver treats as optional is the field the shared caller will forget to set. When the two branches of a missing value differ in blast radius, the safe branch cannot be the default. Refusing is cheap; a public post cannot be recalled.
Section 06 · the details that cost a day each
Four things the documentation says once and you learn twice.
The post id is in a header
Publishing returns 201 with an empty body. The URN of the thing you just created comes back in the x-restli-id response header. Read the body and you store an empty id, then every later reply to that post fails with nothing to point at.
URNs must be fully encoded in paths
A URN contains colons, and most path-escaping helpers leave colons alone as legal path characters. /socialActions/urn:li:share:1/comments answers 404 while looking perfectly correct in your logs. It has to be /socialActions/urn%3Ali%3Ashare%3A1/comments.
The API version expires
Every call carries a version header in YYYYMM form, and versions stop working after roughly a year, answering 426 on every request at once. It is configuration in a database row, not a constant, so the fix is an edit and not a deploy.
The commenter's profile is already there
Ask for the actor~ decoration in the comments projection and each comment arrives with the member's name, headline and photo attached. A separate profile lookup would cost a request per commenter and need a permission that is closed to new applications.
The batched read that makes the polling budget work uses rest.li batch-get syntax, which is worth seeing once because it does not look like other APIs: ids=List(urn%3Ali%3Ashare%3A1,urn%3Ali%3Ashare%3A2). Each URN percent-encoded, inside a literal List(...), as a query parameter.
Section 07 · rules that bind the implementation
The review reads your privacy policy, so the policy has to describe the code.
Access to member data on this API is granted conditionally and can be revoked. The review looks at what you store, what you display, and whether those two lists match the one in your published policy. That turns compliance from a document into a set of constraints on the implementation, which is a better place for it.
| Rule | How it shows up in code |
|---|---|
| Store the minimum | Four fields off a comment: member URN, name, headline, avatar. The payload carries more and the parser drops it |
| Display only what you store | One contact panel, the same four fields, and it is the panel that gets filmed |
| Delete on disconnect | Disconnecting is a deletion, not a toggle: watched posts and cached member fields go, through the erasure path that already exists |
| Never post without a person | Publishing is an explicit action in the console. Agent-drafted replies pass the same approval gate as every other channel |
| No cross-tenant identity | A member who comments on two customers' Pages is two contacts and is never unified |
Worth internalising if you are planning a similar integration: the screen recording is the specification. Anything you claimed on the access form has to be demonstrable on camera, which means a feature that cannot be filmed does not count as built, and a claim you cannot honour is worse than a capability you left out.
What it took
One driver, one connector, one sweep.
Roughly two thousand lines across the middleware and the console, most of it neither LinkedIn-specific nor interesting, which is the point.
In the platform
- One catalog entry with honest capability values, shipped switched off until access is granted
- One driver: publish, comment, parse, and the optional page-reading interface
- One OAuth connector with a parked Page choice and a signed, tenant-bound state
- One platform config row, because the version header and the request budget both have to be editable without a deploy
- One cron that ticks cheaply and decides inside the sweep which Pages are actually due
What it did not take
- No new conversation model, inbox view, or message type
- No LinkedIn branch in the inbox, journeys, takeoff or the CRM
- No second erasure path, because disconnect reuses the one that exists
- No per-tenant developer application: one platform app, each tenant's own admin authorising their own Page
The honest caveat: at the time of writing none of the read paths have met the live API. They are tested against fixtures, so the response shapes come from documentation rather than from a real response. The first connect after the access grant is where that gets confirmed, which is why every vendor failure surfaces the platform's own message rather than a generic one.