Pagination
How list endpoints paginate using the starting_after cursor pattern.
Static Ads Lab list endpoints return a fixed-size page plus a has_more flag. Pagination is cursor-based via the starting_after query parameter.
The list response shape
{
"data": [
{ "id": "ia_a1b2c3d4e5f67890", "...": "..." },
{ "id": "ia_b2c3d4e5f6789012", "...": "..." }
],
"has_more": true,
"total_count": 357,
"meta": {
"request_id": "req_abc123",
"timestamp": "2026-04-30T14:30:00.000Z"
}
}datais always an array.has_moreistrueif more items exist after this page.total_countis the exact number of items matching your filters, independent of pagination. Use it to show "20 of 357" before you have fetched all 357.- Default
limitis20; max is100.
Cursor-based pagination with starting_after
Pass the last item's id as starting_after= to get the next page.
async function listAll(url, headers) {
const all = [];
let startingAfter;
while (true) {
const params = new URLSearchParams();
if (startingAfter) params.set("starting_after", startingAfter);
params.set("limit", "100");
const r = await fetch(`${url}?${params}`, { headers });
const { data, has_more } = await r.json();
all.push(...data);
if (!has_more) break;
startingAfter = data[data.length - 1].id;
}
return all;
}
const allBrands = await listAll(
"https://api.staticadslab.com/v1/brands",
{ "X-API-Key": process.env.SAL_API_KEY },
);Filter parameters
Most list endpoints accept resource-specific filters in addition to starting_after and limit:
| Endpoint | Common filters |
|---|---|
/v1/products | brand_id |
/v1/product-variants | product_id |
/v1/audiences | product_id |
/v1/images | product_id, image_type (product, lifestyle, logo, background, other) |
/v1/design-templates | status, industry, ad_format, deleted, ids |
/v1/swipe-files | industry, ad_format, aspect_ratio, status, archived, data_source_ids, include_own, ids |
/v1/image-ads | status, brand_id, product_id, product_variant_id, reference_ad_url, batch_id, editable, sku_code, search, ids |
Some filters accept multiple values. On /v1/swipe-files, repeat the parameter (?industry=A&industry=B). On /v1/image-ads, comma-separate (?status=processing,completed). Values within one dimension match any; different dimensions combine with AND.
See each endpoint's API reference page for the full filter list.
Facets: discovering filter values
Rather than guessing which filter values exist, ask for them. Name the dimensions you want in the facets parameter and the response gains a facets object of values with exact counts.
const params = new URLSearchParams({
limit: "20",
facets: "industry,aspect_ratio",
});
const r = await fetch(`https://api.staticadslab.com/v1/swipe-files?${params}`, {
headers: { "X-API-Key": process.env.SAL_API_KEY },
});
const { data, total_count, facets } = await r.json();
// facets.industry -> [{ value: "Apparel & Accessories", count: 116 }, ...]{
"data": ["..."],
"has_more": true,
"total_count": 231,
"facets": {
"industry": [
{ "value": "Apparel & Accessories", "count": 116 },
{ "value": "Health & Beauty", "count": 43 }
],
"aspect_ratio": [
{ "value": "1:1", "count": 125 },
{ "value": "4:5", "count": 66 }
]
}
}Supported dimensions:
| Endpoint | Facet dimensions |
|---|---|
/v1/swipe-files | industry, ad_format, aspect_ratio |
/v1/design-templates | industry, ad_format, status |
/v1/image-ads | status |
A facet key is a filter parameter
Each facet key is the name of the query parameter that filters on it, and each facet value is a valid value for that parameter. So you turn a facet straight back into a filter with no mapping:
const top = facets.industry[0];
const narrowed = await fetch(
`https://api.staticadslab.com/v1/swipe-files?industry=${encodeURIComponent(top.value)}`,
{ headers: { "X-API-Key": process.env.SAL_API_KEY } },
);
// The narrowed total_count equals top.count exactly.Counts exclude their own dimension's filter
Each dimension's counts honour every other active filter but not its own. With industry=Apparel & Accessories applied:
- the
industryfacet still lists every industry, with library-wide counts, so you can offer a switch to another one; - the
ad_formatandaspect_ratiofacets narrow to Apparel only, so their counts describe what you would actually get.
This is standard disjunctive faceting. It means a count always predicts the size of the next refinement.
Notes
- Facets are opt-in. Omit the parameter and nothing is computed.
- Counts are exact. At most 100 values per dimension, ordered by count descending.
- Values with zero matches are omitted; every dimension you request is present, as an empty array if nothing matched.
- An unrecognised dimension returns
400 VALIDATION_ERRORlisting the allowed names, rather than being ignored. - Because facets ignore the filter for their own dimension, the block is identical across pages of the same filter set. Request it on the first page only.
Batch reading by IDs
For image ads and design templates, you can read up to N specific resources in one call without iterating, by passing a comma-separated ids= parameter. When ids= is provided, cursor pagination is ignored.
GET /v1/image-ads?ids=ia_a,ia_b,ia_c
GET /v1/design-templates?ids=dt_a,dt_bThis is the recommended pattern for batch polling. See Async jobs.
Pitfalls
- Don't use
data.length === limitas a stop condition — the API may return fewer items thanlimiteven whenhas_moreis true. Always trusthas_more. starting_afteris the ID of an item you've already received. Don't synthesize cursors client-side.- Parallel cursor requests aren't supported — paginate serially.
- Some legacy patterns refer to
cursor— the actual parameter isstarting_after. - Don't derive a total from
data.lengthacross pages while you are still paginating; readtotal_countinstead. - Don't rebuild filter dropdowns from the values present in
data— one page is not the whole result set. That's whatfacetsis for.