adlibrary.com Logoadlibrary.com
Share
Guides & Tutorials,  Platforms & Tools

Node.js Ad Intelligence Service: Wrap an Ad API in Your Stack

Wrap the AdLibrary API in a Node.js ad intelligence service: typed client, credit-aware caching, webhook-out alerts, fixtures, and Railway/Coolify deploys.

Node.js ad intelligence service architecture diagram showing data streams from ad platforms to a central API wrapper

Building a Node.js ad intelligence service sounds like a bigger project than it is. The hard part, collecting competitor ads across eleven platforms with engagement and spend signals attached, is already solved by the AdLibrary API. What's left is the part you actually want to own: a thin Node wrapper that caches responses, types the data, and pushes changes to the rest of your stack.

TL;DR: The ad API is your data layer. Your Node.js service is the opinion layer. Build a small Hono or Express app with a typed client, a response cache keyed on normalized filters, and a webhook-out diff job. Cache aggressively because every search costs a credit. Test against recorded fixtures so CI never burns credits. Deploy to Railway or Coolify with the adl_ key in a server-side secret store. API access ships with the Business plan (€329/mo, 1000+ credits).

That split matters more than any framework choice. Teams that pipe raw API responses straight into product features end up rebuilding the same filtering, deduplication, and alerting logic in three places. Teams that wrap the API once get a single internal service every other tool can call. This guide builds that service end to end: skeleton, typed client, caching, webhooks, key security, fixtures, and deploy notes for Railway and Coolify.

Why a Node.js Ad Intelligence Layer Beats Direct API Calls

You could call the ad API directly from your Next.js routes, your cron jobs, and your Slack bot. Three callers, three places to handle auth, three places to forget the rate limit.

A dedicated service fixes four problems at once.

Credit control. The AdLibrary API bills per operation. A search costs 1 credit per page. An AI enrichment costs 1 credit for text and standard video. A winners scan costs a flat 10. When five internal callers hit the API independently, nobody owns the budget. When they all go through your wrapper, one cache and one usage counter own it.

One place for types. The API returns rich JSON per ad, including creative URLs, engagement counts, impression signals, and runtime. Typing that shape once in TypeScript and exporting it beats five ad-hoc any blobs.

Rate limit discipline. The API allows 10 requests per minute and 10,000 per day per key. A single queue in your service serializes bursts. Direct callers trample each other and eat 429s.

Product semantics. Raw ad intelligence data answers "what ads exist." Your product needs opinions: "which competitor launched something new this week," "which creative has been running 60+ days." Those rules belong in your service, not scattered across consumers.

The same argument applies whether your Node.js ad intelligence service feeds an internal dashboard or a customer-facing SaaS. The ad data for AI agents use case follows the identical pattern with an LLM as the consumer.

The Data Layer: What You're Actually Wrapping

Worth being precise about the source before writing code. Meta operates the original Ad Library API, free and built for transparency. It covers political and social-issue ads worldwide, plus all ad types in the EU and UK for the last 12 months, on Meta platforms only. Google runs its own Ads Transparency Center for Google and YouTube ads. Both are legitimate and worth knowing. Meta's free API is fine for one platform. The moment you want TikTok, YouTube, or LinkedIn data in the same query, you need something else.

The AdLibrary API is that something else: a paid, commercial-grade layer that returns ads across Facebook, Instagram, TikTok, YouTube, Google, LinkedIn, Twitter, Pinterest, Yahoo, Unity Ads, and AdMob from one endpoint. Three differences matter for a service builder:

  1. More data per ad. Creative URLs, full copy, engagement counts, impressions, an estimated spend figure, and a heat score on every ad, commercial ads included. Note the word estimated: spend is modeled, and impression figures are reach signals rather than audited counts. Treat both as ranking inputs, never as billing-grade numbers. The ad intelligence data explained post breaks down each signal.
  2. Multi-platform in one call. One request fans out across platforms and returns merged, deduplicated results.
  3. Easier integration. One adl_ API key. No app review, no business verification, no 60-day token refresh dance. Compare that with the setup steps in the Meta Ad Library free API guide.

The public surface you'll wrap: POST /api/search (1 credit per page), GET /api/advertisers/search (free brand resolution), POST /api/advertisers plus POST /api/advertisers/{id}/curate (saved brands, 1 credit per 30-minute session), POST /api/enrichment (1 credit), and the winners endpoints. The full API documentation guide covers every parameter. If you need market sizing, the search response includes a total count, so one search tells you how big a keyword is.

Service Skeleton: Express or Hono

Either framework works. Express is the safe default your team already knows. Hono is smaller, faster to cold-start, and ships first-class TypeScript types, which matters if you might later run the same code on serverless. For a new internal service in 2026, Hono is the better pick.

ts
// src/app.ts
import { Hono } from "hono";
import { adsRouter } from "./routes/ads";
import { alertsRouter } from "./routes/alerts";

const app = new Hono();

app.get("/health", (c) => c.json({ ok: true }));
app.route("/ads", adsRouter);
app.route("/alerts", alertsRouter);

export default app;
ts
// src/server.ts
import { serve } from "@hono/node-server";
import app from "./app";

serve({ fetch: app.fetch, port: Number(process.env.PORT ?? 3000) }, (info) => {
  console.log(`ad-intel service on :${info.port}`);
});

The Express equivalent swaps Hono for express() and c.json for res.json. Nothing else in this article changes. What does matter is the folder shape:

src/
  client/       # typed AdLibrary API client (the only file that knows the base URL)
  cache/        # response cache + credit ledger
  routes/       # your opinionated endpoints
  jobs/         # cron-style diff jobs that emit webhooks
  fixtures/     # recorded API responses for tests

One rule keeps the architecture honest: nothing outside src/client/ may construct an upstream URL or read the API key. Every consumer goes through the client. That single chokepoint is where caching, rate limiting, and logging live.

A Typed Client for Search, Advertisers, Curate, and Enrichment

The client is around 120 lines. Type the request filters and the response envelope, then expose one method per endpoint.

ts
// src/client/types.ts
export interface SearchFilters {
  keyword?: string;
  appType: "1" | "2" | "3";          // vertical; "3" = e-commerce
  platform?: string[];                // ["facebook","instagram","tiktok",...]
  geo?: string[];                     // ISO codes, e.g. ["USA","DEU"]
  adsType?: string[];                 // "1" image, "2" video, "3" carousel, "4" collection
  sortField?: "-impression" | "-days" | "-heat_degree" | "-first_seen" | "-last_seen";
  daysBack?: 1 | 3 | 7 | 14 | 30 | 60 | 90 | 180 | 365;
  page?: number;
  pageSize?: number;
}

export interface AdCreative {
  ad_key: string;                     // stable id — dedupe and cache on this
  advertiser_name: string;
  platform: string;
  ads_type: number;                   // 1 image, 2 video, 3 carousel, 4 collection
  message?: string;
  title?: string;
  impression?: number;
  like_count?: number;
  share_count?: number;
  comment_count?: number;
  first_seen?: number;                // unix timestamp
  last_seen?: number;
  preview_img_url?: string;
  video_url?: string;
}

export interface SearchResponse {
  total: number;
  page: number;
  pageSize: number;
  results: AdCreative[];
  _credits: { used: number; remaining: number };
}
ts
// src/client/adlibrary.ts
import type { SearchFilters, SearchResponse } from "./types";

const BASE = "https://adlibrary.com/api";

async function call<T>(path: string, init: RequestInit): Promise<T> {
  const res = await fetch(`${BASE}${path}`, {
    ...init,
    headers: {
      Authorization: `Bearer ${process.env.ADLIBRARY_API_KEY}`,
      "Content-Type": "application/json",
      ...init.headers,
    },
  });
  if (res.status === 429) {
    const wait = Number(res.headers.get("Retry-After") ?? 30);
    throw new RateLimitError(wait);
  }
  if (res.status === 402) throw new CreditError(await res.json());
  if (!res.ok) throw new UpstreamError(res.status, await res.text());
  return res.json() as Promise<T>;
}

export const adlibrary = {
  search: (filters: SearchFilters) =>
    call<SearchResponse>("/search", { method: "POST", body: JSON.stringify(filters) }),

  resolveBrand: (q: string, country = "US") =>
    call(`/advertisers/search?q=${encodeURIComponent(q)}&country=${country}`, { method: "GET" }),

  saveAdvertiser: (body: { name: string; meta_page_ids?: string[]; google_advertiser_ids?: string[]; linkedin_company_ids?: string[] }) =>
    call("/advertisers", { method: "POST", body: JSON.stringify(body) }),

  curate: (id: string, cursors?: object) =>
    call(`/advertisers/${id}/curate`, { method: "POST", body: JSON.stringify(cursors ? { cursors } : {}) }),

  enrich: (ad: { ad_key: string; platform: string; [k: string]: unknown }) =>
    call("/enrichment", { method: "POST", body: JSON.stringify({ ad }) }),
};

Three design notes that save real money.

Brand resolution is free, so always resolve first. GET /api/advertisers/search maps "Gymshark" to its Meta page ID, Google advertiser ID, and LinkedIn company ID at zero cost. Never spend a search credit guessing at brand spellings.

Curate sessions have a free window. The first curate call for a saved advertiser costs 1 credit and opens a 30-minute session. Continuation calls that pass back the returned cursors are free within that window. Your client should paginate a brand's full portfolio inside one session instead of opening five.

Enrichment remembers what you unlocked. Re-enriching an ad you already paid for returns alreadyUnlocked: true and charges nothing. Store enrichment output keyed on ad_key anyway, because your own database is faster than any upstream cache.

Caching: Respect the Credits

Every search costs a credit, including a repeat of the identical query five minutes later. The API will not stop you from burning your monthly allowance on duplicate requests. The cache is the budget enforcement layer of a Node.js ad intelligence service.

The right cache key is a hash of the normalized filter object, and the right TTL depends on the question being asked.

ts
// src/cache/searchCache.ts
import { createHash } from "node:crypto";
import type { SearchFilters, SearchResponse } from "../client/types";

const store = new Map<string, { exp: number; data: SearchResponse }>();

function keyFor(filters: SearchFilters): string {
  const sorted = Object.fromEntries(Object.entries(filters).sort());
  return createHash("sha256").update(JSON.stringify(sorted)).digest("hex");
}

export async function cachedSearch(
  filters: SearchFilters,
  ttlSeconds: number,
  fetcher: (f: SearchFilters) => Promise<SearchResponse>,
): Promise<SearchResponse & { fromCache: boolean }> {
  const key = keyFor(filters);
  const hit = store.get(key);
  if (hit && hit.exp > Date.now()) return { ...hit.data, fromCache: true };

  const data = await fetcher(filters);
  store.set(key, { exp: Date.now() + ttlSeconds * 1000, data });
  return { ...data, fromCache: false };
}

TTL guidance from real usage patterns:

  • Trend queries (sortField: "-heat_degree", daysBack: 7): 6 to 12 hours. Heat moves daily, not hourly.
  • Long-runner queries (sortField: "-days"): 24 hours. An ad that's been live 80 days will still be a long-runner tomorrow.
  • Brand portfolio pulls via curate: 24 hours, refreshed by your nightly job rather than on user request.
  • Enrichment output: permanent. Persist it. The teardown of a given creative never changes.

Run the math for a typical internal tool. Ten tracked competitors, one curate session each per night, is 10 credits a day, around 300 a month. Add 20 keyword trend searches a day at 1 credit each with a 12-hour TTL and you land near 900 credits monthly. That fits the Business plan's 1000+ credit allowance with headroom, and it's why the cache is a finance feature rather than a performance one. The ad spend estimator makes a similar point about modeled budgets from the analyst's side.

Swap the Map for Redis when you run more than one instance. The interface stays identical, which is the point of hiding it behind cachedSearch.

Webhook push notification pattern for node.js ad intelligence API showing server pushing data to connected apps

The Webhook-Out Pattern: Push, Don't Poll

Dashboards that query on page load feel alive but waste credits and miss events. The stronger pattern inverts it: a scheduled job pulls once, diffs against what you've seen, and pushes changes out to consumers. This is the engine behind automated competitor ad monitoring.

ts
// src/jobs/diffAndNotify.ts
import { adlibrary } from "../client/adlibrary";
import { db } from "../db";

export async function diffAdvertiser(savedId: string, webhookUrl: string) {
  const portfolio = await adlibrary.curate(savedId);
  const ads = [...portfolio.meta.ads, ...portfolio.google.ads, ...portfolio.linkedin.ads];

  const knownKeys = await db.adKeys.forAdvertiser(savedId);
  const fresh = ads.filter((ad) => !knownKeys.has(ad.ad_key));
  if (fresh.length === 0) return;

  await db.adKeys.insert(savedId, fresh.map((a) => a.ad_key));
  await fetch(webhookUrl, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      event: "competitor.new_ads",
      advertiserId: savedId,
      count: fresh.length,
      ads: fresh.map((a) => ({
        ad_key: a.ad_key,
        advertiser: a.advertiser_name,
        platform: a.platform,
        format: a.ads_type,
        preview: a.preview_img_url,
      })),
    }),
  });
}

Schedule it nightly per tracked brand. The receiving end can be a Slack webhook, your product's notification service, or an n8n flow. The n8n Meta ads automation recipes post shows eight downstream consumers for exactly this payload, and the Zapier and Make.com equivalents cover teams on those stacks.

Two diff-quality details separate useful alerts from noise. First, diff on ad_key, the stable cross-call identifier, never on creative URLs that get re-signed. Second, batch the webhook per advertiser per run. Fifteen single-ad pings at 6am train people to mute the channel. One digest with fifteen ads gets read. The broader alerting strategy, including what counts as a meaningful change, is covered in how to monitor competitor ads.

A natural extension: when a new ad clears a runtime or engagement threshold, auto-enrich it and attach the creative brief to the webhook payload. That turns "competitor launched something" into "competitor launched this hook, here's the teardown," which is the difference between an alert and an action.

Environment and Key Security

The adl_ key is a bearer credential with a credit balance attached. Treat it like a payment token.

  • Server-side only, always. The key never ships to a browser. Don't put it behind a NEXT_PUBLIC_ prefix, and don't let it near a client bundle or a mobile app embed. Your Node service is the only holder, which is another argument for the wrapper architecture.
  • Local dev via .env, never committed. Add .env to .gitignore before the first commit, then load with Node's built-in --env-file=.env flag. One leaked key in git history is a drained credit balance.
  • Production via the platform secret store. Railway and Coolify both encrypt environment variables at rest. Set ADLIBRARY_API_KEY there, never in a Dockerfile ENV line that bakes it into the image.
  • One key per environment. The dashboard allows up to 10 keys per account. Mint separate keys for production, staging, and CI so you can rotate one without touching the others. Agencies running per-client services should scope a key per client for the same reason.
  • Never log the Authorization header. Audit your request logger. A surprising number of leaks come from verbose middleware echoing headers into log aggregators.
  • Handle 402 as a product state, not an exception. When credits run out the API returns 402 with the balance in the body. Surface that to operators ("research paused, 0 credits remaining") instead of letting jobs crash-loop.

Keys are created in the AdLibrary dashboard and shown exactly once at creation, so store the value in your secret manager in the same minute you mint it.

Testing with Recorded Fixtures

CI must never spend a credit. The clean way to guarantee that is recorded fixtures: capture a handful of real responses once, commit them, and inject them in tests.

Record once with curl, redacting nothing because responses contain no secrets:

bash
curl -s -X POST "https://adlibrary.com/api/search" \
  -H "Authorization: Bearer $ADLIBRARY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"keyword":"protein powder","appType":"3","sortField":"-days","daysBack":90}' \
  > src/fixtures/search-protein-days.json

Then test with Node's built-in test runner, injecting the fixture instead of the network:

ts
// src/cache/searchCache.test.ts
import test from "node:test";
import assert from "node:assert/strict";
import { cachedSearch } from "./searchCache";
import fixture from "../fixtures/search-protein-days.json" with { type: "json" };

test("identical filters hit the cache, not the API", async () => {
  let upstreamCalls = 0;
  const fetcher = async () => { upstreamCalls++; return fixture; };
  const filters = { keyword: "protein powder", appType: "3" as const, daysBack: 90 as const };

  const first = await cachedSearch(filters, 60, fetcher);
  const second = await cachedSearch(filters, 60, fetcher);

  assert.equal(upstreamCalls, 1);
  assert.equal(first.fromCache, false);
  assert.equal(second.fromCache, true);
});

Fixture discipline that pays off later: keep one fixture per endpoint shape (search, curate, enrichment, a 402 body, a 429), refresh them quarterly with a tagged "uses real credits" script, and assert on structure rather than values so a refreshed fixture doesn't break fifty tests. When you want fuller integration coverage, run the diff job against fixtures and assert the exact webhook payload it would emit. That single test catches most regressions in the opinion layer, which is where your bugs will actually live.

Deploying a Node.js Ad Intelligence Service: Railway and Coolify

The service is stateless except for its cache, so deployment is deliberately boring.

Railway is the fastest path for a small team. Connect the GitHub repo and Railway detects Node, builds, and deploys on push. Set ADLIBRARY_API_KEY in the service variables, point a cron schedule at your diff job (or run node-cron in-process), and wire the /health endpoint into Railway's healthcheck so bad deploys roll back. Add the Redis plugin the day you scale past one instance.

Coolify fits teams that already run their own VPS. Self-hosted on a Hetzner box, it gives you the same git-push deploy flow with no per-seat cost. Create an application from your repo, let nixpacks build it or supply a Dockerfile, set the key as a secret, and use Coolify's scheduled tasks for the nightly diff run. You own backups and OS updates, which is the trade.

Sizing notes from running this shape of service in production:

  • One instance is enough for a long time. Upstream rate limits at 10 requests per minute cap your throughput anyway. Horizontal scaling buys nothing until you have many concurrent human users, and at that point Redis replaces the in-memory Map first.
  • Memory stays small. Cached JSON for a few hundred searches is tens of megabytes. A 512 MB container is comfortable.
  • Log credit spend on every upstream call. Emit _credits.remaining from each response as a metric. A dashboard line that trends toward zero mid-month is the single most useful alert this service can produce. Pipe it next to your ROAS calculator numbers and research cost becomes a line item you can defend.

From Wrapper to Product: The Opinion Layer

With the wrapper deployed, your Node.js ad intelligence service stops being plumbing and starts being product. Everything below is a thin route over the client plus your own rules, which is the whole thesis: raw credits in, product features out.

Launch radar. The diff job already detects new ads. Add a rule that flags any competitor shipping 5+ creatives in a week, a reliable signal of a testing push. Sales teams use the same trigger in competitive intelligence workflows.

Longevity boards. A route that returns ads from your category sorted by -days, annotated with your own "proven" threshold. Long runtime is the strongest public signal an ad converts, the same heuristic every ad spy workflow relies on.

Auto-briefs. Chain search, threshold filter, and enrichment into one endpoint that turns a winning ad into a structured teardown your creative team can act on. This pattern powers the agent workflows in Claude Code + AdLibrary API, and the agentic marketing version shows it running unattended.

An MCP facade. Expose your service's routes as tools so any LLM agent in your company can query competitor ads through your cache and your credit ledger. The build your own MCP server guide does it in 60 lines of Python, and the translation to your Node service is mechanical.

Worth saying plainly: this build-on-top approach is also the honest alternative to scraping. The Meta ad library scraping tools roundup explains why scrapers break monthly and violate platform terms, and what Meta Ad Library doesn't show you covers the data gaps that no scraper fixes.

Frequently Asked Questions

What does a Node.js ad intelligence service actually do?

A Node.js ad intelligence service sits between an ad data API and your internal tools. The service holds the API key, caches responses so repeat questions cost zero credits, types the data once in TypeScript, and pushes changes out via webhooks. Consumers like dashboards, Slack bots, and AI agents call your service instead of the upstream API directly.

How many credits does a typical wrapper service consume per month?

A nightly curate session for 10 tracked competitors costs about 300 credits monthly. Add 15 to 20 cached keyword searches a day and you land between 700 and 900. The Business plan's 1000+ monthly credits cover that with headroom, and a response cache typically cuts raw demand by half or more.

Should I use Express or Hono for the service skeleton?

Both work, and the client, cache, and job code in this guide is identical under either. Hono is the better default for new services: smaller, faster cold starts, first-class TypeScript, and portable to serverless runtimes. Pick Express only when your team already maintains Express middleware it wants to reuse.

How do I keep the AdLibrary API key secure in production?

Keep it server-side only, load it from the deployment platform's encrypted variable store, and never bake it into a Docker image or client bundle. Mint separate keys for production, staging, and CI from the 10 allowed per account so any single key can be rotated without downtime, and make sure request loggers never echo the Authorization header.

Can I test the service without spending API credits?

Yes. Record real responses once with curl, commit them as JSON fixtures, and inject them in tests through a swappable fetcher. Node's built-in test runner handles the rest. CI then runs the full suite, including cache and webhook logic, at zero credit cost, and a quarterly script refreshes fixtures with real calls.

Wrap the API, Own the Opinion

A Node.js ad intelligence service is a weekend of real work: a Hono skeleton, a 120-line typed client, a cache that turns repeat questions into free answers, a diff job that pushes instead of polls, and fixtures that keep CI off the meter. None of it is clever. All of it compounds, because every future feature, from launch alerts to auto-briefs to agent tools, becomes a thin route over infrastructure you already trust.

The data layer is solved and priced. API access ships with the Business plan at €329/mo with 1000+ monthly credits, which covers a tracked-competitor roster plus daily trend searches for a typical team, and Business includes free integration help if you get stuck wiring it in. Start with the pricing page, mint a key, and put the first fixture in your repo today. Your wrapper is the part of the stack competitors can't copy.

Related Articles