Social media publishing for headless CMS workflows
When an editor hits publish in Contentful, Sanity, or Strapi, automatically create social posts from the content. Covers webhook triggers, content extraction, and multi-platform distribution.
The publish button that only publishes to one place
An editor finishes a blog post. They review the formatting, check the images, and click publish in their headless CMS. The content goes live on the website.
Then a different process starts. Someone — the editor, a social media manager, an intern — opens a social scheduling tool. They write a tweet summarizing the article. They write a LinkedIn post. They write something for Instagram. They find an image, resize it, upload it to each platform. They paste the URL. They click publish again, once per platform.
The CMS publish event triggered one thing: a website update. Everything else was manual. The content management system managed the content, but not the distribution.
This is the default in most headless CMS setups. Contentful, Sanity, Strapi — they all have webhook systems. They all fire events when content changes. But almost nobody wires those webhooks to social media publishing, because publishing to social media has historically required eight different integrations with eight different APIs, each with its own upload protocol and authentication flow.
That gap is what Postproxy closes. One webhook fires, one API call publishes to every platform.
How headless CMS webhooks work
Every major headless CMS can send HTTP requests when content changes. The specifics vary, but the pattern is the same.
Contentful fires webhooks on entry publish, unpublish, create, update, and delete. You configure a URL, select which events trigger it, and optionally filter by content type. When an editor publishes a blog post, Contentful sends a POST request to your endpoint with the entry data.
Sanity uses GROQ-powered webhooks. You define a GROQ filter and projection, and Sanity sends a POST request whenever a matching document changes. You can filter to only _type == "post" && !(_id in path("drafts.**")) to catch only published posts.
Strapi has lifecycle hooks and webhooks. Webhooks fire on entry create, update, delete, and publish. You configure them in the admin panel or programmatically.
In every case, the CMS tells you: “This content just changed.” What you do with that signal is up to you.
The architecture: webhook to social post
The flow has three parts:
CMS publish event │ ▼Webhook handler(extract content, build post) │ ▼Postproxy API(publish to all platforms)The webhook handler sits between the CMS and Postproxy. It receives the CMS payload, extracts the fields you want in the social post, and calls the Postproxy API. This handler can be a serverless function, an n8n workflow, a small Express server — anything that can receive an HTTP request and make one.
Extracting content from the webhook payload
CMS webhooks send structured data, not social-ready text. You need to decide what goes into the social post.
A typical blog post entry might have:
- A title
- A summary or excerpt
- A featured image
- A slug (for building the URL)
- Tags or categories
From these, you construct a social post. A simple approach:
function buildSocialPost(entry) { const title = entry.fields.title; const summary = entry.fields.excerpt || entry.fields.metaDescription; const slug = entry.fields.slug; const imageUrl = entry.fields.featuredImage?.url; const url = `https://yourdomain.com/blog/${slug}`;
return { body: `${title}\n\n${summary}\n\n${url}`, media: imageUrl ? [imageUrl] : [], };}This gives you a post body with the title, a summary, and a link. The featured image becomes the media attachment. Nothing fancy — and nothing that requires a person to retype.
For richer setups, you might:
- Use different text for different platforms via Postproxy’s platform-specific overrides
- Pull the first paragraph of the body content instead of the excerpt
- Add hashtags based on the entry’s tags
- Add a first comment on Instagram with the URL (since Instagram does not support link previews in post text)
curl -X POST "https://api.postproxy.dev/api/posts" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "post": { "body": "We just published: How to optimize database queries at scale\n\nA deep dive into query planning, indexing strategies, and connection pooling.\n\nhttps://yourdomain.com/blog/optimize-database-queries" }, "profiles": ["twitter", "instagram", "linkedin", "threads"], "media": ["https://yourdomain.com/images/db-queries-hero.png"], "platforms": { "instagram": { "first_comment": "Read the full post — link in bio" } } }'One call. Every platform. The image is uploaded through each platform’s specific protocol automatically.
Contentful webhook handler
Here is a concrete example. A serverless function that receives a Contentful webhook and publishes via Postproxy:
export default async function handler(req, res) { const { sys, fields } = req.body;
// Only act on publish events for blog posts if (sys.contentType.sys.id !== "blogPost") { return res.status(200).json({ skipped: true }); }
const title = fields.title["en-US"]; const excerpt = fields.excerpt["en-US"]; const slug = fields.slug["en-US"]; const imageUrl = fields.featuredImage?.["en-US"]?.fields?.file?.["en-US"]?.url; const url = `https://yourdomain.com/blog/${slug}`;
const body = `${title}\n\n${excerpt}\n\n${url}`;
const response = await fetch("https://api.postproxy.dev/api/posts", { method: "POST", headers: { "Authorization": `Bearer ${process.env.POSTPROXY_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ post: { body }, profiles: ["twitter", "instagram", "linkedin", "threads"], media: imageUrl ? [`https:${imageUrl}`] : [], }), });
const result = await response.json(); return res.status(200).json(result);}Deploy this to Vercel, Netlify Functions, AWS Lambda, or any serverless platform. Point Contentful’s webhook at the function URL. Filter the webhook to only fire on publish events for the blogPost content type.
Now every time an editor publishes a blog post, a social post goes out across all connected platforms.
Sanity webhook handler
Sanity’s GROQ-powered webhooks let you filter and project at the source. You can configure the webhook to only send the fields you need:
*[_type == "post" && !(_id in path("drafts.**"))] { title, "slug": slug.current, excerpt, "imageUrl": mainImage.asset->url}The handler is simpler because Sanity has already shaped the data:
export default async function handler(req, res) { const { title, slug, excerpt, imageUrl } = req.body;
const url = `https://yourdomain.com/blog/${slug}`; const body = `${title}\n\n${excerpt}\n\n${url}`;
const response = await fetch("https://api.postproxy.dev/api/posts", { method: "POST", headers: { "Authorization": `Bearer ${process.env.POSTPROXY_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ post: { body }, profiles: ["twitter", "linkedin", "threads"], media: imageUrl ? [imageUrl] : [], }), });
const result = await response.json(); return res.status(200).json(result);}Strapi webhook handler
Strapi webhooks send the full entry in the entry field of the payload, along with a model and event field:
export default async function handler(req, res) { const { event, model, entry } = req.body;
if (model !== "article" || event !== "entry.publish") { return res.status(200).json({ skipped: true }); }
const { title, description, slug } = entry; const imageUrl = entry.cover?.url; const url = `https://yourdomain.com/blog/${slug}`;
const body = `${title}\n\n${description}\n\n${url}`;
const response = await fetch("https://api.postproxy.dev/api/posts", { method: "POST", headers: { "Authorization": `Bearer ${process.env.POSTPROXY_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ post: { body }, profiles: ["twitter", "instagram", "linkedin", "threads"], media: imageUrl ? [imageUrl] : [], }), });
const result = await response.json(); return res.status(200).json(result);}Using a workflow engine instead of code
If you would rather not write and deploy a function, workflow engines like n8n handle this without code.
The n8n workflow:
- Webhook trigger — receives the CMS payload
- Function node — extracts title, excerpt, slug, image URL, and builds the post body
- HTTP Request node — calls the Postproxy API to publish
Each step is visible, editable, and debuggable. If the CMS changes its payload format, you update one node. If you want to add a Slack notification after publishing, you add a node. If you want human approval before the post goes live, you add a wait step.
This is particularly useful for teams where the person managing social publishing is not a developer. The workflow is visual. The logic is explicit. No deployment pipeline required.
Drafts: when you want review before publishing
Not every CMS publish should immediately become a social post. For teams that want editorial control over social content, Postproxy’s draft system provides a clean boundary.
Create the post as a draft when the webhook fires:
curl -X POST "https://api.postproxy.dev/api/posts" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "post": { "body": "New post: Query optimization strategies..." }, "profiles": ["twitter", "linkedin"], "media": ["https://yourdomain.com/images/hero.png"], "draft": true }'The post is saved but not published. A social media manager can review it in the Postproxy dashboard, edit the text, and publish when ready. Or a second webhook — triggered by a custom field in the CMS like “approved for social” — can call the publish endpoint:
curl -X POST "https://api.postproxy.dev/api/posts/POST_ID/publish" \ -H "Authorization: Bearer YOUR_API_KEY"This separates the content trigger from the distribution decision. The CMS publish event creates the social post. A human (or automated) decision publishes it.
Handling images from a CMS
Headless CMS platforms store media in their own asset systems. Contentful serves images from images.ctfassets.net. Sanity uses its CDN at cdn.sanity.io. Strapi serves from its own media library.
Postproxy needs publicly accessible URLs for media. CMS asset URLs are typically public by default, so they work directly. Pass the image URL from the CMS payload to Postproxy’s media array:
{ "media": ["https://images.ctfassets.net/space_id/asset_id/hash/hero.png"]}Postproxy downloads the image and uploads it to each platform using the appropriate protocol. You do not need to re-host the image or convert it. If the CMS serves it at a URL, Postproxy can use it.
One thing to check: image dimensions and formats. Some platforms have minimum dimensions (Instagram requires at least 320px wide) or format restrictions. Postproxy validates these before uploading and returns clear errors if something does not meet a platform’s requirements.
Multi-platform distribution from a single content model
The real value of this setup is not just automation — it is that your content model becomes the source of truth for social distribution.
A blog post in your CMS has a title, an excerpt, an image, and tags. From that single content model, you can generate platform-appropriate posts:
- X/Twitter: Title + URL (character limit friendly)
- LinkedIn: Title + full excerpt + URL (professional tone, longer text works well)
- Instagram: Title + excerpt as caption, image as the post, URL in first comment
- Threads: Title + excerpt + URL
You can use Postproxy’s platform-specific overrides to customize per platform without making separate API calls:
curl -X POST "https://api.postproxy.dev/api/posts" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "post": { "body": "Query optimization strategies for production databases\n\nHow to identify slow queries, build effective indexes, and manage connection pools without overcomplicating your stack.\n\nhttps://yourdomain.com/blog/query-optimization" }, "profiles": ["twitter", "instagram", "linkedin", "threads"], "media": ["https://yourdomain.com/images/query-opt-hero.png"], "platforms": { "instagram": { "first_comment": "Full article at the link in bio" } } }'One API call. Four platforms. Platform-specific text where it matters. The CMS content drives everything.
What this changes for content teams
Without this setup, publishing is two jobs: managing content and distributing content. The CMS handles the first. A person handles the second. Every article that goes live requires a second round of manual work to promote it on social channels.
With webhooks wired to Postproxy, publishing content and distributing it become the same action. The editor clicks publish in Contentful, Sanity, or Strapi. The website updates. Social posts go out. The editor does not need to switch tools, write social copy from scratch, or remember which platforms need updates.
This does not remove the option for editorial control. Drafts, approval workflows, and platform-specific overrides are all available. The difference is that the default shifts from “nothing happens on social until someone manually does it” to “social distribution is automatic unless someone intervenes.”
For teams publishing frequently — daily blog posts, product updates, changelog entries — this eliminates a recurring manual task that scales linearly with content volume. One post per day means one manual distribution per day. Ten posts per day means ten. With the webhook approach, it means zero, regardless of volume.
Getting started
The setup takes three steps:
- Connect your social accounts in Postproxy and get your API key
- Deploy a webhook handler — a serverless function or n8n workflow that receives CMS events and calls the Postproxy API
- Configure your CMS webhook to point at the handler URL, filtered to publish events for the content types you want to distribute
Once wired, every publish in your CMS becomes a publish across your social channels. The content model you already maintain becomes the source for multi-platform distribution, with no extra tools, no extra steps, and no extra manual work.