Static Ads Lab
Concepts

Swipe file

A searchable library of reference images. Upload competitor ads and inspiration, then find them by natural-language search.

A swipe file is a lightweight, org-scoped library of static reference images — competitor ads, inspiration, anything you want to point at later. Each upload is embedded and auto-classified so you can retrieve it with filtered vector search. Unlike design templates, swipe files are just the image plus metadata — no JSON tree, no reverse-engineering pipeline.

Mental model

Use a swipe file when you want to stash a reference image and find it fast, without turning it into a structured, editable end-product. On upload, each image is:

  1. Stored (compressed, with a thumbnail).
  2. Embedded with a multimodal model into a shared text/image vector space.
  3. Auto-classified into one industry and one ad_format from our standard taxonomies.

You then retrieve images with filtered vector search: a text query is embedded and cosine-matched against the image embeddings, optionally narrowed by industry, ad_format, and aspect_ratio.

Your library is the union of your own uploads and the data sources your organization is subscribed to — curated collections managed by Static Ads Lab that you can toggle on or off at any time.

Swipe files are free — there is no per-image charge.

ID prefix: sf_ (swipe files), sfs_ (data sources).

Fields

FieldTypeDescription
idstringsf_…
image_urlstringURL of the stored image
thumbnail_urlstring | nullURL of a smaller thumbnail
width / heightnumber | nullPixel dimensions
aspect_ratiostring | nulle.g. "1:1", "4:5", "9:16"
industrystring | nullAuto-classified Shopify L1 category
ad_formatstring | nullAuto-classified creative format
sourceenumupload | import | studio_concept | sal_managed
is_managedbooleanTrue for designs from a managed data source (read-only)
statusenumprocessing | ready | failed
errorstring | nullFailure reason when status is failed
created_at / updated_atstringISO timestamps

Lifecycle

stateDiagram-v2
  [*] --> processing: POST /v1/swipe-files (multipart upload)
  processing --> ready: embed + classify succeed
  processing --> failed: embedding failed
  ready --> [*]
  failed --> [*]

Ingest is asynchronous. The upload returns immediately with status: "processing"; the item becomes ready once its embedding and classification finish. A swipe file only appears in search results once it is ready. If classification degrades, the item still becomes ready with industry/ad_format left null — only an embedding failure marks it failed.

Endpoints

MethodPathPurpose
POST/v1/swipe-filesUpload a reference image (multipart)
GET/v1/swipe-filesList swipe files with filters
GET/v1/swipe-files/:idGet a swipe file
DELETE/v1/swipe-files/:idArchive (soft-delete) a swipe file
POST/v1/swipe-files/:id/restoreRestore an archived swipe file
POST/v1/swipe-files/searchFiltered vector search
GET/v1/swipe-files/data-sourcesList data sources with subscription state
PUT/v1/swipe-files/data-sources/:id/subscriptionEnable or disable a data source

Data sources

Beyond your own uploads, your swipe file can pull from data sources: shared, Static Ads Lab-curated collections of reference ads. New organizations are automatically subscribed to the default collection, so your library is stocked from day one. Subscriptions are per-organization and idempotent to toggle.

  • Designs from a subscribed source appear in your list and search results alongside your own uploads, marked with is_managed: true and source: "sal_managed".
  • Managed designs are read-only: archiving, restoring, or deleting them returns SWIPE_FILE_READ_ONLY. To hide them, disable the source.
  • Data sources are free — the curated content adds no charges to your account.

List available data sources

const response = await fetch("https://api.staticadslab.com/v1/swipe-files/data-sources", {
  headers: { "X-API-Key": "YOUR_API_KEY" },
});
const { data } = await response.json();
for (const source of data) {
  console.log(source.id, source.name, source.subscribed, source.item_count);
}
// sfs_all_sal_ads "All Static Ads Lab Ads" true 240

Enable or disable a data source

await fetch(
  "https://api.staticadslab.com/v1/swipe-files/data-sources/sfs_all_sal_ads/subscription",
  {
    method: "PUT",
    headers: {
      "X-API-Key": "YOUR_API_KEY",
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ active: false }), // true re-enables
  },
);

Scope list/search to specific sources

Both GET /v1/swipe-files and POST /v1/swipe-files/search accept data_source_ids and include_own:

const response = await fetch("https://api.staticadslab.com/v1/swipe-files/search", {
  method: "POST",
  headers: {
    "X-API-Key": "YOUR_API_KEY",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    query: "bold typography sale announcement",
    data_source_ids: ["sfs_all_sal_ads"], // only this source's designs
    include_own: false,              // exclude my own uploads
  }),
});

Omit data_source_ids to search everything you are subscribed to; pass data_source_ids: [] with include_own: true to search only your own uploads.

Common patterns

Upload a reference image

Upload raw bytes directly with multipart/form-data — no need to pre-host the image. search and read require the swipe_files:read scope; uploads require swipe_files:write.

const form = new FormData();
form.append("file", fileBlob, "competitor-ad.png"); // File or Blob

const response = await fetch("https://api.staticadslab.com/v1/swipe-files", {
  method: "POST",
  headers: {
    "X-API-Key": "YOUR_API_KEY",
    // Do NOT set Content-Type — the runtime adds the multipart boundary.
  },
  body: form,
});
const { data } = await response.json();
console.log(data.id, data.status); // sf_..., "processing"

Then poll until ready:

async function waitForSwipeFile(id) {
  while (true) {
    const r = await fetch(
      `https://api.staticadslab.com/v1/swipe-files/${id}`,
      { headers: { "X-API-Key": "YOUR_API_KEY" } },
    );
    const { data } = await r.json();
    if (data.status === "ready") return data;
    if (data.status === "failed") throw new Error(data.error ?? "Ingest failed");
    await new Promise((res) => setTimeout(res, 2000));
  }
}

Search by description

const response = await fetch("https://api.staticadslab.com/v1/swipe-files/search", {
  method: "POST",
  headers: {
    "X-API-Key": "YOUR_API_KEY",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    query: "minimalist beige flatlay with soft shadows",
    ad_format: ["Sale", "Offer"], // optional filters; string or array (matches any)
    aspect_ratio: "1:1",
    limit: 20,
  }),
});
const { data } = await response.json();
for (const hit of data) {
  console.log(hit.id, hit.similarity, hit.image_url);
}

Every metadata filter (industry, ad_format, aspect_ratio) accepts a single string or an array of strings. Multiple values on one dimension match any of them; different dimensions combine with AND.

List and filter without a query

// Repeat a query param to match any of multiple values on that dimension.
const params = new URLSearchParams({ status: "ready" });
params.append("industry", "Apparel & Accessories");
params.append("industry", "Health & Beauty");
const response = await fetch(
  `https://api.staticadslab.com/v1/swipe-files?${params}`,
  { headers: { "X-API-Key": "YOUR_API_KEY" } },
);
const { data, has_more, total_count } = await response.json();

total_count is the exact number of designs matching your filters, independent of the page size — useful for a "20 of 231" style label before you have loaded everything.

Discovering which filter values exist

A library you did not upload yourself (a subscribed data source, for instance) can hold industries and formats you have no way to guess. Ask for the available values instead of inferring them from a page of results:

const params = new URLSearchParams({
  limit: "20",
  facets: "industry,ad_format,aspect_ratio",
});
const response = await fetch(
  `https://api.staticadslab.com/v1/swipe-files?${params}`,
  { headers: { "X-API-Key": "YOUR_API_KEY" } },
);
const { data, total_count, facets } = await response.json();

// facets.industry -> [{ value: "Apparel & Accessories", count: 116 }, ...]
// Each facet key is the filter parameter name, and each value is a valid value
// for it, so a facet round-trips into a filter directly.

Counts exclude their own dimension's filter: with industry applied, the industry facet still lists every industry (so you can switch), while ad_format and aspect_ratio narrow to the selected industry. See Pagination for the full convention.

Pitfalls

  • Ingest is async. A newly uploaded swipe file is processing and will not appear in /search results until it is ready. Poll GET /v1/swipe-files/:id or list with status=processing to track it.
  • search is a POST but read-intent — it requires the swipe_files:read scope, not write.
  • Supported upload formats are PNG, JPEG, and WebP. WebP is transcoded to PNG before embedding.
  • Your library is org-scoped plus any subscribed data sources. If unexpected designs appear (or disappear), check GET /v1/swipe-files/data-sources — a subscription toggle changes list and search results immediately.
  • Managed designs (is_managed: true) cannot be archived or restored — DELETE returns SWIPE_FILE_READ_ONLY. Disable the data source instead.
  • Don't build filter dropdowns from the industry/ad_format values present in a page of data. Results are ordered newest-first, so one page is often dominated by a single industry and the rest of the library looks like it does not exist. Use facets.
  • Facet counts describe the filtered library, not a /search result set. Search is vector-ranked over a single page, so pair it with values rather than counts.

Prompt for your agent

Read https://www.staticadslab.com/docs/resources/swipe-file.mdx and write a function that uploads a reference image to the swipe file via multipart POST /v1/swipe-files, polls until status is ready, then runs POST /v1/swipe-files/search with a text query and returns the top matches with their similarity scores.