Content pipeline: Notion calendar → social
Editorial calendar nudges the owner in Slack, then schedules the post in Buffer.
A content calendar only works if it chases people. This workflow watches a Notion editorial calendar, DMs the owner 48 hours before publish with the draft copy and an approve button, and on approval pushes the post into Buffer at the scheduled time — then writes the Buffer permalink back onto the Notion row so the calendar stays the single source of truth.
How it flows
- 01
Hourly sweep of the calendar
Rows that are Ready and publishing within 48 hours are picked up.
- 02
Owner DMed with the draft copy
Full text inline plus Approve and Edit buttons.
- 03
Approval captured
Slack posts the click to your interactivity Request URL; the handler acknowledges inside 3 seconds, then works. The button carries the Notion page id, so it needs no lookup table.
- 04
Post created in Buffer
One createPost per Buffer channel matching the Channel multi-select, due at the exact Publish At time.
- 05
Notion row updated to Scheduled
Buffer permalink written back so the calendar reflects reality.
- 06
Unapproved items escalate
Still Ready at 12 hours out? The nudge repeats once, then pings the content lead.
Set up each app
Work through these in order — later apps usually need a token or an id from an earlier one.
Notion
The editorial calendar and approval state
- 01
Build the "Editorial Calendar" database
Properties: Title (Title), Publish At (Date, with time enabled), Channel (Multi-select: X, LinkedIn, Newsletter), Status (Status: Idea → Drafting → Ready → Scheduled → Published), Owner (Person), Copy (Text), Asset (File), Buffer URL (URL). Switch on "Include time" for Publish At or everything schedules at midnight. Status options always sit inside one of Notion's three groups, so put Idea and Drafting under To-do, Ready and Scheduled under In Progress, and Published under Complete — the group is what view filters and the API status group filter key off.
- 02
Query the nudge window
Share the database with your internal connection first (••• → Connections → Add connection) and keep both "read content" and "update content" on, or you get a 404 on a database you can plainly see and a 403 on the write-back. Retrieve the database once for its data source id: GET /v1/databases/{database_id} returns data_sources[].id. Then, hourly, POST /v1/data_sources/{data_source_id}/query with Notion-Version: 2026-03-11 (/v1/databases/{id}/query is deprecated) for rows that are Ready and publishing in the next 48 hours. Filter the raw Publish At date rather than a now() formula: the two bounds below are computed by your job, so nothing depends on when Notion last recalculated a formula.
Data source query filter — now to now + 48h{ "filter": { "and": [ { "property": "Status", "status": { "equals": "Ready" } }, { "property": "Publish At", "date": { "after": "2026-03-21T09:00:00Z" } }, { "property": "Publish At", "date": { "on_or_before": "2026-03-23T09:00:00Z" } } ] }, "sorts": [{ "property": "Publish At", "direction": "ascending" }] }
Slack
Nudges the owner and captures approval
- 01
DM the owner, do not post to a channel
Match the Notion Person property to a Slack user by email with users.lookupByEmail, then call conversations.open with that single user id and post to the channel.id it returns. Bot scopes: users:read.email for the lookup (plain users:read is not enough), im:write to open the DM, chat:write to send it. Notion's own Slack connection can DM an assignee, but only on an edit event — it has no "N hours before a date" trigger, which is why this runs on a schedule instead of using the native automation.
- 02
Turn interactivity on and give Slack a Request URL
api.slack.com/apps → your app → Interactivity & Shortcuts → toggle Interactivity on → set the Request URL. Skip this and the approve button is inert, which takes the whole workflow with it. Slack POSTs a form-encoded payload parameter that you parse as JSON, and it wants an acknowledgement within 3 seconds — acknowledge first, call Buffer after. The link button counts too: clicking "Edit in Notion" still delivers a payload you have to acknowledge.
- 03
Send the copy with an approve action
Include the full copy in the message so approval does not require opening Notion on a phone. The action value carries the Notion page id back to your handler. For the edit link, use the url the query already returned on each page object rather than composing one from the id — the composed form has changed before and the API hands you the current one for free.
Approval message blocks{ "blocks": [ { "type": "section", "text": { "type": "mrkdwn", "text": "*Publishing Monday 09:00 — LinkedIn*\n\n> Six months of build logs, one honest retro. Here is what we would not do again." } }, { "type": "actions", "elements": [ { "type": "button", "style": "primary", "text": { "type": "plain_text", "text": "Approve & schedule" }, "value": "notion:2f91c4a70b8d4e5f9a1c3b6d8e0f2a4b", "action_id": "content_approve" }, { "type": "button", "text": { "type": "plain_text", "text": "Edit in Notion" }, "url": "https://app.notion.com/p/Six-months-of-build-logs-2f91c4a70b8d4e5f9a1c3b6d8e0f2a4b" } ] } ] }
Buffer
Holds the scheduled queue per channel
- 01
Point your agent at Buffer's MCP server, or take an API key
Buffer ships an official MCP server alongside its public API, and for an agent that is the path: connect it and the scheduling below is a tool call instead of hand-rolled HTTP. Building it by hand instead? Same credential either way — publish.buffer.com/settings/api → Create API key, sent as an Authorization Bearer header against api.buffer.com, on every plan including Free. What you cannot do any more is the old route: new client creation on the legacy REST API is closed and that API retires 2027-02-01, so there is no v1 token left to generate.
- 02
Map Buffer channels to your Notion Channel values
One Buffer channel is one value of the Notion Channel multi-select. Fetch the list once and keep the mapping in config. The legacy profile_ids array is gone: createPost takes a single channelId, so a row tagged with three channels is three calls, not one.
List the connected channelsquery { channels(input: { organizationId: "8f31c0a9e2b7" }) { id service } } - 03
Create one scheduled post per channel
mode is an enum, and the failure to design against is addToQueue — it drops the post in the next free slot and desyncs the calendar. Pass customScheduled with dueAt as an ISO 8601 UTC timestamp taken straight from Publish At. assets is required, so send an empty array for a text-only post and put the link in text or in the network's metadata.
createPost — one per channelIdmutation { createPost(input: { channelId: "5f2b9c1a7e4d3b0016a2c9d1" text: "Six months of build logs, one honest retro." assets: [] mode: customScheduled dueAt: "2026-03-23T09:00:00Z" schedulingType: automatic }) { post { id status dueAt } } } - 04
Write the permalink back to Notion
On success, PATCH the Notion row: Status → Scheduled, Buffer URL → the link for the created post. This is the call that needs "update content" on the connection you shared the database with. Now the calendar shows what is actually queued, not what someone intended.