Swipe file: API contract
A person's own library of ad references. The browser extension saves ads they find on the Meta Ad Library, the web app saves ads and pages from anywhere, and both read the same library. It is a list of pointers and…
A person's own library of ad references. The browser extension saves ads they find on the Meta Ad Library, the web app saves ads and pages from anywhere, and both read the same library. It is a list of pointers and facts the client read, not a media mirror: the server never fetches a stored URL, never downloads or re-hosts third-party media, and keeps at most one small thumbnail that the client itself supplied.
Code: src/services/swipe-file/ (service.ts is the one service layer,
http.ts the shared handlers, validate.ts the pure validation, limits.ts
every number). Tables: SwipeItem, SwipeFolder (additive, prisma/schema.prisma).
Two doors, one behaviour
| Door | Prefix | Caller |
|---|---|---|
| Web | /api/swipe/* | the dashboard, session cookie |
| Extension | /api/extension/swipe/* | the extension, through a signed-in platform tab (same rule as the sibling /api/extension/* routes: chrome.runtime message to the tab, the tab calls with the session cookie) |
Both trees re-export the same handlers, so the paths below are identical under
either prefix. Auth is withSessionAuth({ requireOrg: true }), like the
siblings: no token to store in the extension, no CORS and no CSRF exception.
Unauthenticated: 401. Responses are Cache-Control: private, no-store.
If the extension later needs to call from its own origin, the opt-in is
allowExtensionOrigin: true on authed() in http.ts (what
/api/intelligence/panel/ingest does); it is not enabled today.
Whose library, which plan
The owner is (organisation, user). A swipe file is personal: nobody else in
the organisation sees it. The organisation is the one the session resolves
(oldest membership), or ?orgId=<id> on any request when the person belongs to
several (an organisation they are not a member of: 403). The plan, and so the
caps, is that organisation's plan, resolved server-side. An id that is not
yours (another person's, another organisation's, or one that never existed)
answers 404 not_found, never 403 and never 200.
Endpoints
JSON in, JSON out. Field names are camelCase. All ids are opaque strings.
| Method and path | Purpose |
|---|---|
GET /items | List, keyset-paginated |
POST /items | Save one item |
POST /import | Bulk import, up to 200, per-item report |
PATCH /items/:id | Change folder, note, tags |
DELETE /items/:id | Delete one |
POST /items/delete | Delete many { ids } |
POST /items/move | Move many { ids, folderId } |
GET /folders | Folders with item counts |
POST /folders | Create folder { name } |
PATCH /folders/:id | Rename or reorder { name?, order? } |
DELETE /folders/:id | Delete folder; its items are un-filed, not deleted |
GET /export | The person's whole library as JSON, paged |
The item you send (POST /items, each entry of POST /import)
Unknown fields are refused (unknown_field:<name>), not ignored.
| Field | Type | Rule |
|---|---|---|
sourceKind | "meta_ad_library" | "web" | "manual" | required |
adId | string, 5 to 25 digits | required for meta_ad_library; the dedup key |
pageId | string, digits | optional (advertiser page id) |
pageName | string, 200 | optional |
permalink | string, 2048 | http(s) only. meta_ad_library: must be an Ad Library page (facebook.com/ads/library/... or /ads/archive/...). web: required, any public-looking host (no localhost, private or metadata addresses). Credential-like query params (access_token, token, key, secret, *_token...), userinfo and token fragments are stripped before storing |
adCopy | string, 5000 | optional, line breaks kept |
cta | string, 80 | optional |
startedAt | YYYY-MM-DD or ISO datetime | optional, not before 2000, not in the future |
platforms | string[], at most 10 | lower-cased, e.g. ["facebook","instagram"] |
countries | string[], at most 60 | ISO 3166 alpha-2, upper-cased |
reachLow, reachHigh | integers 0 to 2e9 | optional, one side is enough (<1K, >1M); low must not exceed high |
reachKind | "reach" | "impressions" | required with the bounds, and only with them. What the bounds count |
thumbnail | data URL data:image/(png|jpeg|webp);base64,... | optional, at most 16 KB decoded, signature checked, SVG refused |
note | string, 2000 | optional |
tags | string[], at most 10, each 32 | lower-cased, de-duplicated |
folderId | string | null | optional; must be one of your folders. In /import it overrides the call-level folderId |
sourceRef | string [A-Za-z0-9_.:-] up to 100 | manual only: your own idempotency key |
Anything you do not know, omit. Absence is stored as null, never 0.
Dedup key (server-derived, you never send it): meta_ad_library:<adId>;
web:<sha256 of the normalised permalink> (host lower-cased, fragment, trailing
slash and tracking params utm_*/fbclid/gclid/... ignored, query sorted);
manual:<sourceRef> or a random id when there is no sourceRef (never a
duplicate). Unique per person and organisation. Saving the same ad twice is a
duplicate, not an error.
The item you get back
{
"id": "…",
"folderId": null,
"sourceKind": "meta_ad_library",
"adId": "123…",
"pageId": null,
"pageName": "…",
"permalink": "https://www.facebook.com/ads/library/?id=123…",
"adCopy": "…",
"cta": "Shop now",
"startedAt": "2026-09-01T00:00:00.000Z",
"platforms": ["facebook"],
"countries": ["FR"],
"reach": { "low": 1000, "high": 5000, "kind": "impressions" },
"thumbnail": "data:image/jpeg;base64,…",
"note": null,
"tags": ["hook"],
"createdAt": "…",
"updatedAt": "…"
}
reach is null when unknown; when present it always carries kind. It is a
per-ad figure of what kind says. Never add reach values up and call the
result "people reached", and never render permalink as an <img>/<video>
source: it is a link.
GET /items
Query: folder (id, or none for un-filed), tag, q (substring of ad copy,
page name, CTA, note), from / to (date saved, ISO), cursor, limit
(1 to 50, default 30). Newest saved first.
{ "items": [ … ], "nextCursor": "opaque-or-null" }
Pass nextCursor back as cursor until it is null. A cursor the server did
not issue: 400 invalid_cursor.
POST /items
Body: one item. 201 { "status": "created", "item": {…} }. Otherwise:
| Status | Body | Meaning |
|---|---|---|
| 409 | { "error": "duplicate", "id"?: "…" } | already saved |
| 409 | { "error": "item_limit", "limit": 50 } | the plan's cap is reached |
| 422 | { "error": "invalid_item", "detail": "<code>" } | see codes below |
| 404 | { "error": "folder_not_found" } | folderId is not yours |
POST /import
Body: { "items": [ …1 to 200 items… ], "folderId"?: string | null }.
More than 200 items: 400 { "error": "too_many_items", "max": 200 } for the
whole call. Otherwise always 200, with one entry per input item, in
input order:
{
"results": [
{ "index": 0, "status": "created", "id": "…" },
{ "index": 1, "status": "duplicate", "id": "…" },
{ "index": 2, "status": "invalid", "detail": "permalink_invalid" },
{ "index": 3, "status": "over_limit", "detail": "limit:50" }
],
"summary": { "created": 1, "duplicate": 1, "invalid": 1, "over_limit": 1 },
"limit": 50,
"used": 50
}
duplicate carries the existing id when the item was already saved, and no
id when it duplicates an earlier entry of the same call. over_limit means
the plan's cap was reached before this item; items after it that are
duplicates still report duplicate. used is what the person holds after the
call. Retrying the same batch is safe (everything created is now a duplicate).
detail codes for invalid: invalid_field:<field>, unknown_field:<name>,
ad_id_required, permalink_required, permalink_invalid,
permalink_not_ad_library, reach_kind_required, reach_bounds_required,
reach_range_inverted, started_at_invalid, thumbnail_invalid,
thumbnail_too_large, folder_not_found.
Update, move, delete
PATCH /items/:id, body any of{ folderId: string | null, note: string | null, tags: string[] }(at least one).200 { "item": {…} }. Nothing else is editable: the saved facts are what the client read at the time.DELETE /items/:id:200 { "ok": true }.POST /items/delete, body{ "ids": [1 to 200] }:200 { "deleted": n, "notFound": m };404when none matched (not yours and non-existent look the same).POST /items/move, body{ "ids": [1 to 200], "folderId": string | null }(nullun-files):200 { "moved": n, "notFound": m };404when none matched or the folder is not yours.
Folders
GET /folders → { "folders": [{ "id", "name", "order", "itemCount" }], "unfiledCount": n }.
POST /folders { "name" } (1 to 60 chars, one line) → 201 { "folder": {…} },
409 folder_exists (names are unique per person) or 409 folder_limit { limit }.
PATCH /folders/:id → { "ok": true }, 404, or 409 folder_exists.
DELETE /folders/:id → items go back to un-filed.
GET /export
The person's own library, as a JSON attachment (swipe-file-export.json),
paged by the same keyset. Query: cursor, limit (default and max 100; up to
500 with thumbnails=0), thumbnails=0|1 (default 1).
{ "version": 1, "exportedAt": "…", "folders": [ … ], "items": [ … ], "nextCursor": "…|null" }
folders is on the first page only. Follow nextCursor until null.
Caps and limits
| What | Free | Pro | Max |
|---|---|---|---|
| Saved items per person and organisation | 50 | 2,000 | 10,000 |
| Folders | 5 | 50 | 200 |
Existing rows above a cap (after a downgrade) are never deleted or hidden; the
cap only refuses a new item. The caps are enforced server-side in
importItems / createFolder, counted once per call. Under truly concurrent
imports from the same person the count can overshoot by the size of one batch
at most; the rate limits below bound that.
| What | Limit |
|---|---|
Items per /import | 200 (whole call refused above) |
| Ids per delete-many or move | 200 |
| Request body | /import 4,000,000 bytes (under the 4.5 MB serverless limit); one item 64 KB; delete/move 64 KB; small bodies 8 KB. Over: 413 payload_too_large, checked while streaming |
| Thumbnail | 16 KB decoded (a 96 to 160 px JPEG fits) |
| Rate limit, per user and per route, per minute | reads 120, single writes 60, /import 10, /export 6; on top of that every write shares one budget of 120 per minute across all routes. Over: 429 RATE_LIMITED with retryAfterSeconds |
A batch of 200 items that each carry a near-cap thumbnail is bigger than the import body limit: split it (roughly 100 per call when every item has a thumbnail).
Privacy and honesty
- The server never fetches, resolves or previews a stored URL, and does no scraping. It stores what the client sent, after validation.
- Credential-like query parameters are removed from
permalink(reusingscrubCredentialParamsfrom the ad-library module), so a saved link cannot leak a token. - Reach is stored only with its kind and is never summed.
- Erasure: both tables cascade from
UserandOrganization(onDelete: Cascade), so account deletion (deleteUserCompletely) and organisation deletion remove the whole library;src/services/swipe-file/erasure.test.tspins the keys. The person can also export it (/export) and delete items one by one or in bulk. - A person removed from an organisation loses access to their library in that organisation (it is scoped to the pair); the rows are removed with the organisation or the account.