Skip to main content

Facebook Qubix Bridge

Facebook Qubix Bridge is the SDK that lets a user script talk to Facebook directly: the script sends its own request — to Graph API or to any other *.facebook.com address — and Qubix carries it through a browser profile you have already connected: with that profile's access token, cookies, proxy and browser signature. Facebook's answer comes back to the script as is — down to the binary body. Anything the profile is allowed to do in the ads cabinet by hand, a script can now do on a schedule: read settings and statistics, change budgets, pause and start, download creatives, clean up comments.

There is nothing to set up: a profile connected to Qubix means the bridge already works. What a request is allowed to do is decided by that profile's rights inside Facebook: if the profile cannot edit a campaign by hand, Facebook will refuse the script too, and the script sees that refusal word for word.

This is a pipe, and only a pipe: you write the address, the method, the parameters and the headers, while Qubix supplies the key, the cookies, the proxy and the browser signature. There are deliberately no ready-made campaign-management functions here — they would freeze today's shape of Facebook into the product and break at its first change. Take the high-level pieces from the samples and adjust them for yourself: the bridge knows nothing about the shape of a request, which is exactly why it does not go stale.

How a request travels

The fb command is available in user scripts — both on a schedule and via the ▶ Run button. It is not available in Britva rules.

Two ways to choose the executor profile

Every request is sent by some browser profile — the executor. You either name it yourself or let Qubix pick one.

JavaScript
// a) let Qubix pick: any profile that sees this campaign
const res = fb.campaign(campaignId).request({
method: 'GET',
url: `https://graph.facebook.com/${campaignId}`,
params: { fields: 'name,daily_budget' },
})

// b) a named profile: no picking, no fallbacks
const res2 = fb.profile(profileId).request({
method: 'GET',
url: 'https://graph.facebook.com/me',
params: { fields: 'id,name' },
})
  • fb.campaign(campaignId) — the campaign must belong to your script's data set (what your role is allowed to see). Qubix picks the executor among the profiles that see this campaign, preferring the profiles of the ad's owner. If a candidate drops out on the delivery level — a dead proxy, a broken connection — the next one is tried, but only for reads: a change (POST, DELETE) is never retried through another profile, so it cannot be applied twice. As soon as Facebook answers — even with a refusal — the search stops. The target also carries getProfiles() — the same profile list a campaign from your selection has.
  • fb.profile(profileId) — the request goes through exactly this profile, with no fallbacks. The target carries the profile's own fields too (name, tokenAlive, onCheckpoint, …), so targets are easy to tell apart when you iterate over several.

Which profiles see an object

Before sending anything, a script can ask which profiles can act on a campaign or an ad — with everything Qubix knows about their state:

JavaScript
const profiles = campaign.getProfiles() // also: ad.getProfiles() and fb.campaign(id).getProfiles()
for (const p of profiles) {
console.log(p.name, 'token alive:', p.tokenAlive, 'checkpoint:', p.onCheckpoint)
}

It returns an array: one campaign is often visible to several profiles, their access levels in Facebook differ, and the choice is yours. Each entry carries:

FieldMeaning
idprofile identifier — pass it to fb.profile(...)
nameprofile name
groupprofile group
ownerBuyerIdthe buyer who owns the profile
tokenAlivewhether the access token is alive
onCheckpointwhether the profile is stuck on a Facebook security checkpoint
hasProxywhether the profile has a proxy configured

There is deliberately no "access level" field: Qubix does not store it anywhere. What a profile may do with an object is answered by Facebook itself — send the request and read the answer. tokenAlive, onCheckpoint and hasProxy are state, not permission: a profile with a dead token stays in the list so that you see it and decide yourself. An object outside your data set returns an empty array, not an error.

The request

request(options) takes one object:

OptionMeaning
urlthe absolute address of the request: https://graph.facebook.com/${campaignId}, https://graph.facebook.com/act_123/campaigns, https://graph.facebook.com/me/adaccounts. Any *.facebook.com host works — the bridge attaches the profile's cookies and key, and they never go to a foreign host
methodGET (default), POST or DELETE
paramsrequest parameters as an object; POST carries them in the body, the other methods — in the address
headersyour own headers; they go on top of ours and may override any
timeoutMshow long to wait for the answer, in milliseconds; empty — the operator's default, and you cannot go above the ceiling

The access token, cookies, proxy and browser signature are supplied by the profile's transport itself.

The answer — and the two kinds of refusal

Facebook's answer arrives as is; Qubix does not parse it — you do:

JavaScript
const res = fb.campaign(campaignId).request({
method: 'GET',
url: `https://graph.facebook.com/${campaignId}`,
params: { fields: 'name,daily_budget' },
timeoutMs: 5000,
})

const data = JSON.parse(res.text)
if (res.status >= 400) { // Facebook's refusal — a normal answer with the reason in the body
console.log('Facebook refused:', data.error.message, 'code', data.error.code)
return
}
console.log(data.name, 'daily budget:', data.daily_budget)
FieldMeaning
okthe answer arrived and the code is a success
statusthe HTTP response code from Facebook
contentTypethe content type of the answer
headersthe response headers — all except Set-Cookie; this is where things absent from the body live: the remaining rate limits, the request identifier for a support case with Facebook
textthe body as is, as a string — JSON.parse(res.text)
bytesthe same body in binary — for images and downloads
profileIdwhich browser profile sent the request

There are two kinds of refusal, and they arrive differently:

  • Qubix could not deliver — a dead proxy, a broken connection, a campaign outside your data set — is thrown as an error: catch it with try/catch if you want the loop to continue. For reads via fb.campaign(...) the fallback profiles have already been tried by this point.
  • Facebook answered with a refusal — no rights, a wrong field, an expired session — is returned as a normal answer with res.status >= 400 and the reason in the body: JSON.parse(res.text).error carries message and code, and when error_user_title and error_user_msg are present, that is a ready-made human wording.

A three-line helper so you do not repeat the parsing in every recipe:

JavaScript
function graph(res) {
const data = res.text ? JSON.parse(res.text) : {}
if (res.status >= 400) throw new Error('Facebook: ' + ((data.error && data.error.message) || res.status))
return data
}
Do not re-read right after a change

Facebook serves reads with a delay: a value read immediately after a successful change may still be the old one. Do not treat that as a failed write. Check the new value on the next run, or remember what you set in ctx.state.

Batch: many requests in one exchange

The per-profile queue lets one request through per interval — a hundred single reads will not fit into the run's time limit. A batch pays the queue once:

JavaScript
const rows = fbBatch(fb.profile(profileId), fbReads(ids, 'name,daily_budget'))
for (const r of rows) {
if (r.code !== 200) { console.log('code', r.code); continue }
console.log(r.data.name, r.data.daily_budget)
}
  • fbBatch(target, subrequests, { timeoutMs }) — sends the subrequests in one exchange; sliced by 50 — that is Facebook's limit. A refusal of the whole batch is thrown; check each subrequest's outcome in its code, and its parsed answer in data.
  • fbReads(ids, fields) — builds read subrequests from a list of identifiers; you write the fields. A subrequest can also be built by hand: { method: 'GET', relativeUrl: 'act_123/ads?fields=name' }.

Limits and the log

  • Script runtime cuts fb calls like the rest of the code — the command is synchronous, like sql and ctx.fetch.
  • Per-call wait is set by the administrator in SystemJavaScript, the Facebook (own request via fb.*) block: a default and a ceiling. Your timeoutMs applies within the ceiling.
  • One queue per profile. Requests to one browser profile are spaced out box-wide — Britva, statistics collection and scripts share one queue, so a script cannot burn a profile with frequent calls. Need many reads — use a batch.
  • Every request is recorded in the request log on your server — including reads: time, script, executor profile, method, path, outcome and Facebook's answer. A complaint like "the script wrecked my campaigns" takes a minute to sort out by the log. Access tokens never reach the log — they are scrubbed from stored texts.

The catalog of building blocks

Every request below has been sent by Qubix in live operation and answered (verified on 27 August 2026) — you can rely on them as the base set. Anything beyond it also goes through: the bridge accepts any *.facebook.com address — Graph API and beyond — and your profile's rights decide the rest. The AI assistant in the script editor knows these blocks and will assemble a script from a task described in plain words.

Ready-made scripts built from these blocks are in Script examples.

Who am I under this profile

JavaScript
const res = fb.profile(profileId).request({ url: 'https://graph.facebook.com/me', params: { fields: 'id,name,email' } })

A refusal here means the profile's session is dead. This is the cheapest way to check session health on a schedule — earlier than the media buying notices.

Ad accounts — including money

JavaScript
const res = fb.profile(profileId).request({
url: 'https://graph.facebook.com/me/adaccounts',
params: {
limit: 50,
fields: 'name,account_id,account_status,disable_reason,currency,' +
'adspaymentcycle,adtrust_dsl,amount_spent,timezone_name',
},
})

The money lives here — Graph API has no separate "balance" endpoint:

FieldWhat it is
amount_spenttotal spend of the account, in minor units of the account currency
adtrust_dslthe account's daily spend limit
adspaymentcyclethe billing threshold; the amount sits in .data[0].threshold_amount and is divided by 100
account_statusthe account state, as a number
disable_reasonthe reason it was disabled, as a number
currencythe account currency
timezone_namethe account timezone; "today" is counted in it

Payment cards of the accounts

The same address, a different set of fields — display_string carries the mask of the attached card:

JavaScript
const res = fb.profile(profileId).request({
url: 'https://graph.facebook.com/me/adaccounts',
params: { limit: 100, fields: 'account_id,all_payment_methods{pm_credit_card{display_string}}' },
})

Campaign state and budget

JavaScript
const res = fb.campaign(campaignId).request({
url: `https://graph.facebook.com/${campaignId}`,
params: { fields: 'id,name,status,effective_status,daily_budget' },
})

daily_budget is returned in minor units of the account currency, as a string. If the budget is set on the ad sets rather than the campaign, the field is simply absent — that is a normal state, not an error.

Changing the budget

JavaScript
const res = fb.campaign(campaignId).request({
method: 'POST',
url: `https://graph.facebook.com/${campaignId}`,
params: { daily_budget: '5000' }, // minor units: 5000 = 50.00 in the account currency
})

On success Facebook answers {"success":true}. The full scenario with safety thresholds is the "Raising the budget of profitable campaigns" recipe in the examples.

Ad set settings: targeting, budgets, optimization

An ad set exposes what our reports do not have at all:

JavaScript
const res = fb.campaign(campaignId).request({
url: `https://graph.facebook.com/${adsetId}`,
params: { fields: 'name,targeting,daily_budget,lifetime_budget,optimization_goal,billing_event' },
})

The same way, one request on an ad pulls its nested objects — the campaign, the ad set and the creative at once:

JavaScript
const res = fb.campaign(campaignId).request({
url: `https://graph.facebook.com/${adId}`,
params: { fields: 'name,status,effective_status,campaign{objective,daily_budget},adset{targeting,optimization_goal},creative{id}' },
})

Pausing and starting

JavaScript
fb.campaign(campaignId).request({ method: 'POST', url: `https://graph.facebook.com/${someId}`, params: { status: 'PAUSED' } })
fb.campaign(campaignId).request({ method: 'POST', url: `https://graph.facebook.com/${someId}`, params: { status: 'ACTIVE' } })

The same form works for an ad, an ad set and a campaign — Facebook tells them apart by the identifier. Pausing a campaign cascades: its ads report effective_status: 'CAMPAIGN_PAUSED' while their own status stays unchanged. So judge whether something is actually running by effective_status, never by status.

Prefer the SDK actions for pausing ads

Pausing and returning ads, ad sets and campaigns is better done with the built-in .pause() / .activate(): they go through the same queue as Britva and keep the full pause bookkeeping. Use fb for what the SDK has no command for.

Why an ad is not delivering

JavaScript
const res = fb.campaign(campaignId).request({
url: `https://graph.facebook.com/${adId}`,
params: { fields: 'effective_status,issues_info{error_summary,error_message,level},adset{effective_status,end_time}' },
})

issues_info carries the problem in Facebook's own words; adset.end_time catches an expired ad set — the usual answer to "the ad is active but nothing spends".

Statistics straight from the cabinet

When you need a slice that Qubix reports do not have, ask Facebook itself:

JavaScript
const res = fb.campaign(campaignId).request({
url: `https://graph.facebook.com/act_${accountId}/insights`,
params: {
level: 'ad',
fields: 'ad_id,ad_name,spend,impressions,clicks',
time_range: JSON.stringify({ since: '2026-08-20', until: '2026-08-27' }),
time_increment: 1, // by day
limit: 500,
},
})

Two traps everyone hits:

  • outbound_clicks and unique_outbound_clicks arrive as an array of action_type/value objects, not as a number — the other click fields arrive as a number in a string;
  • there is no top-level landing_page_views field at all — request actions and look for action_type: 'landing_page_view' inside it.

Creative images — down to the binary body

JavaScript
const res = fb.campaign(campaignId).request({
url: `https://graph.facebook.com/act_${accountId}/adimages`,
params: { hashes: JSON.stringify([imageHash]), fields: 'url,permalink_url,hash' },
})

An fb answer can be binary too: res.bytes carries the body as is — that is how you take the files themselves, not just links to them.

Video source and the post picture

JavaScript
// the source file of a video creative
fb.campaign(campaignId).request({ url: `https://graph.facebook.com/${videoId}`, params: { fields: 'source' } })

The "ad → its picture" chain takes three requests and cannot be assembled otherwise:

JavaScript
const cr = JSON.parse(fb.campaign(campaignId).request({ url: `https://graph.facebook.com/${adId}`, params: { fields: 'creative{id}' } }).text)
const post = JSON.parse(fb.campaign(campaignId).request({ url: `https://graph.facebook.com/${cr.creative.id}`, params: { fields: 'effective_object_story_id' } }).text)
const pic = JSON.parse(fb.campaign(campaignId).request({ url: `https://graph.facebook.com/${post.effective_object_story_id}`, params: { fields: 'full_picture' } }).text)
console.log(pic.full_picture)

Pages, business managers, posts and comments

JavaScript
fb.profile(profileId).request({ url: 'https://graph.facebook.com/me/accounts' }) // fan pages
fb.profile(profileId).request({ url: 'https://graph.facebook.com/me/businesses' }) // business managers
fb.profile(profileId).request({ url: `https://graph.facebook.com/${pageId}/published_posts` }) // page posts

// post comments
const res = fb.profile(profileId).request({
url: `https://graph.facebook.com/${postId}/comments`,
params: { fields: 'id,message,from,created_time', limit: 100 },
})

// delete a comment
fb.profile(profileId).request({ method: 'DELETE', url: `https://graph.facebook.com/${commentId}` })
Qubix can clean comments on its own too

Automatic cleanup of planted links, with a log, is already built in — the Comment cleanup section. Use fb to build your own logic on top: your own word lists, your own exceptions, your own author checks.

Paging

Facebook serves long lists with cursors: while the answer carries paging.next, take paging.cursors.after and repeat the request with after in params. No paging means the end.

JavaScript
let after = null
do {
const params = { limit: 50, fields: 'name,account_id' }
if (after) params.after = after
const data = JSON.parse(fb.profile(profileId).request({ url: 'https://graph.facebook.com/me/adaccounts', params: params }).text)
if (data.error) break
for (const acc of data.data || []) console.log(acc.name)
after = data.paging && data.paging.next ? data.paging.cursors.after : null
} while (after)

If Facebook answers "Please reduce the amount of data you're asking for", that is a request, not a refusal: lower the limit and start over.

The power is in combining with the rest of the SDK

The bridge gets truly powerful together with the script's other commands: withCondition selects objects by your statistics, sql pulls any slice from the database, fb checks and changes the cabinet, ctx.state keeps memory between runs, ctx.fetch sends the result to your messenger.

JavaScript
function main() {
// 1. Your own statistics — a direct ClickHouse query: campaign profitability by FUNNEL revenue
const rows = sql`
SELECT campaign_id, sum(spend_24h) AS spend, sum(revenue_24h) AS revenue
FROM v_ads_stats
GROUP BY campaign_id
HAVING spend > 0 AND revenue / spend >= 1.5
ORDER BY revenue / spend DESC`

// 2. Live budgets of all candidates — in one exchange with Facebook
const ids = rows.map((r) => r.campaign_id)
if (!ids.length) return
const budgets = fbBatch(fb.campaign(ids[0]), fbReads(ids, 'name,daily_budget'))

for (let i = 0; i < ids.length; i++) {
if (budgets[i].code !== 200) continue
console.log(budgets[i].data.name,
'funnel ROAS:', (rows[i].revenue / rows[i].spend).toFixed(2),
'budget in the cabinet:', budgets[i].data.daily_budget)
}
}

Objects can also be selected without SQL — with withCondition: an SQL-level expression over all of the object's metrics, with parentheses, AND/OR/NOT, arithmetic and field-to-field comparison — spend_24h > 2 * geo_avg_payout, roas_24h < 0.5 * prev_roas_24h. The full field list is on the macros tab in the editor and in Metrics.

This is how you build scenarios a vendor would never get around to: your funnel decides, your profile executes.

What's next