Skip to main content

fopost 0.3.0 copy "fopost: ^0.3.0" to clipboard
fopost: ^0.3.0 copied to clipboard

Official Dart SDK for the FoPost API. Schedule, publish and analyze social media posts across every connected platform, from Dart or Flutter.

fopost #

pub package CI License: MIT

Official Dart SDK for the FoPost API. Schedule and publish to +30 social platforms from your code.

dart pub add fopost

Requires Dart 3.4 or newer. Pure Dart, depending only on http and meta, so the same client runs in Flutter (iOS, Android, web, desktop) and on the server. There is no code generation and no build step.

0.x release. The public API is still settling and minor versions may contain breaking changes. Pin an exact version if that matters to you.

Quick start #

import 'package:fopost/fopost.dart';

Future<void> main() async {
  final client = FoPost(apiKey: 'fp_...'); // or set FOPOST_API_KEY

  final workspace = (await client.workspaces.list()).first;
  final accounts = await client.accounts.list(workspaceId: workspace.id);

  final post = await client.posts.create(
    workspaceId: workspace.id,
    accounts: accounts.map((account) => account.id).toList(),
    content: 'Hello from Dart',
  );

  await client.posts.publish(post.id);

  client.close();
}

Get a key from Settings → API Keys in the FoPost dashboard (https://fopost.com/dashboard/settings/api-keys). Call close() when you are done with a client, so the underlying connection pool is released.

Flutter #

Add the dependency to pubspec.yaml and use it exactly as above — nothing in this package imports dart:io on a path the web can reach.

dependencies:
  fopost: ^0.3.0

Two things to know:

  • No environment on the web. FOPOST_API_KEY and FOPOST_BASE_URL are read through dart:io, which Flutter web does not have, so a web build must pass apiKey: to the constructor.
  • Keys belong on your server. An API key shipped inside a mobile or web build can be extracted from it. Call FoPost from your own backend and let the app talk to that, or restrict the key's scopes to exactly what the app needs.

Uploads take bytes rather than a path, so the same code works everywhere:

final bytes = await File('chart.png').readAsBytes();      // dart:io
final bytes = await pickedFile.readAsBytes();             // image_picker, web included

final uploaded = await client.media.upload(workspace.id, [
  FoPostFile(filename: 'chart.png', bytes: bytes),
]);

For a direct upload the bytes go straight to storage instead of through the API: uploadDirect presigns a slot, PUTs the bytes to it, and completes. presign and complete are also exposed for doing the PUT yourself:

final asset = await client.media.uploadDirect(
  workspace.id,
  'chart.png',
  'image/png',
  bytes,
);

A large file should not be held in memory. FoPostFile.stream opens the file each time it is sent, and uploadDirectFile streams it to storage:

final clip = File('launch.mp4');
final asset = await client.media.uploadDirectFile(
  workspace.id,
  FoPostFile.stream(
    filename: 'launch.mp4',
    mimeType: 'video/mp4',
    length: await clip.length(),
    open: clip.openRead,
  ),
);

Upload limits #

How large a file may be depends on the account: 100 MB with up to 2 connected accounts, 400 MB up to 7, 2 GB up to 15, 5 GB beyond that or with the Large Uploads add-on. media.limits reports the ceiling, where it comes from, and the next tier:

final limits = await client.media.limits(workspaceId: workspace.id);
print(limits.maxFileBytes);          // the largest file the account may upload
print(limits.multipartMaxFileBytes); // the most one multipart request carries per file
print(limits.nextTier?.upToAccounts);

A file over the ceiling is refused with a 413, which the SDK throws as FoPostFileTooLargeException carrying maxFileBytes, fileBytes and upgradeUrl.

Files on a post #

posts.create and posts.update take files directly, up to five, each with a mimeType. They are stored in the workspace library and attached to the first content block. Files within the multipart ceiling travel with the request itself; anything larger is uploaded direct first. Either way the post comes back the same:

final post = await client.posts.create(
  workspaceId: workspace.id,
  accounts: accountIds,
  content: 'Launch day',
  files: [
    FoPostFile(filename: 'chart.png', mimeType: 'image/png', bytes: bytes),
    FoPostFile.stream(
      filename: 'launch.mp4',
      mimeType: 'video/mp4',
      length: await clip.length(),
      open: clip.openRead,
    ),
  ],
);

On update, files passed without content are appended to the post's current first block.

Content #

content takes a String for a single post, or a list for a thread. Each entry is a String, a ContentBlock, or a raw map, and media is attached per block:

await client.posts.create(
  workspaceId: workspace.id,
  accounts: accountIds,
  content: [
    'First post in the thread',
    ContentBlock(
      text: 'Second one, with an image',
      media: [uploaded.first.toMediaItem()],
    ),
  ],
);

Scheduling and publishing #

status is 'draft' or 'scheduled'; a scheduled post needs scheduleAt. To send something out now, create it and call publish. Nothing reaches a platform without one of those two, and publish returns when delivery is queued, not when it is live.

await client.posts.create(
  workspaceId: workspace.id,
  accounts: accountIds,
  content: 'Scheduled with the SDK',
  status: PostStatus.scheduled,
  scheduleAt: DateTime.utc(2026, 9, 1, 10),
);

preflight reports per-account blockers and advisory signals without publishing, and publish(..., dryRun: true) validates the whole delivery plan:

final check = await client.posts.preflight(post.id);
for (final account in check.accounts) {
  print('${account.platform} ready=${account.ready} ${account.issues}');
}

After publishing, deliveries is the per-account state, retry re-sends only what failed, and cancel stops what has not gone out yet.

Pagination #

posts.list returns one Page with its meta. posts.stream walks every page for you:

final page = await client.posts.list(
  workspaceId: workspace.id,
  status: PostStatus.published,
  perPage: 50,
);
print('${page.meta.total} published posts');

await for (final post in client.posts.stream(workspaceId: workspace.id)) {
  print('${post.id} ${post.status}');
}

Unset parameters are not sent, so the API applies its own defaults.

Configuration #

final client = FoPost(
  apiKey: 'fp_...',
  baseUrl: 'http://localhost:8080/v1',        // point at another deployment
  timeout: const Duration(seconds: 30),       // per attempt
  maxRetries: 3,                              // total attempts, so 2 retries
  userAgent: 'my-app/2.0',                    // prefixed to the SDK's
  httpClient: myClient,                       // bring your own transport
);
Env var Used for
FOPOST_API_KEY API key, when apiKey is not passed
FOPOST_BASE_URL API root, when baseUrl is not passed

Both are read through dart:io and are simply absent on the web.

A client you pass in with httpClient is never closed by close() — close it where you created it.

Retries #

Every request is attempted up to maxRetries times, which defaults to 3, so two retries. Only these are retried:

  • 429 — waiting the interval Retry-After asks for, in either delta-seconds or HTTP-date form, capped at 60 seconds
  • 5xx
  • transport errors — a socket failure, a DNS failure, or the timeout

Everything else, including a 400 or a 404, throws on the first response, because the request itself is what needs changing. Backoff between attempts is exponential: 500ms, then 1s, capped at 60s.

Analytics #

// How long a post keeps earning, from the repeated readings of each post
final decay = await client.analytics.decay(days: 30);
print(decay.halfLifeBucket); // e.g. "1h_3h"

// Whether posting more earned more
final cadence = await client.analytics.frequency(days: 90);
print(cadence.best?.label); // e.g. "3-5 a week"

// Every reading held for one post, with what moved between them
final timeline = await client.analytics.timeline(post.id);

// Mirror the metrics into your own store, without refetching everything
String? cursor;
while (true) {
  final page = await client.analytics.changes(since: cursor);
  save(page.changes);
  if (!page.hasMore || page.cursor == null) break;
  cursor = page.cursor!.toIso8601String();
}

// Refresh one post now instead of waiting for the next collection run
await client.analytics.collectPost(post.id);

// Posts on the account that never went out through FoPost
final native = await client.analytics.nativePosts(accounts.first.id);

A post is addressed by its FoPost id or by its permalink, so a post made by hand on the network works the same way:

await client.analytics.timeline('https://x.com/acme/status/1');

Errors #

Every non-2xx response throws a subclass of FoPostException, carrying the API's statusCode, code, message, and the raw decoded body.

try {
  await client.posts.publish(postId);
} on FoPostPaymentRequiredException catch (error) {
  print('Subscription needed — upgrade at ${error.upgradeUrl}');
} on FoPostRateLimitException catch (error) {
  print('Rate limited, retry in ${error.retryAfter}');
} on FoPostException catch (error) {
  print('${error.statusCode} ${error.code}: ${error.message}');
}
Status Exception Meaning
400/422 FoPostValidationException the body did not pass validation
401 FoPostAuthenticationException missing, invalid, or expired key
402 FoPostPaymentRequiredException no active subscription, or credits exhausted
403 FoPostPermissionDeniedException valid key, but no scope or workspace access
404 FoPostNotFoundException no such resource, or outside the key's reach
429 FoPostRateLimitException over the plan's per-minute ceiling
5xx FoPostServerException the API failed to handle the request
— FoPostConnectionException the request never reached the API

error.rateLimit carries the X-RateLimit-* headers that came with the response, and error.bodyMap gives you any extra fields the API sent.

Resources #

Resource Covers
posts list, stream, get, create, update, delete, duplicate, publish, retry, cancel, preflight, deliveries, publishRuns, analytics, bulkShift, bulkLabel, bulkDelete, validateBulkImport, commitBulkImport, rollbackBulkImport
workspaces list, get, create, update, delete, analytics
accountGroups list, get, create, update, delete, setMembers
accounts list, get, create, rename, move, delete, setPrimary, validate, health, healthSummary, refreshToken, analytics, createTelegramConnectCode, getTelegramConnectStatus, getTelegramBotCommands, setTelegramBotCommands, deleteTelegramBotCommands, slackChannels, slackMembers, getSlackIdentity, updateSlackIdentity, iceBreakers, setIceBreakers, deleteIceBreakers, persistentMenu, setPersistentMenu, deletePersistentMenu, greeting, setGreeting, deleteGreeting, webhookSubscription, resubscribeWebhook, discordChannels, switchDiscordChannel, getDiscordIdentity, updateDiscordIdentity, discordPins, deleteDiscordMessage, pinDiscordMessage, unpinDiscordMessage, crosspostDiscordMessage, createDiscordThread, sendDiscordDirectMessage, discordEvents, getDiscordEvent, createDiscordEvent, updateDiscordEvent, deleteDiscordEvent, discordMembers, discordMember, discordRoles, createDiscordRole, updateDiscordRole, deleteDiscordRole, addDiscordMemberRole, removeDiscordMemberRole, platformMetrics
communities list, sync, search, add, remove
labels list, get, create, update, delete
webhooks list, create, update, delete, test
blog getSettings, updateSettings, rotateWebsiteSecret, testWebsite, listKeywords, suggestSeeds, planKeywords, addKeyword, updateKeyword, deleteKeyword, listArticles, requestArticle, getArticle, updateArticle, regenerateArticle, submitArticle, publishArticle, rejectArticle, deleteArticle
analytics overview, timeSeries, topPosts, labels, postsTable, postingStreak, demographics, collect, decay, frequency, timeline, changes, collectPost, nativePosts
automations list, get, create, update, delete, toggle, runs, run, trigger, stats
media list, limits, upload, presign, complete, uploadDirect, uploadDirectFile, delete
inbox list, threads, conversations, unreadCount, accounts, platforms, markThreadRead, refresh, listApprovals, approveReply, rejectReply, update, editComment, reply, hide, unhide, delete, like, unlike, pin, unpin, react, startConversation, setTyping, handover
contacts list, get, create, update, delete, conversations, import, listFields, createField, updateField, deleteField, conversationAnalytics
broadcasts list, get, create, update, delete, send, cancel, recipients
sequences list, get, create, update, delete, enroll, unenroll, enrollments
ads list, external, boostable, connections, sources, authorizeMeta, deleteConnection, boost, create, refresh, setStatus, delete, accountTree, createCampaign, campaign, updateCampaign, deleteCampaign, duplicateCampaign, createAdSet, adSet, updateAdSet, deleteAdSet, duplicateAdSet, createNetworkAd, networkAd, updateNetworkAd, deleteNetworkAd, duplicateNetworkAd, bulkSetStatus, creatives, createCreative, creative, deleteCreative, audiences, createAudience, audience, updateAudience, deleteAudience, addAudienceUsers, searchTargeting, estimateReach, insights, adInsights, leadForms, createLeadForm, leadForm, archiveLeadForm, leads, leadsFeed, leadPages, subscribeLeadPage, unsubscribeLeadPage, goals, catalogs, createCatalog, catalog, updateCatalog, deleteCatalog, catalogProducts, writeCatalogProducts, productFeeds, createProductFeed, deleteProductFeed, feedUploads, startFeedUpload, productSets, createProductSet, updateProductSet, deleteProductSet, reachFrequency, createReachFrequency, reachFrequencyPrediction, reserveReachFrequency, cancelReachFrequency, library, partnershipCreators, requestPartnership, revokePartnership, accountActivity, labels, createLabel, updateLabel, deleteLabel, applyLabel, studies, createStudy, study, deleteStudy, iosCampaignLimits, highDemandPeriods, createHighDemandPeriod, deleteHighDemandPeriod, valueRuleSets, createValueRuleSet, deleteValueRuleSet
knowledge list, create, update, delete, sync, search
inbox list, threads, conversations, unreadCount, accounts, platforms, markThreadRead, refresh, listApprovals, approveReply, rejectReply, update, editComment, reply, hide, unhide, delete, like, unlike, pin, unpin, react, startConversation, setTyping
ads list, external, boostable, connections, sources, providers, authorize, deleteConnection, boost, create, refresh, setStatus, delete, accountTree, createCampaign, campaign, updateCampaign, deleteCampaign, duplicateCampaign, createAdSet, adSet, updateAdSet, deleteAdSet, duplicateAdSet, createNetworkAd, networkAd, updateNetworkAd, deleteNetworkAd, duplicateNetworkAd, bulkSetStatus, creatives, createCreative, creative, deleteCreative, audiences, createAudience, audience, updateAudience, deleteAudience, addAudienceUsers, addAudienceCompanies, searchTargeting, estimateReach, insights, adInsights, leadForms, createLeadForm, leadForm, archiveLeadForm, leads, leadsFeed, leadPages, subscribeLeadPage, unsubscribeLeadPage, bidPricing, supplyForecast, conversionRules, createConversionRule, conversionRule, updateConversionRule, deleteConversionRule, attachConversionRule, detachConversionRule, conversionMetrics, sendConversionEvents
googleBusiness getLocation, updateLocation, getAttributes, updateAttributes, getMenus, replaceMenus, getServices, replaceServices, listMedia, addMedia, deleteMedia, listPlaceActions, createPlaceAction, updatePlaceAction, deletePlaceAction, getVerificationOptions, startVerification, completeVerification, getPerformance, getSearchKeywords, assign
whatsapp profile, updateProfile, requestDisplayName, setUsername, templates, templateLibrary, template, createTemplate, importTemplate, updateTemplate, deleteTemplate, groups, createGroup, group, updateGroup, deleteGroup, groupInviteLink, resetGroupInviteLink, removeGroupParticipants, blocked, blockUsers, unblockUsers, commerceSettings, updateCommerceSettings, linkCatalog, flows, flow, createFlow, updateFlow, deleteFlow, uploadFlowJson, publishFlow, deprecateFlow, flowResponses, encryptionKeyStatus, setEncryptionKey, accountEvents, sandboxSessions, createSandboxSession
blogs listBlogs, listArticles, getArticle, createArticle, updateArticle, deleteArticle, listProducts, updateProduct
validate post, length, media
activity list

For an endpoint the SDK does not wrap yet, request sends an authenticated call and hands back the decoded body as it came, envelope and all:

final body = await client.request('GET', '/platforms');

Scopes and limits #

Requests send X-API-Key. A key carries only the scopes granted when it was created: posts (which also covers publishing, deliveries, media, and validate), workspaces, accounts, labels, webhooks, analytics, automations, inbox, ads. Spending money through ads (boost, create, setStatus, delete, bulkSetStatus, and every create, update, delete and duplicate on campaigns, ad sets and network ads) needs publish as well, and anything created starts paused unless paused: false is passed. The inbox actions editComment, like, unlike, pin, unpin, react, startConversation, setTyping and handover, deleting our own reply, and a reply with mediaIds or quickReplies need publish as well. A key may also be bound to a single workspace, in which case naming any other one returns 403.

Mutating endpoints require an active subscription. Rate limits are per key, per minute, and every response carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset.

Example #

example/example.dart creates a post, previews it, and publishes it on request:

export FOPOST_API_KEY=fp_...
dart run example/example.dart "Hello from the Dart SDK" --publish

Chatbots and the inbox #

The chat adapter turns the FoPost inbox into one send/receive channel for a chatbot framework. It ships in the TypeScript and Python SDKs. There is no dedicated adapter here and no API change behind it, so the same loop is three pieces with this client:

  1. Verify the inbox.message_received webhook. The payload is ids only, on purpose, so nothing a customer wrote sits in your logs. The signing scheme is HMAC-SHA256 over {timestamp}.{body}, refused past a five minute tolerance.
  2. Read the item back with client.inbox.list(…), filtered to the payload's accountId and matched on its itemId.
  3. Answer with client.inbox.reply(item.id, text), or open a thread with client.inbox.startConversation(…).

Reading needs the inbox scope; answering needs publish as well.

Blog #

client.blog plans keywords and writes articles for a website. An article is not a post: it goes to the target in the blog settings, which is the hosted blog, a connected account, or your own site. Reads and submitArticle need the posts scope, suggestSeeds, planKeywords, requestArticle and regenerateArticle need ai and answer 402 on an empty balance, and publishArticle needs publish.

Planning is goal-driven: set goal, focusTopics and avoidTopics in the settings, refine terms with suggestSeeds, then research them with planKeywords. Results arrive as suggested keywords; setting one to planned picks it for the next free date. A finished draft is submitted with submitArticle and only then published or scheduled.

final article = await client.blog.requestArticle(
  workspaceId,
  keyword: 'best trail running shoes',
  instructions: 'Lead with the lightest pair.',
);
// It comes back `queued`; poll getArticle until it is a `draft`.
final draft = await client.blog.getArticle(workspaceId, article.id);

The website target POSTs a signed article.published webhook to your websiteUrl. verifyWebhook checks the signature over the raw bytes, refuses a timestamp more than five minutes off, and returns the envelope:

final envelope = verifyWebhook(
  rawBody: request.bodyBytes,
  headers: request.headers,
  secret: websiteSecret, // returned once by updateSettings
);
if (envelope.event == 'article.published') {
  final published = BlogArticlePublished.fromJson(envelope.data);
  // Image URLs expire in six hours, so download and store your own copies.
}

A bad signature throws FoPostWebhookSignatureException. The same helper verifies every FoPost webhook, not only the blog's.

Contributing #

Issues and pull requests are welcome at fopost/fopost-dart.

dart pub get
dart analyze --fatal-infos
dart test
dart format .

The test suite runs entirely offline against a stubbed transport, so it never touches the real API.

License #

MIT. See LICENSE.

Docs at fopost.com/docs. Questions or a problem: fopost.com/contact or GitHub issues.

Campaigns, ad groups, ads, audiences and insights are on client.ads and dispatch by connection. What only Google has is on client.googleAds:

const scope = GoogleAdsScope(
  connectionId: 'c4d5e6f7-…',
  customerId: '1234567890',
);

final keywords = await client.googleAds.keywords(scope);

await client.googleAds.createKeyword(
  const GoogleAdsScope(
    workspaceId: '7d2b8c11-…',
    connectionId: 'c4d5e6f7-…',
    customerId: '1234567890',
  ),
  adGroupId: '1234567890~adGroup~77',
  text: 'running shoes',
  matchType: 'EXACT',
);

Also keywordIdeas, keywordMetrics, searchTerms, bidStrategies, adSchedule and setAdSchedule, the negative keyword lists, assets and assetGroups, localServicesLeads, the conversion methods, and query for a raw read-only GAQL SELECT. Changes need the publish scope as well as ads; customerId has to name an account the connection's grant reaches.

0
likes
160
points
344
downloads

Documentation

Documentation
API reference

Publisher

verified publisherfopost.com

Weekly Downloads

Official Dart SDK for the FoPost API. Schedule, publish and analyze social media posts across every connected platform, from Dart or Flutter.

Homepage
Repository (GitHub)
View/report issues

Topics

#fopost #social-media #scheduling #publishing #analytics

License

MIT (license)

Dependencies

crypto, http, meta

More

Packages that depend on fopost