Overview
Meta Ads Kit wraps Meta’s Graph API, but you can also call it directly using curl or your own scripts. This guide covers token management, rate limits, error codes, and retry logic.
API Version
Meta Ads Kit uses Graph API v22.0 :
https://graph.facebook.com/v22.0/
Meta releases new API versions quarterly. v22.0 is supported until Q1 2027.
Authentication
Getting Your Access Token
Meta Ads Kit uses social-cli for authentication:
# Login and grant permissions
social auth login --scopes "ads_read,ads_management,pages_read_engagement"
# Extract token from config
TOKEN = $( jq -r '.profiles.default.tokens.facebook' ~/.social-cli/config.json )
export FACEBOOK_ACCESS_TOKEN = $TOKEN
# Verify it works
curl -s "https://graph.facebook.com/v22.0/me?access_token= $FACEBOOK_ACCESS_TOKEN " | jq .
Required Scopes
Scope Purpose ads_readRead ad insights and campaign data ads_managementCreate, update, pause ads pages_read_engagementRead Facebook Page info (required for creatives)
Token Lifespan
User access tokens expire after 60 days. Check expiration:
curl -s "https://graph.facebook.com/v22.0/debug_token? \
input_token= $FACEBOOK_ACCESS_TOKEN \
&access_token= $FACEBOOK_ACCESS_TOKEN " | jq '.data | {valid, expires_at, scopes}'
Output:
{
"valid" : true ,
"expires_at" : 1751328000 ,
"scopes" : [
"ads_read" ,
"ads_management" ,
"pages_read_engagement"
]
}
If expires_at is within 7 days, re-authenticate:
Ad Account Setup
List Your Ad Accounts
curl -s "https://graph.facebook.com/v22.0/me/adaccounts? \
fields=id,name,account_status,currency \
&access_token= $FACEBOOK_ACCESS_TOKEN " | jq '.data[]'
Output:
{
"id" : "act_123456789" ,
"name" : "My Ad Account" ,
"account_status" : 1 ,
"currency" : "USD"
}
Set Default Account
export META_AD_ACCOUNT = "act_123456789"
Store in workspace/brand/stack.md to avoid setting every time:
ad_account: act_123456789
default_adset_id: 23847000000001
target_cpa: 25.00
Core Endpoints
Upload Image
IMAGE_PATH = "/path/to/image.jpg"
IMAGE_NAME = "hero-v1.jpg"
curl -s \
-F "filename= $IMAGE_NAME " \
-F "source=@ $IMAGE_PATH " \
"https://graph.facebook.com/v22.0/ $META_AD_ACCOUNT /adimages?access_token= $FACEBOOK_ACCESS_TOKEN " \
| jq .
Response:
{
"images" : {
"hero-v1.jpg" : {
"hash" : "a1b2c3d4e5f6789abc123def456" ,
"url" : "https://www.facebook.com/ads/image/?d=..." ,
"width" : 1080 ,
"height" : 1080
}
}
}
Extract hash:
HASH = $( curl -s \
-F "filename= $IMAGE_NAME " \
-F "source=@ $IMAGE_PATH " \
"https://graph.facebook.com/v22.0/ $META_AD_ACCOUNT /adimages?access_token= $FACEBOOK_ACCESS_TOKEN " \
| jq -r ".images[ \" $IMAGE_NAME \" ].hash" )
echo "Image hash: $HASH "
Create Ad Creative
PAGE_ID = "YOUR_FACEBOOK_PAGE_ID"
IMAGE_HASH = "a1b2c3d4e5f6789abc123def456"
curl -s \
-X POST \
"https://graph.facebook.com/v22.0/ $META_AD_ACCOUNT /adcreatives" \
-H "Content-Type: application/json" \
-d '{
"name": "Summer Sale - Hero v1",
"object_story_spec": {
"page_id": "'" $PAGE_ID "'"
},
"asset_feed_spec": {
"bodies": [
{"text": "Stop losing receipts. Snap a photo and let AI handle the rest. Simple expense tracking that actually works."},
{"text": "Track expenses in seconds. No manual entry, no categories. Just snap, and AI files it. Built for people who hate budgeting apps."}
],
"titles": [
{"text": "AI That Reads Your Receipts"},
{"text": "Never Lose a Receipt Again"}
],
"descriptions": [
{"text": "Snap. AI logs it. Done."}
],
"images": [
{"hash": "'" $IMAGE_HASH "'"}
],
"call_to_actions": [
{
"type": "LEARN_MORE",
"value": {
"link": "https://yoursite.com/lp",
"link_caption": "yoursite.com"
}
}
],
"optimization_type": "DEGREES_OF_FREEDOM"
},
"degrees_of_freedom_spec": {
"creative_features_spec": {
"standard_enhancements": {"enroll_status": "OPT_IN"}
}
},
"access_token": "'" $FACEBOOK_ACCESS_TOKEN "'"
}' \
| jq .
Response:
{
"id" : "23847293847293847"
}
Create Ad
ADSET_ID = "23847000000001"
CREATIVE_ID = "23847293847293847"
curl -s \
-X POST \
"https://graph.facebook.com/v22.0/ $META_AD_ACCOUNT /ads" \
-H "Content-Type: application/json" \
-d '{
"name": "Summer Sale - Hero v1",
"adset_id": "'" $ADSET_ID "'",
"creative": {
"creative_id": "'" $CREATIVE_ID "'"
},
"status": "PAUSED",
"access_token": "'" $FACEBOOK_ACCESS_TOKEN "'"
}' \
| jq .
Response:
{
"id" : "23847111111111111"
}
Always create ads in PAUSED status. Review in Ads Manager before activating.
Get Ad Insights
AD_ID = "23847111111111111"
curl -s "https://graph.facebook.com/v22.0/ $AD_ID /insights? \
fields=impressions,clicks,ctr,spend,cpc \
&date_preset=last_7d \
&access_token= $FACEBOOK_ACCESS_TOKEN " | jq .
Response:
{
"data" : [
{
"impressions" : "12543" ,
"clicks" : "342" ,
"ctr" : "2.727" ,
"spend" : "125.43" ,
"cpc" : "0.367" ,
"date_start" : "2026-02-26" ,
"date_stop" : "2026-03-04"
}
]
}
Update Ad Status
AD_ID = "23847111111111111"
# Activate
curl -s \
-X POST \
"https://graph.facebook.com/v22.0/ $AD_ID " \
-d "status=ACTIVE&access_token= $FACEBOOK_ACCESS_TOKEN " \
| jq .
# Pause
curl -s \
-X POST \
"https://graph.facebook.com/v22.0/ $AD_ID " \
-d "status=PAUSED&access_token= $FACEBOOK_ACCESS_TOKEN " \
| jq .
Rate Limits
Meta uses score-based rate limiting , not a simple per-minute cap. Each API call consumes a score based on complexity:
Call Type Approximate Score Read single object 1 Read insights 5-10 Create ad 10 Batch request Sum of all calls
Your rate limit budget refreshes over time. If you exceed it, you get error 294.
Every API response includes rate limit headers:
curl -i "https://graph.facebook.com/v22.0/me?access_token= $FACEBOOK_ACCESS_TOKEN "
Look for:
x-business-use-case-usage: {"YOUR_AD_ACCOUNT_ID":[{"type":"ads_management","call_count":50,"total_cputime":30,"total_time":45,"estimated_time_to_regain_access":5}]}
Field Meaning call_countAPI calls made in current window total_cputimeCPU time consumed (seconds) estimated_time_to_regain_accessMinutes until limit resets
If estimated_time_to_regain_access > 0, you’re rate limited. Wait before making more calls.
Handling Rate Limit Errors (294)
# Simple retry wrapper with exponential backoff
upload_with_retry () {
local max_retries = 3
local wait = 10
for i in $( seq 1 $max_retries ); do
result = $( eval " $@ " )
if echo " $result " | jq -e '.error.code == 294' > /dev/null 2>&1 ; then
echo "Rate limited. Waiting ${ wait }s (attempt $i / $max_retries )..." >&2
sleep $wait
wait = $(( wait * 2 ))
else
echo " $result "
return 0
fi
done
echo "Max retries hit. Last response: $result " >&2
return 1
}
# Use it:
upload_with_retry "curl -s -X POST https://graph.facebook.com/v22.0/ $META_AD_ACCOUNT /ads -d '...'"
Best Practices
Batch requests - Upload multiple images in one call instead of separate calls
Add delays - Wait 1-2 seconds between calls in loops
Monitor headers - Check x-business-use-case-usage to see when you’re approaching limits
Retry with backoff - Wait 10s, then 20s, then 40s on error 294
Use webhooks - For insights, set up webhooks instead of polling
Error Codes
Common Errors
Meaning: Access token expired or revokedCheck token validity: curl -s "https://graph.facebook.com/v22.0/debug_token? \
input_token= $FACEBOOK_ACCESS_TOKEN \
&access_token= $FACEBOOK_ACCESS_TOKEN " | jq '.data.valid'
Fix: social auth login
export FACEBOOK_ACCESS_TOKEN = $( jq -r '.profiles.default.tokens.facebook' ~/.social-cli/config.json )
Meaning: Token missing required scopeFix: Re-authenticate with correct scopessocial auth login --scopes "ads_read,ads_management,pages_read_engagement"
Meaning: Exceeded API rate limitFix: Wait and retry with exponential backoff# Check how long to wait
curl -i "https://graph.facebook.com/v22.0/me?access_token= $FACEBOOK_ACCESS_TOKEN " \
| grep -i x-business-use-case-usage
# Wait for estimated_time_to_regain_access minutes
sleep 60 # Wait 1 minute, then retry
Meaning: Malformed request or invalid field valueCommon causes:
Invalid CTA type (must be LEARN_MORE, SHOP_NOW, etc.)
Missing required field (page_id, adset_id)
Wrong data type (string instead of integer)
Fix: Check error message for specific field:{
"error" : {
"code" : 100 ,
"message" : "Invalid parameter" ,
"error_subcode" : 1487195 ,
"error_user_title" : "Invalid Call-To-Action Type" ,
"error_user_msg" : "The call-to-action type 'click here' is not valid"
}
}
Meaning: Creative rejected by Meta’s ad policiesCommon causes:
Image has >20% text overlay
Prohibited content (alcohol, before/after weight loss, etc.)
Misleading imagery
Fix: Meaning: Image hash expired or doesn’t existFix: Re-upload image to get fresh hashHASH = $( curl -s \
-F "filename=image.jpg" \
-F "source=@/path/to/image.jpg" \
"https://graph.facebook.com/v22.0/ $META_AD_ACCOUNT /adimages?access_token= $FACEBOOK_ACCESS_TOKEN " \
| jq -r '.images["image.jpg"].hash' )
Meaning: Account flagged for suspicious activityFix:
Check Ads Manager for notifications
Review account quality: https://www.facebook.com/adsmanager/account_quality
May need to verify identity or wait 24-48 hours
This is a serious error. Stop making API calls and review your account status.
All Graph API errors follow this structure:
{
"error" : {
"code" : 294 ,
"message" : "Managing ads over rate limit" ,
"error_subcode" : 2446079 ,
"error_user_title" : "Rate Limit Exceeded" ,
"error_user_msg" : "Too many calls to the Marketing API. Please retry in a few minutes." ,
"fbtrace_id" : "A1B2C3D4E5F6"
}
}
Field Purpose codeMain error code (see table above) messageTechnical description error_subcodeSpecific error variant error_user_titleUser-friendly title error_user_msgUser-friendly description fbtrace_idReference for Meta support
Retry Logic
Exponential Backoff Strategy
#!/bin/bash
retry_with_backoff () {
local max_retries = 5
local timeout = 1
local attempt = 1
local exitCode = 0
while [ $attempt -le $max_retries ]; do
set +e
" $@ "
exitCode = $?
set -e
if [ $exitCode -eq 0 ]; then
return 0
fi
echo "Attempt $attempt failed. Retrying in ${ timeout }s..." >&2
sleep $timeout
attempt = $(( attempt + 1 ))
timeout = $(( timeout * 2 ))
done
echo "All $max_retries attempts failed." >&2
return $exitCode
}
# Usage:
retry_with_backoff curl -s -X POST "https://graph.facebook.com/v22.0/ $META_AD_ACCOUNT /ads" -d '...'
Error-Specific Retry
call_api () {
local response
response = $( curl -s -X POST " $@ " )
local error_code
error_code = $( echo " $response " | jq -r '.error.code // empty' )
case $error_code in
294 )
echo "Rate limited. Waiting 60s..." >&2
sleep 60
return 1 # Retry
;;
190 )
echo "Token expired. Please re-authenticate." >&2
return 2 # Don't retry
;;
100 )
echo "Invalid parameter: $( echo " $response " | jq -r '.error.message')" >&2
return 2 # Don't retry
;;
"" )
echo " $response " # Success
return 0
;;
*)
echo "Unknown error $error_code : $( echo " $response " | jq -r '.error.message')" >&2
return 2 # Don't retry unknown errors
;;
esac
}
# Retry only on rate limit errors
until call_api "https://graph.facebook.com/v22.0/ $META_AD_ACCOUNT /ads" -d '...' ; do
[ $? -eq 2 ] && break # Exit on non-retryable error
done
Debugging
Verbose Output
# Show full request/response
curl -v \
-X POST \
"https://graph.facebook.com/v22.0/ $META_AD_ACCOUNT /ads" \
-H "Content-Type: application/json" \
-d '{...}' \
2>&1 | tee debug.log
Save Requests for Replay
# Save request to file
cat > create-ad-request.json << EOF
{
"name": "Test Ad",
"adset_id": " $ADSET_ID ",
"creative": {"creative_id": " $CREATIVE_ID "},
"status": "PAUSED",
"access_token": " $FACEBOOK_ACCESS_TOKEN "
}
EOF
# Replay request
curl -s -X POST \
"https://graph.facebook.com/v22.0/ $META_AD_ACCOUNT /ads" \
-H "Content-Type: application/json" \
-d @create-ad-request.json \
| jq .
Test Token Validity
# Quick test
curl -f -s "https://graph.facebook.com/v22.0/me?access_token= $FACEBOOK_ACCESS_TOKEN " > /dev/null
if [ $? -eq 0 ]; then
echo "Token valid"
else
echo "Token invalid or expired"
fi
Inspect Creative Before Creating Ad
CREATIVE_ID = "23847293847293847"
curl -s "https://graph.facebook.com/v22.0/ $CREATIVE_ID ? \
fields=id,name,status,asset_feed_spec,object_story_spec \
&access_token= $FACEBOOK_ACCESS_TOKEN " | jq .
Next Steps
OpenClaw Integration Natural language ad management
Batch Operations Upload multiple ads at once