Facebook (Pages API)
Category: Social Publishing
Integration type: Tenant OAuth (stored in integrations table)
External API: Meta Graph API v19.0
Purpose
Facebook integration covers organic social publishing to a tenant’s Facebook Page. The Social Post Writer agent generates Facebook post copy; the Social Publisher worker pushes it as a scheduled or draft post to the Page via the Graph API.
The platform also pulls Page Insights (reach, impressions, engagement) for use in the Report Writer and Anomaly Detector.
This is distinct from Meta Ads — that integration manages paid campaigns. This integration manages organic Page content.
Config Structure
OAuth flow
scope: pages_manage_posts,pages_read_engagement,pages_show_list,publish_to_groups
Stored in integrations:
provider: 'facebook'
api_key: encrypt(long_lived_page_access_token)
metadata: {
pageId: '111222333444',
pageName: 'Acme Plumbing',
accessToken: string, // Page access token (not user token)
tokenExpiresAt: string, // Long-lived page tokens don't expire, but track anyway
}Page access token vs User access token
The platform exchanges the user access token (from OAuth) for a Page access token. Page tokens are tied to the Page, not the user, and do not expire. This is the correct token to use for automated posting.
Integration Pattern
Publishing a post
class FacebookTool {
constructor(
private pageId: string,
private accessToken: string,
private baseUrl: string = 'https://graph.facebook.com/v19.0',
) {}
async publishPost(options: {
message: string; // Post copy text
link?: string; // Optional URL to attach (shows link preview)
imageUrls?: string[]; // URLs of images to attach
published?: boolean; // true = publish now; false = create as draft (default: false)
scheduledAt?: Date; // Schedule for future time (requires published: false)
}): Promise<{ postId: string; postUrl: string }> {
// If images provided, create a multi-photo post
if (options.imageUrls?.length) {
return this.publishPhotoPost(options);
}
const params: Record<string, any> = {
message: options.message,
access_token: this.accessToken,
};
if (options.link) params.link = options.link;
if (options.published === false) params.published = false;
if (options.scheduledAt) {
params.published = false;
params.scheduled_publish_time = Math.floor(options.scheduledAt.getTime() / 1000);
}
const response = await axios.post(
`${this.baseUrl}/${this.pageId}/feed`,
params,
);
return {
postId: response.data.id,
postUrl: `https://www.facebook.com/${response.data.id}`,
};
}
private async publishPhotoPost(options: {
message: string;
imageUrls: string[];
published?: boolean;
}): Promise<{ postId: string; postUrl: string }> {
// Upload each photo as unpublished, then attach to a multi-image post
const mediaIds = await Promise.all(
options.imageUrls.map(url =>
axios.post(`${this.baseUrl}/${this.pageId}/photos`, {
url,
published: false,
access_token: this.accessToken,
}).then(r => ({ media_fbid: r.data.id })),
),
);
const response = await axios.post(`${this.baseUrl}/${this.pageId}/feed`, {
message: options.message,
attached_media: mediaIds,
published: options.published ?? false,
access_token: this.accessToken,
});
return {
postId: response.data.id,
postUrl: `https://www.facebook.com/${response.data.id}`,
};
}
async getPageInsights(options: {
metrics: string[]; // e.g. ['page_impressions', 'page_engaged_users']
startDate: string; // YYYY-MM-DD
endDate: string;
period?: string; // 'day' | 'week' | 'month' (default: 'day')
}): Promise<PageInsightRow[]> {
const response = await axios.get(
`${this.baseUrl}/${this.pageId}/insights`,
{
params: {
metric: options.metrics.join(','),
since: options.startDate,
until: options.endDate,
period: options.period ?? 'day',
access_token: this.accessToken,
},
},
);
return response.data.data.map((metric: any) => ({
name: metric.name,
period: metric.period,
values: metric.values, // [{ value, end_time }]
}));
}
async getPages(): Promise<{ id: string; name: string; category: string }[]> {
const response = await axios.get(`${this.baseUrl}/me/accounts`, {
params: { access_token: this.accessToken },
});
return response.data.data.map((p: any) => ({
id: p.id,
name: p.name,
category: p.category,
}));
}
async verify(): Promise<void> {
const response = await axios.get(`${this.baseUrl}/${this.pageId}`, {
params: {
fields: 'id,name',
access_token: this.accessToken,
},
});
if (!response.data.id) throw new Error('Facebook Page not found');
}
}Social Calendar → Facebook publish workflow
Social Calendar Planner generates monthly post schedule
│
▼
Social Post Writer creates copy for each Facebook slot
│
▼ (HITL approval)
Social Publisher worker
├── Resolve tenant's Facebook integration
├── Post copy → Graph API /feed (as draft or scheduled)
└── Store postId + postUrl in social_posts.external_id + social_posts.publish_urlThe platform always creates posts as drafts or scheduled (never auto-publishes with published: true) unless the tenant has explicitly opted in to auto-publish.
Post Limitations
| Limit | Value |
|---|---|
| Max text length | 63,206 characters |
| Max images per multi-photo post | 10 |
| Scheduling window | 10 minutes to 75 days from now |
| API rate limit | 200 calls/hour per access token |
Test Cases
Unit tests (packages/tools/src/facebook.test.ts)
| Test | Approach |
|---|---|
publishPost() POSTs to /feed with correct params | Mock axios.post; assert message, access_token, published |
publishPost() sets scheduled_publish_time when scheduledAt provided | Assert Unix timestamp in params |
publishPhotoPost() uploads photos first then creates post | Assert two axios calls: photos upload then feed post |
getPageInsights() builds correct params | Assert metric, since, until, period |
getPages() returns typed page list | Mock /me/accounts response |
verify() throws when page ID not found | Mock { error: { code: 100 } }; assert throws |
Related
- Instagram Provider — same OAuth app, different endpoint
- Meta Ads Provider — paid campaigns on Facebook/Instagram
- Social Post Writer Agent — generates post copy
- Social Calendar Planner Agent — builds the publishing schedule
- Tool Integration Layer — OAuth management