We build websites for a living, but running the SO Websites Facebook page is its own job. Writing captions, designing graphics, copying links, opening Business Suite, uploading images, scheduling the post. Every single time. It adds up fast when you are also trying to do client work.
So we built our own tool. A private, internal Facebook post manager that connects directly to the Facebook Graph API, lets us design custom SVG posts, and handles the scheduling and publishing without touching Business Suite. This is how it works and what we learned building it.
Why We Bothered
Third-party scheduling tools exist, obviously. Buffer, Hootsuite, Later. But they all have the same problem: you are designing your post image somewhere else, uploading it, writing the caption in a different box, and hoping the preview looks right. For us, the post design and the caption are part of the same thing. We wanted them in one place, under our control.
The tool is private, internal, and only used by us. The setup took a weekend to get working properly, mostly because of Facebook’s permission system.
How the Graph API Works for Page Posting
The Facebook Graph API gives you programmatic access to Facebook pages you manage. To post to a page, you need two things: a Page Access Token and the correct permissions on your Facebook App.
The Page Access Token is not your personal user token. You get it by calling /me/accounts with a user token, which returns a list of pages you manage along with their individual page tokens. That page token is what you use to post.
There are two main endpoints for posting:
/{page-id}/feedfor text posts and link posts/{page-id}/photosfor image posts
Simple image post? Just POST to /photos with the image URL or as a form upload, include your caption, done. But if you want the post to show up correctly in Business Suite under scheduled posts, and if you want to attach multiple images or control the post type precisely, you need a two-step approach.
The Two-Step Image Posting Approach
This is the part that took the most trial and error to figure out. If you post directly to /photos, Facebook treats it as a photo post, not a feed post with an attached image. The distinction matters for how it displays, how it gets scheduled, and whether Business Suite shows it in the right queue.
The correct approach is:
- Upload the image as an unpublished photo to get a
photo_id - Create a feed post using
attached_mediawith thatphoto_id
Step one looks like this:
POST /{page-id}/photos
published: false
source: [image file]
Facebook returns a photo_id. You then use that in your feed post:
POST /{page-id}/feed
message: "Your caption here"
attached_media: [{"media_fbid": "{photo_id}"}]
scheduled_publish_time: {unix_timestamp}
published: false
Setting published: false with a scheduled_publish_time tells Facebook to schedule it. If you want to post immediately, set published: true and leave out the scheduled time.
This two-step flow is the only reliable way to get scheduled image posts showing in the correct state inside Business Suite. Posting directly to /photos with a scheduled time works technically, but the post ends up in a weird limbo where it does not always appear in your scheduled posts queue.
The JavaScript SDK for Authentication
We used the Facebook JavaScript SDK to handle login and token retrieval. The flow is straightforward once you accept that you are going to spend time in the App Dashboard setting things up.
You initialise the SDK with your App ID:
FB.init({
appId: 'YOUR_APP_ID',
cookie: true,
xfbml: false,
version: 'v19.0'
});
Then trigger login with the permissions you need:
FB.login(function(response) {
if (response.authResponse) {
const userToken = response.authResponse.accessToken;
FB.api('/me/accounts', function(data) {
// data.data is an array of pages
// each has an access_token field
});
}
}, { scope: 'pages_manage_posts,pages_show_list,pages_read_engagement' });
The /me/accounts call returns each page the user manages, along with that page's access token. Store that token in memory (or localStorage if you want it to persist) and use it for all subsequent API calls.
The Content Security Policy Problem
Our site runs with a fairly strict Content Security Policy set in .htaccess. When we loaded the Facebook SDK on the marketing page, the browser blocked it immediately. The error was in the console: refused to load script from connect.facebook.net because it violated the script-src directive.
The fix is to add the Facebook domains to your CSP. In .htaccess:
Header set Content-Security-Policy "script-src 'self' connect.facebook.net; \ frame-src 'self' www.facebook.com facebook.com; \ img-src 'self' data: *.fbcdn.net"
You may also need frame-src for the FB login dialog popup, and img-src additions if you are showing Facebook profile images. Add them one at a time and watch the console, rather than blanket-allowing everything.
Facebook's App Permission System
Honest assessment: it is painful. Not impossible, but genuinely poorly documented for simple use cases.
The first decision you hit is App Type. Facebook's options are Consumer, Business, or None. For a page management tool that only you will use, you want None. Consumer and Business apps go through App Review, which requires a video demo, a privacy policy URL, and Facebook's approval team. "None" type apps can use permissions in development mode without review, as long as you are a developer or tester on the app.
The permissions we needed:
pages_manage_poststo create and schedule postspages_show_listto retrieve the list of pages via/me/accountspages_read_engagementto read page data (useful for confirming the post went through)
Add these under App Settings, then under Permissions and Features. In development mode, they work immediately for any user listed as a developer or tester on the app. If you ever want to make this available to others, you would need to submit for App Review. For a private internal tool, development mode is all you need.
Auto-Marking Scheduled Posts
One feature that saves a lot of mental overhead: when we schedule a post through the tool, it stores the unix timestamp locally alongside the post record. Each time the page loads, the tool checks whether that timestamp has passed. If it has, it automatically marks the post as "Posted" in the UI without us having to manually update anything.
The logic is simple. When you schedule a post via the API, you have the timestamp. Store it. On load:
const now = Math.floor(Date.now() / 1000);
posts.forEach(post => {
if (post.scheduledTime && post.scheduledTime <= now && post.status !== 'posted') {
post.status = 'posted';
}
});
It is not checking the API to confirm the post actually went live. It trusts that Facebook did its job. For our purposes that is fine. If a post ever fails silently, we would catch it when we review the page. But in practice, scheduled posts through the Graph API are reliable.
What the UI Looks Like
The tool is card-based. Each post is a card showing the SVG post image (at 1200x630px, the standard Facebook link preview ratio), the caption, and the scheduled date. From each card you can:
- Download the image as a PNG (using a canvas render of the SVG)
- Copy the caption to clipboard
- Pick a schedule date and time
- Post to Facebook directly (fires the two-step API call)
The SVG images are designed inline in the HTML. Each one has the SO Websites branding, a headline, and a colour scheme that matches the post topic. When you click Download PNG, the SVG gets written to a canvas element and exported. That same canvas output is what gets uploaded to Facebook in step one of the posting flow.
It is not pretty in a consumer-app sense. It is a plain, functional internal tool with no framework, no build step, just HTML, vanilla JS, and a PHP proxy on the backend to keep the App Secret off the client.
Is It Worth Building?
For us, yes. We post regularly enough that the time savings add up, and we care enough about post design that having the image and caption in the same place is genuinely useful. If you post once a month, stick with Business Suite.
But if you run social content for a web agency or a brand with a consistent design system, building a private posting tool on top of the Graph API is a realistic weekend project. The API is well-documented once you find the right pages (the Page Feed reference and the Pages Publishing guide are the two you actually need). The hard part is the permissions setup, and now you know what to expect there.
If you are building something similar and want to compare notes, get in touch through our contact page. We are happy to share more detail on the implementation.