Skip to content

About

Official Rust client for the FoPost API — schedule and publish social posts, manage accounts, media, and analytics.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

103 Commits

Folders and files

Repository files navigation

fopost

crates.io docs.rs license ci

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

[dependencies]
fopost = "0.3"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }

Async, built on reqwest. Requires Rust 1.88 or newer.

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

use fopost::{models::CreatePost, Client};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let client = Client::from_env()?; // reads FOPOST_API_KEY

    // Everything is scoped to a workspace.
    let workspaces = client.workspaces().list().await?;
    let workspace = &workspaces[0];

    let accounts = client.accounts().list(Some(&workspace.id)).await?;
    let ids: Vec<_> = accounts.iter().map(|a| a.id.clone()).collect();

    // Create a post, then publish it immediately.
    let post = client
        .posts()
        .create(&CreatePost::text(&workspace.id, "Hello from Rust").accounts(ids.clone()))
        .await?;
    client.posts().publish(&post.id, &Default::default()).await?;

    // Or schedule it for later.
    client
        .posts()
        .create(
            &CreatePost::text(&workspace.id, "Scheduled with the SDK")
                .accounts(ids)
                .schedule_at("2026-09-01T10:00:00Z"),
        )
        .await?;

    Ok(())
}

Configuration

use std::time::Duration;

let client = fopost::Client::builder()
    .api_key(std::env::var("FOPOST_API_KEY")?)
    .base_url("https://api.fopost.com/v1") // override for another deployment
    .timeout(Duration::from_secs(30))
    .max_retries(3)                        // 1 disables retrying
    .build()?;
# Ok::<(), Box<dyn std::error::Error>>(())
Env var Used for
FOPOST_API_KEY The API key Client::from_env() reads

Keys are created in the dashboard under Settings → API Keys. A key carries only the scopes granted at creation and may be bound to a single workspace, in which case naming any other workspace answers 403.

Resources

Namespace Methods
posts() list, list_all, get, create, update, delete, duplicate, publish, retry, cancel, preflight, deliveries, publish_runs, analytics, bulk, validate_import, commit_import, rollback_import
accounts() list, list_with, get, create, rename, move_to, delete, toggle_primary, validate, health, health_summary, refresh_token, analytics, communities, sync_communities, search_communities, add_community, remove_community, create_telegram_connect_code, telegram_connect_status, telegram_bot_commands, set_telegram_bot_commands, delete_telegram_bot_commands, slack_channels, slack_members, slack_identity, update_slack_identity, ice_breakers, set_ice_breakers, delete_ice_breakers, persistent_menu, set_persistent_menu, delete_persistent_menu, greeting, set_greeting, delete_greeting, webhook_subscription, resubscribe_webhook, discord_channels, switch_discord_channel, discord_identity, update_discord_identity, discord_pins, delete_discord_message, pin_discord_message, unpin_discord_message, crosspost_discord_message, create_discord_thread, send_discord_dm, discord_events, discord_event, create_discord_event, update_discord_event, delete_discord_event, discord_members, discord_member, discord_roles, create_discord_role, update_discord_role, delete_discord_role, add_discord_member_role, remove_discord_member_role, platform_metrics
account_groups() list, get, create, update, delete, set_members
workspaces() list, get, create, update, delete, analytics
labels() list, get, create, update, delete
webhooks() list, create, update, delete, test
automations() list, get, create, update, delete, toggle, runs, get_run, stats, trigger
analytics() overview, time_series, top_posts, labels, posts_table, posting_streak, demographics, collect, decay, frequency, timeline, changes, collect_post, native_posts
media() list, upload, presign, complete, upload_direct, delete
contacts() list, get, create, update, delete, conversations, import, list_fields, create_field, update_field, delete_field, conversation_analytics
broadcasts() list, get, create, update, delete, send, cancel, recipients
sequences() list, get, create, update, delete, enroll, unenroll, enrollments
inbox() list, threads, conversations, unread_count, accounts, platforms, mark_thread_read, refresh, update, edit_comment, reply, reply_with, hide, unhide, delete, like, unlike, pin, unpin, react, start_conversation, set_typing, handover, approvals, approve_reply, reject_reply
ads() list, external, boostable, connections, sources, authorize_meta, delete_connection, boost, create, refresh, set_status, delete, audiences, create_audience, search_targeting, lead_forms, create_lead_form, leads, tree, create_campaign, campaign, update_campaign, delete_campaign, duplicate_campaign, create_ad_set, ad_set, update_ad_set, delete_ad_set, duplicate_ad_set, create_network_ad, network_ad, update_network_ad, delete_network_ad, duplicate_network_ad, set_statuses, creatives, create_creative, creative, delete_creative, audience, update_audience, delete_audience, add_audience_users, estimate_reach, insights, ad_insights, lead_form, archive_lead_form, leads_feed, lead_pages, subscribe_lead_page, unsubscribe_lead_page, goals, catalogs, create_catalog, catalog, update_catalog, delete_catalog, catalog_products, write_catalog_products, product_feeds, create_product_feed, delete_product_feed, feed_uploads, start_feed_upload, product_sets, create_product_set, update_product_set, delete_product_set, reach_frequency, create_reach_frequency, reach_frequency_prediction, reserve_reach_frequency, cancel_reach_frequency, library, partnership_creators, request_partnership, revoke_partnership, account_activity, labels, create_label, update_label, delete_label, apply_label, studies, create_study, study, delete_study, ios_campaign_limits, high_demand_periods, create_high_demand_period, delete_high_demand_period, value_rule_sets, create_value_rule_set, delete_value_rule_set
knowledge() list, create, update, delete, sync, search
inbox() list, threads, conversations, unread_count, accounts, platforms, mark_thread_read, refresh, update, edit_comment, reply, reply_with, hide, unhide, delete, like, unlike, pin, unpin, react, start_conversation, set_typing, approvals, approve_reply, reject_reply
ads() list, external, boostable, connections, sources, providers, authorize, delete_connection, boost, create, refresh, set_status, delete, audiences, create_audience, search_targeting, lead_forms, create_lead_form, leads, tree, create_campaign, campaign, update_campaign, delete_campaign, duplicate_campaign, create_ad_set, ad_set, update_ad_set, delete_ad_set, duplicate_ad_set, create_network_ad, network_ad, update_network_ad, delete_network_ad, duplicate_network_ad, set_statuses, creatives, create_creative, creative, delete_creative, audience, update_audience, delete_audience, add_audience_users, add_audience_companies, estimate_reach, insights, ad_insights, lead_form, archive_lead_form, leads_feed, lead_pages, subscribe_lead_page, unsubscribe_lead_page, bid_pricing, supply_forecast, conversion_rules, create_conversion_rule, conversion_rule, update_conversion_rule, delete_conversion_rule, attach_conversion_rule, detach_conversion_rule, conversion_metrics, send_conversion_events
google_business() get_location, update_location, get_attributes, update_attributes, get_menus, replace_menus, get_services, replace_services, list_media, add_media, delete_media, list_place_actions, create_place_action, update_place_action, delete_place_action, get_verification_options, start_verification, complete_verification, get_performance, get_search_keywords, assign
validate() post, length, media
activity() list

account_groups() needs the accounts scope. inbox(), knowledge(), contacts(), broadcasts() and sequences() need the inbox scope (and publish on broadcasts().send/cancel and sequences().enroll/unenroll, which reach a platform), except contacts().conversation_analytics, which needs analytics and ads() the ads scope; boost, create, set_status, delete, set_statuses and every create, update, delete and duplicate on campaigns, ad sets and network ads on ads() spend money and also need publish. On inbox(), edit_comment, like, unlike, pin, unpin, react, start_conversation, set_typing, handover, a reply_with carrying media or quick replies, and deleting our own reply also need publish. A boost or ad starts paused unless you pass .paused(false). validate() needs the posts scope and stores nothing. On google_business() the reads need accounts and every write needs publish as well; every call fails with a 503 configuration_error until Google grants the deployment Business Profile API access, and responses relay Google's own shape as serde_json::Value.

That is every endpoint the API documents. Anything not yet wrapped is reachable through client.request(method, path, query, body), which gets the same auth, retries, and error handling.

Analytics

use fopost::models::{AnalyticsQuery, MetricChangesQuery, NativePostsQuery};

# async fn run(client: fopost::Client, post: fopost::models::Post, accounts: Vec<fopost::models::Account>) -> Result<(), fopost::Error> {
# fn save(_changes: &[fopost::models::MetricChange]) {}
// How long a post keeps earning, from the repeated readings of each post
let decay = client.analytics().decay(&AnalyticsQuery::new().days(30)).await?;
println!("{:?}", decay.half_life_bucket); // Some("1h_3h")

// Whether posting more earned more
let cadence = client.analytics().frequency(&AnalyticsQuery::new().days(90)).await?;
if let Some(best) = &cadence.best {
    println!("{}", best.label); // "3-5 a week"
}

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

// Mirror the metrics into your own store, without refetching everything
let mut query = MetricChangesQuery::new();
loop {
    let page = client.analytics().changes(&query).await?;
    save(&page.changes);
    match (page.has_more, page.cursor) {
        (true, Some(cursor)) => query = MetricChangesQuery::new().since(cursor),
        _ => break,
    }
}

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

// Posts on the account that never went out through FoPost
let native = client
    .analytics()
    .native_posts(&accounts[0].id, &NativePostsQuery::new())
    .await?;
# let _ = (timeline, native);
# Ok(())
# }

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:

# async fn run(client: fopost::Client) -> Result<(), fopost::Error> {
client.analytics().timeline("https://x.com/acme/status/1").await?;
# Ok(())
# }

Error handling

Every call returns Result<T, fopost::Error>. A non-2xx response becomes Error::Api, carrying the status, the API's machine-readable code, and the whole body.

use fopost::Error;

# async fn run(client: fopost::Client) {
match client.posts().publish("9b2f6c1e-…", &Default::default()).await {
    Ok(outcome) => println!("{} deliveries queued", outcome.deliveries().len()),
    Err(Error::Api(err)) if err.is_payment_required() => {
        println!("Out of room on this plan — upgrade at {:?}", err.upgrade_url());
    }
    Err(Error::Api(err)) if err.is_rate_limited() => {
        println!("Rate limited, retry in {:?}s", err.retry_after);
    }
    Err(err) => eprintln!("{err}"),
}
# }

Requests are rate limited per key, per minute. A 429 is retried automatically, waiting for the interval the API asks for in Retry-After, up to max_retries attempts.

Publishing safely

publish in dry-run mode, and preflight, both report what would happen without sending anything:

use fopost::models::{PublishOptions, PublishOutcome};

# async fn run(client: fopost::Client, post_id: &str) -> Result<(), fopost::Error> {
// Hard blockers per account, plus advisory content signals.
let check = client.posts().preflight(post_id).await?;
if !check.ready {
    for account in &check.accounts {
        for issue in &account.issues {
            println!("{}: {issue}", account.platform.as_deref().unwrap_or("?"));
        }
    }
}

// Or run the publish path itself without anything leaving the building.
let outcome = client.posts().publish(post_id, &PublishOptions::new().dry_run()).await?;
assert!(matches!(outcome, PublishOutcome::DryRun(_)));
# Ok(()) }

Media

Media upload and bulk CSV import post multipart bodies, which the multipart feature covers. It is on by default.

use fopost::models::{ContentBlock, CreatePost, MediaItem, MediaType, MediaUpload};

# async fn run(client: fopost::Client, workspace_id: &str) -> Result<(), fopost::Error> {
let bytes = std::fs::read("card.png").unwrap();
let uploaded = client
    .media()
    .upload(workspace_id, [MediaUpload::new("card.png", "image/png", bytes)])
    .await?;

let block = ContentBlock::text("Ship it").with_media([MediaItem::new(
    MediaType::Image,
    &uploaded[0].name,
    &uploaded[0].url,
)
.alt("A product screenshot")]);

client.posts().create(&CreatePost::new(workspace_id, [block])).await?;
# Ok(()) }

Larger files can skip the API and go straight to storage. upload_direct reserves a slot with presign, PUTs the bytes to the returned url with the returned headers and no API key, then calls complete to land the file in the library. It needs no Cargo feature.

# async fn run(client: fopost::Client, workspace_id: &str) -> Result<(), fopost::Error> {
let bytes = std::fs::read("clip.mp4").unwrap();
let uploaded = client
    .media()
    .upload_direct(workspace_id, "clip.mp4", "video/mp4", bytes)
    .await?;
println!("{}", uploaded.url);
# Ok(()) }

Cargo features

Feature Default What it does
rustls-tls yes TLS through rustls, needing no system OpenSSL
native-tls no TLS through the platform's own stack
multipart yes Media upload and bulk CSV import

To use native-tls instead, turn the defaults off and name what you want back:

fopost = { version = "0.3", default-features = false, features = ["native-tls", "multipart"] }

Forward compatibility

Response types ignore fields they do not know, and every enum keeps an Other(String) variant, so a platform or status added server-side parses on an older SDK instead of failing the whole response.

use fopost::models::Platform;

assert_eq!(Platform::InstagramBusiness.as_str(), "instagram-business");
assert_eq!(Platform::from("brand-new"), Platform::Other("brand-new".into()));

Examples

export FOPOST_API_KEY=fp_...
cargo run --example create_post -- "Hello from the Rust SDK"
cargo run --example preflight -- <post-id>

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().start_conversation(…).

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

Contributing

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

cargo fmt --check
cargo clippy --all-features --all-targets -- -D warnings
cargo test --all-features

Other SDKs

TypeScript (@fopost/sdk), Python (fopost), PHP, Laravel, and WordPress, plus ready-to-run API collections for Postman and Bruno.

License

MIT. See LICENSE.

Google Ads

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

use fopost::models::{CreateGoogleKeyword, GoogleMatchType, GoogleScope};

# async fn run(client: fopost::Client) -> fopost::Result<()> {
let scope = GoogleScope::new("c4d5e6f7-…", "1234567890");
let keywords = client.google_ads().keywords(&scope, None).await?;

client
    .google_ads()
    .create_keyword(&CreateGoogleKeyword {
        scope: scope.clone().in_workspace("7d2b8c11-…"),
        ad_group_id: "1234567890~adGroup~77".into(),
        text: "running shoes".into(),
        match_type: GoogleMatchType::Exact,
        cpc_bid_minor: None,
    })
    .await?;
# Ok(())
# }

Also keyword_ideas, keyword_metrics, search_terms, bid_strategies, ad_schedule and set_ad_schedule, the negative keyword lists, assets and asset_groups, local_services_leads, the conversion methods, and query for a raw read-only GAQL SELECT. Changes need the publish scope as well as ads; customer_id has to name an account the connection's grant reaches.

About

Official Rust client for the FoPost API — schedule and publish social posts, manage accounts, media, and analytics.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages