Read your Facebook and Instagram advertising into Saltcorn, using the Meta Marketing API.
This module is read only: it never creates, changes, pauses or deletes anything in your Meta ad accounts. It brings your campaigns, ad sets, ads, creatives and their performance figures into Saltcorn, where you can build your own views, dashboards and reports on top of them.
You need an access token that is allowed to read your ads. The most reliable kind is a system user token from Meta Business Manager, because it does not expire:
- In Meta Business Manager, go to Business settings → Users → System users and add a system user.
- Give that system user access to the ad account you want to read, with the View performance (analyst) role or better.
- Choose Generate new token, pick your Meta app, and tick the
ads_readpermission. - Copy the token. This is the only time it is shown.
A personal access token from the Graph API Explorer also works, but those expire within hours unless they are exchanged for a long lived token (see Keeping the token alive below).
Install the module, then open its configuration.
Credentials
| Setting | What it is for |
|---|---|
| Access token | The token from the steps above. Required. |
| App ID and App secret | Only needed if you want to refresh the token or check when it expires |
| Exchange for a long lived token | Swaps the token you pasted for one that lasts about 60 days. Leave this off for system user tokens, which already last forever. |
| Send app secret proof | Switch on if your Meta app is set to require proof of the app secret |
| API version | Which version of the Meta API to call. The default is current; change it only if Meta asks you to. |
| Maximum pages | A safety limit on how much data one request will read |
| Retries | How often to try again when Meta reports a temporary problem or a rate limit |
| Log requests | Writes every call to Meta into the server log, for troubleshooting |
Ad account
The second page lists the ad accounts your token can see. Pick the one you work with most; it is used everywhere you do not name a different account.
The module adds a table provider called Meta ads. Create a table, choose Meta ads as the provider, and the table will show live data from Meta: no copying, no synchronising, the rows are fetched when a view is opened.
First choose what the table should show:
- Ad accounts — the accounts your token can read
- Campaigns, Ad sets, Ads, Ad creatives — the contents of one ad account
- Insights — performance figures: impressions, clicks, spend and so on
For insights you also choose the date range (either one of Meta's ready made
ranges such as last_30d, or your own From and To dates), the level
(one row per account, campaign, ad set or ad), and optionally a time
increment (1 for a row per day, or monthly) and breakdowns such as
age,gender or publisher_platform.
On the second page the module shows you the rows it just read from Meta and suggests a column for each field, with a sensible type. Remove any columns you do not need, correct any types, and save. Those columns are what your views will see.
Two settings are worth knowing about:
- Cache for (seconds) — how long rows already read from Meta are reused. The default of 60 seconds keeps a busy list view from calling Meta on every click. Set it to 0 to always read fresh data.
- Maximum pages — how many requests will be made before the table stops asking for more. Raise it for large accounts, but expect views to be slower.
Tables of ad sets and ads are clever about relationships: when a view filters on a campaign or ad set, only that campaign's or ad set's rows are fetched from Meta rather than the whole account.
Meta sync copies Meta objects into an ordinary Saltcorn table, rather than reading them live. Use it when you want to keep history — Meta only keeps performance figures for a limited time — or when you want data available without waiting for Meta.
Choose the same things as for a table (object type, ad account, date range for insights), then the destination table and which of its text fields holds the Meta id. Rows are matched on that field, so running the action again updates the rows it already created rather than duplicating them. Table fields whose names match a Meta field are filled in automatically; anything named differently can be mapped by hand. You can also store the whole untouched object in a JSON field.
Tick Delete missing rows if the table should be an exact mirror, and rows deleted at Meta should disappear from Saltcorn too. Point it at a scheduled trigger — hourly or daily — to keep the copy up to date.
Refresh Meta token exchanges the stored access token for a fresh long lived one and saves it. It needs the App ID and App secret to be set. If you use an expiring token, run this from a monthly scheduled trigger so the connection never goes stale. System user tokens do not need it.
These can be used in code actions, calculated fields and formulas. Each one returns exactly what Meta returned, and each takes an optional last argument that overrides the module settings, so you can read a second ad account with a different token.
| Function | What it returns |
|---|---|
get_meta_ad_accounts(query) |
The ad accounts your token can read |
get_meta_ad_account(accountId, query) |
One ad account |
get_meta_businesses(query) |
The businesses your token can read |
get_meta_business_ad_accounts(businessId, query) |
The ad accounts a business owns |
get_meta_campaigns(accountId, query) |
The campaigns in an ad account |
get_meta_campaign(campaignId, query) |
One campaign |
get_meta_adsets(accountId, query) |
The ad sets in an ad account |
get_meta_campaign_adsets(campaignId, query) |
The ad sets in a campaign |
get_meta_adset(adSetId, query) |
One ad set |
get_meta_ads(accountId, query) |
The ads in an ad account |
get_meta_campaign_ads(campaignId, query) |
The ads in a campaign |
get_meta_adset_ads(adSetId, query) |
The ads in an ad set |
get_meta_ad(adId, query) |
One ad |
get_meta_ad_creatives(accountId, query) |
The creatives in an ad account |
get_meta_ad_creative(creativeId, query) |
One creative |
get_meta_ad_headline(ad) |
The headline of an ad, from an ad id or an ad you have already read |
get_meta_ad_body(ad) |
The primary text of an ad: the longer wording above the image |
get_meta_ad_text(ad) |
Both of the above together, as { headline, body }, in one read |
get_meta_ad_media(ad, options) |
What an ad is made of and where to download it |
get_meta_ad_media_type(ad) |
Whether an ad is an image, a video, mixed or unknown |
get_meta_ad_media_url(ad) |
The address of the picture or the film in an ad |
get_meta_page_access_token(pageId) |
A token for one of your pages, or nothing when you have no access to it |
get_meta_ad_preview(adId, adFormat) |
A ready made HTML preview of an ad |
get_meta_insights(objectId, query) |
Performance figures for an account, campaign, ad set or ad |
get_meta_insights_async(objectId, query) |
The same, run as a background report, for large date ranges |
get_meta_me(query) |
Who the token belongs to |
debug_meta_token(access_token) |
What a token is allowed to do and when it expires |
get_meta_long_lived_token(app_id, app_secret, access_token) |
A longer lasting version of a token |
meta_auth_fetch(path, query) |
Anything else: reads any Meta API address directly |
Leaving accountId or objectId empty uses the default ad account from the
module settings.
The query argument is optional and lets you say exactly what you want. For
example, the spend of every campaign over the last week:
get_meta_insights("", {
level: "campaign",
date_preset: "last_7d",
fields: "campaign_name,impressions,clicks,spend"
})
Or only the running ads, with their thumbnails:
get_meta_ads("", {
effective_status: '["ACTIVE"]',
fields: "id,name,creative{thumbnail_url}"
})
The wording of an ad is not in the ad itself, it sits on the creative, and
Meta keeps it in a different place for each kind of ad. get_meta_ad_text
finds it for you, wherever it is:
get_meta_ad_text(ad_id)
which gives you { headline: "...", body: "..." }, where the body is the
longer text above the image. For an ad that is boosting a post already on
your page, the wording belongs to the post rather than to the ad; the module
asks Meta for a token for that page and reads it with that, which needs the
access token in the settings to have a say over the page, as described under
Ads that boost a post on your page below. When it cannot be read you get
empty text back rather than an error; get_meta_ad_preview will still show
you the ad as it appears.
get_meta_ad_media tells you what kind of ad you are looking at and where to
download the file it is built on:
get_meta_ad_media(ad_id)
gives you
{
type: "video",
carousel: false,
media: [
{
kind: "video",
video_id: "1234",
url: "https://video.xx.fbcdn.net/...",
thumbnail_url: "https://scontent.xx.fbcdn.net/...",
permalink_url: "...",
length: 15
}
],
creative_id: "5678",
object_type: "VIDEO",
thumbnail_url: "https://scontent.xx.fbcdn.net/...",
from_post: false,
error: undefined
}
- type is
image,video,mixedorunknown.mixedmeans the ad offers Meta both to choose between, which is what a flexible or dynamic creative does, or a carousel with both in it.unknownmeans there is no picture or film to be found, as on a text only ad. For an ad that shows a different picture or film per placement, the leftover catch-all (see below) does not count unless it is what the feed shows, so an ad that shows pictures everywhere that matters is animagead even if a film fills in somewhere. - carousel says whether the ad holds more than one card. The cards are
the entries in
media, in the order they are shown. - media has one entry per picture or film, each with a
urlyou can download. The still that a film shows before it plays is on that film's entry asthumbnail_url, and does not count as a picture of its own. An entry that could not be reached has nourland carries anerrorsaying why instead.
In Ads Manager you can give an ad a different picture or film for, say, the
feed, stories and the right column. For these ads media comes most
important first, so the first entry is the one to look at:
- what the Facebook feed or the Instagram feed shows. When the ad has no choice of its own for the feed, that is the catch-all.
- otherwise the picture the ad is known by in Ads Manager
- what the other placements show
- the catch-all, when it is only used in placements none of the others cover
- anything the ad holds but no placement uses
Each entry of such an ad also has:
- placements, where it is shown, for example
facebook:feed,instagram:storyorfacebookfor every position on Facebook - feed, which is
truefor what the feed shows - fallback, which is
truefor the catch-all. A catch-all that is not shown in the feed and cannot be downloaded keeps its ownerror, but it does not appear in the ad'serrorwhen what the ad shows can be downloaded. - thumbnail_url is a picture of the ad as it appears. It is there even
when nothing else is, so it is worth keeping as a fallback: for a film it
is a still rather than the film itself, which is why it is not in
media. - from_post says the ad is boosting a post that was already on your page, so the media was read from the post rather than from the ad.
- error is there only when something could not be read, and says what.
An ad never stops a run over an ad set because of it: you get
unknownand the reason.
Much of what is advertised on Facebook and Instagram is a post that already exists on a page. Such an ad keeps nothing on the creative but the id of the page and of the post:
{
"object_type": "SHARE",
"object_story_spec": { "page_id": "1067...", "instagram_user_id": "1784..." },
"effective_object_story_id": "1067..._1334..."
}
The picture or the film is on the post, and Meta only shows a post to a token
that carries that page's own permissions. get_meta_ad_media asks for a
token for the page by itself and reads the post with it, so these ads work
like any other, as long as the access token in the settings has a say over
the page. That means:
- the token must have the pages_read_engagement permission, and
- the person or system user it belongs to must have a role on that page. In Business Manager, add the page to the same business as the ad account and give the system user access to it.
When that is missing you get type: "unknown" and an error saying so,
rather than silence. To check one page on its own:
get_meta_page_access_token("106755536029753")
Nothing back means the token has no say over that page. thumbnail_url is
still filled in for these ads, so you have a picture of the ad to look at
even when the post itself cannot be read.
A few things are worth knowing before you download:
- Video addresses are signed and short lived. Fetch the file as soon as
you have the address rather than storing the address for later.
permalink_urlis a stable address for watching it, not for downloading it. - Films are often uploaded to the page rather than to the ad account.
Meta only gives out the address of such a film to a token for that page,
so
get_meta_ad_mediaasks for one by itself, which works when the access token has a say over the page (see above). When it has not, the film is looked for in the ad's preview, the same one you see in Ads Manager, and the largest whole file there is taken. Such an entry saysurl_from: "preview". The preview is not an official part of the Meta API and may change, and each film found this way costs two extra reads. To leave the preview alone, pass the options{ preview_video_urls: false }. A film that cannot be found either way has its id and its thumbnail but nourl, and anerrorsaying why. - Working out the type is cheaper than finding the addresses. Every film
costs one extra read to look up. If all you want is image against video,
use
get_meta_ad_media_type, or pass the options{ resolve_urls: false }. - Pictures are often named by a hash rather than by an address. The
address is then held by the ad account's own picture library, which is
looked up for you, so reading ads of a second ad account works better when
you pass that account's id:
get_meta_ad_media(ad, { account_id: "..." }), or read the ads withaccount_idamong the fields. When the library does not hold the picture and the ad boosts a post, the post is read instead. - An ad whose creative shows no media is read again in full. You may have
asked Meta for only some of the places media can hide, so rather than
answer
unknowntoo readily it asks for all of them. Handing it an ad you read with the creative fields below saves that second read.
To go through a whole ad set:
const ads = await get_meta_adset_ads(adset_id, {
fields:
"id,name,account_id,creative{id,name,object_type,image_url,image_hash," +
"video_id,thumbnail_url,object_story_spec,asset_feed_spec," +
"effective_object_story_id}"
});
for (const ad of ads) {
const { type, media } = await get_meta_ad_media(ad);
console.log(ad.name, type, media.map((m) => m.url));
}
Asking for those creative fields is worth doing on a loop like this: given an
ad that already carries them, get_meta_ad_media works from what you have
instead of reading each ad again. Given only an ad id, it asks for them
itself.
Ads read as a table row, or by get_meta_ads without a fields of your own,
carry creative_object_type. That is a rough answer on its own: most ads
that link somewhere are reported as SHARE whether the media is a picture or
a film, so use get_meta_ad_media when it matters.
- Money is in cents. Budgets, bids and amounts spent come from Meta as
whole numbers in the smallest unit of the account's currency: a daily
budget of
5000means 50.00. Spend in performance figures is a decimal number in the account's currency. - Meta limits how much you can read. If you ask for a lot at once you may see rate limit messages; the module waits and tries again a few times by itself. Reading a large account is better done with the sync action on a schedule than with a live table.
- Deleted campaigns are hidden by default. Set the Statuses option to
include
ARCHIVEDorDELETEDif you need to see them. - Performance figures are not kept forever. Meta only serves recent history. Use the sync action if you want a permanent record.