ArchitectureSwipe file: API contract

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

DoorPrefixCaller
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 pathPurpose
GET /itemsList, keyset-paginated
POST /itemsSave one item
POST /importBulk import, up to 200, per-item report
PATCH /items/:idChange folder, note, tags
DELETE /items/:idDelete one
POST /items/deleteDelete many { ids }
POST /items/moveMove many { ids, folderId }
GET /foldersFolders with item counts
POST /foldersCreate folder { name }
PATCH /folders/:idRename or reorder { name?, order? }
DELETE /folders/:idDelete folder; its items are un-filed, not deleted
GET /exportThe 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.

FieldTypeRule
sourceKind"meta_ad_library" | "web" | "manual"required
adIdstring, 5 to 25 digitsrequired for meta_ad_library; the dedup key
pageIdstring, digitsoptional (advertiser page id)
pageNamestring, 200optional
permalinkstring, 2048http(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
adCopystring, 5000optional, line breaks kept
ctastring, 80optional
startedAtYYYY-MM-DD or ISO datetimeoptional, not before 2000, not in the future
platformsstring[], at most 10lower-cased, e.g. ["facebook","instagram"]
countriesstring[], at most 60ISO 3166 alpha-2, upper-cased
reachLow, reachHighintegers 0 to 2e9optional, 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
thumbnaildata URL data:image/(png|jpeg|webp);base64,...optional, at most 16 KB decoded, signature checked, SVG refused
notestring, 2000optional
tagsstring[], at most 10, each 32lower-cased, de-duplicated
folderIdstring | nulloptional; must be one of your folders. In /import it overrides the call-level folderId
sourceRefstring [A-Za-z0-9_.:-] up to 100manual 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:

StatusBodyMeaning
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 }; 404 when none matched (not yours and non-existent look the same).
  • POST /items/move, body { "ids": [1 to 200], "folderId": string | null } (null un-files): 200 { "moved": n, "notFound": m }; 404 when 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

WhatFreeProMax
Saved items per person and organisation502,00010,000
Folders550200

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.

WhatLimit
Items per /import200 (whole call refused above)
Ids per delete-many or move200
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
Thumbnail16 KB decoded (a 96 to 160 px JPEG fits)
Rate limit, per user and per route, per minutereads 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 (reusing scrubCredentialParams from 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 User and Organization (onDelete: Cascade), so account deletion (deleteUserCompletely) and organisation deletion remove the whole library; src/services/swipe-file/erasure.test.ts pins 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.