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.
// 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 carriesgetProfiles()— the list of profiles that see this campaign.fb.profile(profileId)— the request goes through exactly this profile, with no fallbacks. The target is the profile itself plusrequest(): its fields (name,tokenAlive,onCheckpoint, …) are right on it.
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:
const profiles = fb.campaign(campaignId).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:
| Field | Meaning |
|---|---|
id | profile identifier — pass it to fb.profile(...) |
name | profile name |
group | profile group |
ownerBuyerId | the buyer who owns the profile |
tokenAlive | whether the access token is alive |
onCheckpoint | whether the profile is stuck on a Facebook security checkpoint |
hasProxy | whether 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
There is also a flat form — fb.request({ ...options, profileId }) or campaignId right in the options: the same request without building a target first.
request(options) takes one object:
| Option | Meaning |
|---|---|
url | the absolute address of the request: `https://graph.facebook.com/${campaignId}`, https://graph.facebook.com/me/adaccounts. Any *.facebook.com host works (https) — the profile's cookies and key never go to a foreign host, and answer cookies from such an address are not absorbed into the profile's session |
method | GET (default), POST or DELETE |
params | request parameters as an object; POST carries them in the body, the other methods — in the address |
headers | your own headers; they go on top of ours and may override any |
timeoutMs | how 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:
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)
| Field | Meaning |
|---|---|
status | the HTTP response code from Facebook |
contentType | the content type of the answer |
headers | the 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 |
text | the body as is, as a string — JSON.parse(res.text) |
bytes | the same body in binary — for images and downloads |
profileId | which browser profile sent the request |
There is deliberately no ok field — it could only ever be true, and would silently hide refusals. Judge the outcome by status.
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/catchif you want the loop to continue. For reads viafb.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 >= 400and the reason in the body:JSON.parse(res.text).errorcarriesmessageandcode, and whenerror_user_titleanderror_user_msgare present, that is a ready-made human wording.
A three-line helper so you do not repeat the parsing in every recipe:
function graph(res) {
if (res.status >= 400) throw new Error('Facebook refused: ' + res.status)
return res.text ? JSON.parse(res.text) : {}
}
The status check comes before parsing on purpose: Facebook does not always answer JSON — a
security-checkpoint page or a proxy stub arrives as markup, and JSON.parse on it would give a
cryptic parse error instead of a clear refusal. The wording of a JSON refusal is in the body:
JSON.parse(res.text).error.message.
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:
const rows = fb.batch(fb.profile(profileId), ids.map((id) => ({ method: 'GET', relativeUrl: `${id}?fields=name,daily_budget` })))
for (const r of rows) {
if (r.code >= 400) { console.log('code', r.code); continue }
console.log(r.data.name, r.data.daily_budget)
}
fb.batch(target, requests, { timeoutMs })(the third option is optional) — sends the requests in one exchange; slices by 50 on its own — that is Facebook's limit. The order is preserved; each row carries its owncode(a refusal iscode >= 400), the rawbodyand the parsed answer indata. A refusal of the whole batch is thrown.- A subrequest is
{ method, relativeUrl, body }; the body is a string:'daily_budget=2000'. - Changes may go in a batch, but a batch never falls back to another profile: a broken connection leaves you not knowing whether the change applied — re-read the object with a separate call.
- The sub-answers carry no binary:
bytesexists only on a single request — take images and downloads with a separate call.
Limits and the log
- Script runtime cuts
fbcalls like the rest of the code — the command is synchronous, likesqlandctx.fetch. - Per-call wait is set by the administrator in 系统 → JavaScript, the Facebook (own request via fb.*) block: a default and a ceiling. Your
timeoutMsapplies 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.
- A batch is logged as ONE record per exchange — the exchange with Facebook is one; the path column carries the list of subrequests, the answer body carries every sub-answer.
- 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
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
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:
| Field | What it is |
|---|---|
amount_spent | total spend of the account, in minor units of the account currency |
adtrust_dsl | the account's daily spend limit |
adspaymentcycle | the billing threshold; the amount sits in .data[0].threshold_amount and is divided by 100 |
account_status | the account state, as a number |
disable_reason | the reason it was disabled, as a number |
currency | the account currency |
timezone_name | the 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:
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
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
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:
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:
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
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.
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
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:
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_clicksandunique_outbound_clicksarrive as an array ofaction_type/valueobjects, not as a number — the other click fields arrive as a number in a string;- there is no top-level
landing_page_viewsfield at all — requestactionsand look foraction_type: 'landing_page_view'inside it.
Creative images — down to the binary body
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
// 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 is three dependent steps — each needs the previous answer, so it does not collapse into one request. What it does collapse into is batches, stage by stage, over a whole list of ads at once — three exchanges for all of them, not three per ad:
const ads = fb.batch(fb.campaign(adIds[0]), adIds.map((id) => ({ method: 'GET', relativeUrl: `${id}?fields=creative{id}` })))
const creativeIds = ads.filter((r) => r.code < 400).map((r) => r.data.creative.id)
const creatives = fb.batch(fb.campaign(adIds[0]), creativeIds.map((id) => ({ method: 'GET', relativeUrl: `${id}?fields=effective_object_story_id` })))
const postIds = creatives.filter((r) => r.code < 400).map((r) => r.data.effective_object_story_id)
const posts = fb.batch(fb.campaign(adIds[0]), postIds.map((id) => ({ method: 'GET', relativeUrl: `${id}?fields=full_picture` })))
for (const r of posts) if (r.code < 400) console.log(r.data.full_picture)
Pages, business managers, posts and comments
// fan pages and business managers — independent reads, one exchange
const rows = fb.batch(fb.profile(profileId), [
{ method: 'GET', relativeUrl: 'me/accounts?limit=25' },
{ method: 'GET', relativeUrl: 'me/businesses' },
])
// posts of a page
const posts = graph(fb.profile(profileId).request({ url: `https://graph.facebook.com/${pageId}/published_posts`, params: { limit: 5 } }))
// comments of ALL fresh posts — one batch for the whole list
const coms = fb.batch(fb.profile(profileId),
(posts.data || []).map((po) => ({ method: 'GET', relativeUrl: `${po.id}/comments?fields=id,message,from,created_time&limit=100` })))
// delete a comment — a change goes as a single request
fb.profile(profileId).request({ method: 'DELETE', url: `https://graph.facebook.com/${commentId}` })
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.
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.
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 = fb.batch(fb.campaign(ids[0]), ids.map((id) => ({ method: 'GET', relativeUrl: `${id}?fields=name,daily_budget` })))
for (let i = 0; i < ids.length; i++) {
if (budgets[i].code >= 400) 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.