Meta ads
Connect a Meta ad account to compare campaign spend with the trials, payers and revenue it brings in.
Connect a Meta (Facebook and Instagram) ad account to your project and Voidhash keeps a read-only copy of its campaigns and daily spend. It joins that spend with the people each campaign, ad set and ad brought in, and with what those people went on to do, so one report shows cost per trial, cost per payer and the return on ad spend measured on real revenue.
Voidhash never changes a campaign, budget or ad at Meta. The one thing it can send there is your purchases, if you turn on sending purchases to Meta.
Before you start
Voidhash attributes people to an ad in one of two ways, depending on where the ad sends them.
- Ads that open a web page are matched by the ad's ID in the landing page URL. Add the recommended
URL parameters to every ad and install the web SDK on the landing page, as described in
Page views and attribution. Ads whose URL parameters already
pass the ad's ID, in any parameter such as
utm_id={{ad.id}}, need no change: Voidhash looks the ID up among your synced ads to find its ad set and campaign. - App install ads on Android are matched by the ad IDs in the install referrer Google Play hands your app, once you set the install referrer key.
Without an ID from either, spend still syncs, but no people are matched to it.
You also need:
- Admin access to the business in Meta Business Manager that owns the ad account.
- An app owned by that business. Any app type works; it is only used to issue the token.
- A Voidhash user or secret key with the
project:ads:managepermission to connect the account.
Create a system user token
A system user is a non-human user of your business. Its token keeps working when people leave your team and does not need Meta's app review.
Add a system user
In Business Settings, open Users → System users and
select Add. Name it, for example Voidhash reporting, and give it the Employee role.
Give it the ad account
With the system user selected, choose Assign assets, then Ad accounts. Select the ad account and turn on View performance. Voidhash needs no other access.
Generate the token
Select Generate new token, choose your business's app, and pick the ads_read permission. For
the expiration, choose Never so the sync keeps running. Copy the token; Meta shows it once.
Find the ad account ID
In Ads Manager, open the account menu. The ad account ID is the
number shown under the account name. You can enter it with or without the act_ prefix.
Treat the token like a password. Voidhash stores it encrypted and never shows it again, in the dashboard or through the API.
Connect the account
In the dashboard, open Settings → Ad platforms and select Connect next to Meta. Enter the
token and choose the ad accounts to read. Voidhash checks that the token grants ads_read and can
read the account, then records the account's name, currency and time zone. If the check fails, the
message says what to fix.
Through the API, send the same values to
POST /api/v1/projects/{projectId}/ads/accounts,
with the ad account ID as externalId:
curl https://api.voidhash.com/api/v1/projects/$VOIDHASH_PROJECT_ID/ads/accounts \
-H "Authorization: Bearer $VOIDHASH_SECRET_KEY" \
-H "content-type: application/json" \
-d '{
"platform": "meta",
"externalId": "act_1234567890",
"accessToken": "EAAB...",
"backfillDays": 90
}'Connecting an account that is already connected replaces its token. Use this when you rotate the token or after a sync stopped. A project can connect several ad accounts.
What is synced, and how often
The first sync starts as soon as the account is connected. After it, Voidhash syncs every account at the start of every hour.
- Account health: the account's status in Meta (active, disabled, unpaid balance, grace period, under review or closed) with Meta's reason, its spending limit and how much of it is spent, and the business portfolio that owns it.
- Campaigns, ad sets and ads: IDs, names, status, budget, bid strategy, objective, the event each ad set optimizes for, start and end, where each ad sends people, and when each was created and last updated. Archived and deleted objects are included; an object Meta stops listing is marked removed and keeps its last known name.
- Creatives: format (image, video or carousel), thumbnail, video length, primary text, headline, call to action, landing URL and URL parameters, and which ads use each one.
- Daily delivery per ad: spend, impressions, clicks, link clicks, results, video plays of at
least three seconds and ThruPlays, and Meta's reported purchases with their value, app installs,
leads, landing page views, registrations, subscriptions and trial starts, for ads in every
status, including paused, archived and deleted ones. Each day is dated in the ad account's time
zone, and amounts are in its currency. Conversions are counted with the ad account's attribution
setting, the same numbers Ads Manager shows, and the setting is stored with each day. Meta
reports one conversion under several overlapping action types, so each kind counts the first
type present, starting with the cross-channel total (such as
omni_purchase), instead of adding them up. - Breakdowns: the same daily delivery split by platform (Facebook, Instagram, Audience Network, Messenger), placement, country, device, and age and gender, for the last 28 days.
- Daily account totals: the whole account's spend, impressions and clicks per day. They include spend Meta no longer reports ad by ad, such as on some deleted ads, so totals stay complete.
Reach and frequency count people, so they don't add up across ads or days. Voidhash reads them from Meta for exactly the campaigns, ad sets, ads and days you look at, and never adds them together; a total that would need adding them shows no reach.
The first sync pulls the last 90 days, or the number of days you set with backfillDays, up to
three years. Meta keeps adjusting delivery and attributed conversions for up to 28 days, so every
six hours a sync pulls the last 28 days and the breakdowns again and replaces what it stored for the
ads Meta reports. Days stored for an ad Meta no longer reports are kept. Connecting the account
again with a larger backfillDays pulls the longer history on the next sync.
The hourly syncs in between read the account and every campaign, ad set and ad, and pull daily delivery for today and yesterday. When the account has spent nothing since the previous sync, they skip delivery; conversions Meta attributes to earlier days later arrive with the next six-hourly sync.
Each account under Settings → Ad platforms → Meta shows when it last synced, the last error, and when the next sync runs.
- A sync that fails because Meta is unavailable or rate-limits the request is retried an hour later, or later still when Meta asks for a longer pause. The status shows Sync error until a sync succeeds.
- When the token is revoked or expires, or loses access to the ad account, syncing stops and the error says why. Generate a new token, select Reconnect and enter it to resume; the connected accounts are selected, and their token is replaced.
Statuses
Each campaign, ad set and ad has two statuses and the reasons behind them:
- Configured is what you set in Ads Manager: on, paused, or removed (archived or deleted).
- Delivery says whether it runs: delivering, learning (with its progress, such as 31 of the 50 optimization events Meta needs in seven days), learning limited, in review, rejected, paused, off because a parent campaign or ad set is off, scheduled, ended, not delivering, or stopped by an account issue such as missing payment information.
Reasons explain anything less than delivering: the policy Meta rejected an ad under, with Meta's explanation; issues Meta reports on the object; learning that can't finish; or, for a campaign or ad set, children that don't deliver or are rejected. An ad set whose ads all wait for review or their start date shows as in review or scheduled, not as not delivering. A campaign is shown as learning or learning limited when most of its delivering ad sets are. Voidhash only reports these states. Change them in Ads Manager; every object has a link that opens it there.
After every sync, Voidhash also lists the account's issues: rejected ads, learning-limited ad sets, objects that are on but not delivering, account problems, a spending limit that is 80% or more used, and a sync that failed.
Destinations
Each ad is marked by where it sends people:
- App store: app promotion campaigns, and ad sets that promote an app.
- Website: ads that link to a web page.
- Web-to-app: website ads that link to a page that hands people over to your app. List the
start of those pages' addresses, such as
example.app/start, as your project's web-to-app landing pages. An ad whose link is that page, or a page under it, is web-to-app. Changing the list applies to synced ads right away.
Ad sets and campaigns take their ads' destination when all of them share one, and are marked mixed when their ads go to different ones.
Voidhash also checks that every active website ad's link carries the ad's ID, either in its URL or
in its URL parameters: as ad_id, or as {{ad.id}} in any parameter. A link with only the ad
set's or the campaign's ID misses the IDs below it. The URL parameters tab of
Settings → Ad platforms → Meta reports the ads, and the share of spend, that miss them.
To disconnect an account, open its menu in Settings → Ad platforms → Meta and select Disconnect. Disconnecting an account deletes everything synced from it. Connecting, reconnecting and disconnecting are recorded in the project's audit log, without the token.
Campaign performance
The Ads → Campaigns page, the
performance report and the ads_performance_report
MCP tool report one row per campaign, ad group (Meta's ad set) or ad for a range
of days:
curl https://api.voidhash.com/api/v1/projects/$VOIDHASH_PROJECT_ID/ads/reports/performance \
-H "Authorization: Bearer $VOIDHASH_SECRET_KEY" \
-H "content-type: application/json" \
-d '{ "from": "2026-09-01", "to": "2026-09-30", "level": "campaign" }'level is campaign, ad_group or ad. You can narrow the rows with campaignIds, adGroupIds,
delivery, destination or search, split each row with a breakdown (placement, country,
age, gender, device, or surface for Voidhash's numbers), and sort them. Rows come a page
at a time: pass the previous page's pageInfo.endCursor as cursor. totals cover every matching
row, not only the page.
Each row keeps three sets of numbers apart, and none is ever added to another: what Meta reports,
in reported, what Voidhash measured from the people the ad brought in, in measured, and an
estimate of what it could not see, in modeled. Money is an integer number of minor units of the
report's currency, and rates are decimals, such as 0.0125 for 1.25%.
reported | Meaning |
|---|---|
spend, impressions | Meta's delivery for the days in the range. |
linkClicks, ctr | Link clicks, and linkClicks / impressions. |
results | What Meta counted as results, by type, such as landing_page_view or app_install. Results of different types are never added up. |
purchaseValue, platformRoas | The purchase value Meta attributes to the ad, and that value divided by spend. |
reach, frequency | Only when you ask with "include": ["reach"], which makes the report slower. Never added up, so totals have none. |
measured | Meaning |
|---|---|
visits, installs | Web visits that arrived from the ad, and acquired people your app reported. null where there is no such step. |
signups | People who signed up and whose signup is credited to the ad. |
trials, payers | Acquired people who started a trial, and those who paid anything. |
keyConversions | Acquired people who reached your key conversion, named in keyConversion. |
grossRevenue, refunds, netRevenue | What acquired people paid, what was refunded, and the difference. |
costPerSignup, costPerPayer, costPerKeyConversion | spend divided by signups, payers or keyConversions. |
roas | True return on ad spend: netRevenue / spend. |
returningPayers, returningRevenue | Returning people the ad reached who then bought, and what they spent. Never part of netRevenue or roas, since the sale can also count for the ad that first acquired them. |
modeled | Meaning |
|---|---|
netRevenueHigh | The top of the estimated range for net revenue. measured.netRevenue is the floor. |
roasHigh | netRevenueHigh / spend, the top of the range that starts at measured.roas. |
visitCoverage | The share of web clicks with a visit, row by row, so extra visits on one row cover no other's. |
A ratio is null when what it divides by is zero. Each signup is credited to the person's last
touch from an ad within your project's attribution window, else their last other touch, as in the
acquisition report (see
ad measurement for the definitions), so roas counts each person's
revenue once, on the ad credited with their signup. platformRoas uses Meta's own attribution and
can count the same purchase for several ads; the two often differ.
modeled is an upper estimate, not a measurement. Voidhash can't see every click that leads to your
site: a person who declines analytics consent, or leaves before the page loads, sends no visit, so
roas can only be as high as what was seen. The estimate assumes the clicks it couldn't see converted
like the ones it could: a row's web clicks beyond its visits, each worth the row's own net revenue per
visit once it has at least 30 visits, otherwise the average of the rows that do.
- App store ads have no estimate. A click on one opens the store, so it can't send a visit, and
its
modeledisnull. A row with both kinds of ad counts only its web clicks, but its revenue isn't split, so it can include people from its app store ads. That is one reason this is an upper estimate. - It never assigns revenue that doesn't exist. Across a report's rows the estimates add up to no more than what the people who converted in the report's range with no credited touch have paid, and scale down to that when they would pass it.
- Levels don't add up. Each level is estimated on its own, from its own visits and rates, so a campaign's estimate is not the sum of its ads'.
modeled is also null when no row of the level has 30 visits to scale from. In Studio, the
Attribution columns of Ads → Campaigns show the range beside what was measured, and the Ads
overview shows it for all campaigns together.
Reading the report requires project:ads:read. Voidhash's numbers also need
project:analytics:read: without it the report still answers, with hasMeasuredAccess set to
false and every measured and modeled set to null. Account delivery that no ad row accounts
for, usually on deleted ads, is reported as unallocated, so the totals and unallocated together
match the accounts' spend. It is null when filters narrow the rows.
By default money is reported in your team's reporting currency, chosen in Team settings. Until you
choose one, it is the connected accounts' currency, so spend lines up with Ads Manager, or USD when
they differ. Days follow the accounts' time zone, or UTC when they differ. You can ask for another
currency; spend is then converted at each day's exchange rate. When you ask for
another timezone, people and revenue follow it but spend stays dated in the account's time zone.
The report's warnings say when either happened, and when an account has not finished syncing.
Revenue comes from production purchases unless you set environment to sandbox or test. Recent
days are incomplete: spend for today is still accruing, and people acquired recently have had less
time to convert. provisionalFrom is the first day whose numbers can still change.
What changed
Each sync also records what changed since the previous one, so you can see when a decision was made and what the numbers did around it:
- Decisions: a campaign, ad set or ad was created, turned on, paused or removed, or its budget or bid strategy changed, with the values before and after. Other edits, such as names, targeting or creatives, are not recorded. A change is dated by the time Meta says the object was created or last edited, when that falls between two syncs; otherwise by the sync that found it.
- Delivery: what each ad spent, showed, got clicked and converted between two syncs, normally the hour before.
- Revisions: Meta's corrections to days that had already ended, mostly conversions it attributed late. They are neither new delivery nor the effect of a decision.
The changes report and the ads_changes_report
MCP tool return them for a period of up to 7 days, added up per campaign, ad set
or ad:
curl https://api.voidhash.com/api/v1/projects/$VOIDHASH_PROJECT_ID/ads/reports/changes \
-H "Authorization: Bearer $VOIDHASH_SECRET_KEY" \
-H "content-type: application/json" \
-d '{ "since": "2026-10-28T00:00:00Z", "level": "campaign" }'The numbers are Meta's, in each ad account's currency (minor units) and time zone. History starts
when Voidhash began keeping it for an account, shown as historySince; the first sync after that
stores where each day stood, and only later changes count. Disconnecting an account deletes its
history.
Send purchases to Meta
Meta's pixel and SDK miss purchases that happen outside the browser: renewals, purchases on another device, and people who block them. The Conversions API lets Voidhash tell Meta about those from its servers, so that Meta can count them and optimize for the people who buy. It is off until you turn it on, and needs no connected ad account.
What Meta receives
Only for people you have recorded as agreeing to it (see below), Voidhash sends each production purchase with:
- its time, its amount and currency, and a reference to the transaction;
- a SHA-256 hash of the person's email, and of the distinct ID you identified them by. An anonymous ID means nothing to Meta, so someone who was never identified is sent without one;
- their
$fbcand$fbpas they are, or, without$fbc, one built from thefbclidof the Meta ad click credited with the purchase.
Voidhash holds no user agent or page URL for a purchase, so it sends none. Purchases are sent as
Purchase events, with an action_source of system_generated for renewals and other otherwise,
since website needs both. A purchase with nothing Meta can match the person by is not sent: an
email, $fbc or $fbp. Neither are refunded purchases, or those of people erased or being erased.
Measurement counts these since Meta last took a purchase, by why, and says why when none could go.
Ask for agreement
Sending purchases to Meta is separate from analytics consent, which does not cover it. Ask the person
to agree to sharing with ad platforms, and only then set their $ad_data_sharing trait to true:
await voidhash.setAdDataSharing(true);The Swift, Kotlin and React Native SDKs have the same setAdDataSharing call. Set any other way, the
trait must be exactly true: the text "true" does not count. Setting it to
false stops purchases from then on, and when two profiles of a person merge, a false on either one
stays until you set the trait to true again. Setting it to true within five minutes of setting it to
false has no effect, so if someone changes their mind straight away, ask them again later. What Meta
already received stays with Meta, including after you
erase the person. On iOS, sending someone's purchases to Meta can be
tracking as Apple defines it, so check whether you need the App Tracking Transparency permission
first.
Set it up
- In Meta's Events Manager, select your dataset (pixel), and copy its ID.
- Under its Settings, in Conversions API, generate an access token.
- In the dashboard, open Settings → Ad platforms → Meta → Measurement, enter both, turn on Send purchases to Meta, and save.
Or, from a terminal:
voidhash ads measurement edit --dataset 1234567890 --token --enableOr through the API:
curl -X PATCH https://api.voidhash.com/api/v1/projects/$VOIDHASH_PROJECT_ID/ads/meta-measurement/settings \
-H "Authorization: Bearer $VOIDHASH_SECRET_KEY" \
-H "content-type: application/json" \
-d '{ "conversionsDatasetId": "1234567890", "conversionsToken": "'"$META_TOKEN"'", "isConversionsEnabled": true }'Changing them needs project:ads:manage, and reading them project:ads:read. The token is stored
encrypted and never shown again.
To check that it works, copy the code from the Test events tab in Events Manager and set it as the Test event code. Meta then lists what arrives under that tab and does not count it, and Measurement warns of that for as long as the code is set. Purchases sent this way are not sent again once it is removed. Only real production purchases are sent, so make one, and wait for the next delivery. Remove the code when you are done.
When purchases arrive
Voidhash sends every 15 minutes, and leaves each purchase at least 5 minutes to settle, so a purchase usually reaches Meta within 20 minutes. Each run reads up to 5,000 purchases and makes up to 64 requests, and the next goes on from there.
A purchase that is recorded before its price is known, as often happens when the SDK reports it first, waits for the price and holds back the purchases after it, for up to an hour. A purchase still without a price after that is skipped and counted as skipped, and Measurement says so. If its price comes later, it is still not sent. Prices come from your store's server notifications, so purchases that never get one mean those notifications aren't reaching Voidhash. Free trials are not purchases: they are never sent and hold nothing back.
Some purchases are never sent:
- Purchases made while it is off. Turning it on sends purchases made from then on. Turning it off and on again discards whatever had not gone yet, even if it was waiting behind a failure.
- Purchases that take too long to record. A purchase Voidhash takes longer than the 5 minutes to finish recording arrives behind the point delivery has passed, and is not sent.
Each event has an ID, transaction:<id>, by which Meta drops one it already received, so sending one
again counts it once. If you also send purchases to Meta from your own site or app, they are counted
twice, because they have other IDs. Send them one way.
When it fails
Measurement shows when Meta last took purchases, and the last error, and warns when sending has not run for over an hour. Voidhash keeps trying every 15 minutes and nothing is turned off. When a failure passes, or you fix what caused it, sending resumes from where it stopped: a new token, or access granted at Meta. Meta does not accept purchases older than 7 days, so those that waited longer are passed over and counted as skipped.
If Meta answers that it received fewer purchases than a request held, Voidhash counts that as a failure and sends them again on the next run. Meta drops the ones it already had by their ID.
When Meta refuses a request over an invalid event, Voidhash works out which purchase it was, by sending parts of the request again. That purchase is skipped for good and counted as skipped, and Measurement names it and Meta's reason, and that message stays until a newer error or a new token or dataset. The same goes for purchases passed over for lack of a price. An answer that doesn't say an event is at fault, such as a rejected token, skips nothing.
Skipping stops when something looks wrong with all of them. Once Meta has refused 10 purchases in a row and taken none, over any number of runs, nothing more is skipped: sending waits and Measurement shows the error, so that a fix can still send what is left. A purchase Meta takes starts the count over, as does a new token or dataset. While a test event code is set a refusal holds sending as well, as the code may be what Meta refuses.
Install referrer key
Android installs from Meta ads carry an encrypted referrer naming the ad. To read it, copy the Install Referrer Decryption Key from your app's settings in Meta for Developers, and enter it under Settings → Ad platforms → Meta → Measurement. Without it the install still counts for Meta, without its ad. Only the key's format can be checked when you save it, so Measurement warns when it later fails to open a referrer, which means every install from Meta is losing its ad. See Credit installs to ad campaigns.