# gifs.nostr.build: integration guide for AI agents > Free GIF search for Nostr clients, run by nostr.build. This guide is for coding agents adding a GIF button and picker to a Nostr app (web or native). Read it whole before writing code. Every rule here was checked against the live service; where it says MUST, getting it wrong breaks the integration or the terms. - API base: https://gifs.nostr.build/api/v1 - OpenAPI 3.1: https://gifs.nostr.build/api/v1/openapi.json - Human reference: https://gifs.nostr.build/developers/reference - Registration and dashboard: https://gifs.nostr.build/developers - Short index: https://gifs.nostr.build/llms.txt --- ## 0. Checklist (do all of these) 1. The app's developer (a human) registers the client at https://gifs.nostr.build/developers. You cannot do this for them: it needs their Nostr signer and a one-time 1000-sat Lightning payment. Web code can be developed before that from `http://localhost` (any port), which works without registering. Native code cannot: it needs an API key, so registration comes first (section 1). 2. Web: register the site's Origin. Send NO `Authorization` header from browser code (the CORS preflight does not allow it; the browser will block the request). Native: send `Authorization: Bearer gnb_…` (an API key from the dashboard). 3. Show "GIFs from nostr.build", linked to https://nostr.build, wherever the GIFs appear (the picker's header is the usual place). This is a condition of use. 4. Search with `GET /search?q=…`; show `previews.w240` or `previews.w480` in the grid (not the original); post the item's `url` (the original) in the note, with a NIP-92 `imeta` tag. 5. Debounce typing (about 250 ms), skip one-letter queries, abort stale requests, and handle 400 / 403 / 429 / 503 as described in section 6. 6. Lay tiles out from the API's `width`/`height` before images load (no layout shift), lazy-load, and animate only what is in view; respect `prefers-reduced-motion`. 7. Never upload or re-host the GIF. Link it where it is. --- ## 1. Identification (the part agents most often get wrong) Every request must identify a registered client. The server decides in this order, and the first one present decides alone: 1. `Origin` header (any value other than the opaque `null`). Browsers send it automatically on cross-origin requests. If an Origin is present it alone decides: a request with an unregistered Origin is refused even if it also carries a valid API key. 2. `Authorization: Bearer gnb_…` (an API key), when there is no Origin. 3. `User-Agent`, matched against patterns a nostr.build admin has reviewed, when there is neither. Consequences: - **Browser / web app**: register the exact origin (`https://app.example.com`) or a wildcard for subdomains (`https://*.example.com`) in the dashboard. Origins whose domain proves the owner with a `_@domain` NIP-05 are approved automatically; others wait for review. Call the API with a plain `fetch(url)`: no custom headers. - **Never send `Authorization` from a browser.** Only `GET, HEAD` and no request headers are allowed in CORS preflight, so a browser request with `Authorization` fails before it is sent. It would also leak the key. - **`http://localhost`, `https://localhost`, `http://127.0.0.1` and `http://[::1]` (http or https, any port)** are admitted as a built-in development client, without registering. It is for development only: everyone shares it (and its rate limit). Register before shipping. - **Native apps (iOS, Android, desktop, CLI, server)**: no Origin is sent, so use an API key (create it in the dashboard after registering; it is shown once). There is no unregistered path for native code: get the key first. Never fake an `Origin: http://localhost` header from native code to get around this. Keys ship inside apps and are not secret in any strong sense; that is accepted. Do not put them in public web bundles. - **Hybrid apps (Capacitor, Ionic, Tauri, Electron, other WebViews)**: only `http`/`https` origins with a real domain can be registered, so a WebView whose Origin is `capacitor://localhost`, `tauri://localhost`, `ionic://localhost`, `file://` or similar can never be admitted by Origin (and its Origin makes the server ignore any API key). Call the API from the native side instead (CapacitorHttp / `@capacitor/core` native HTTP, the Tauri `http` plugin, Electron's main process, a native module), which sends no Origin, with the API key. A WebView served as `https://localhost` (Capacitor on Android) is admitted only as the shared development client: do not ship on that. - **A server-side proxy** of your own: use an API key and do not forward the browser's Origin. - Unregistered: `403 client_not_registered`. Registry hiccup: `503 registry_unavailable` (retry shortly). In a browser, both reach your code as a network/CORS error (no status, no body), because they are answered before CORS headers are added: treat a failed `fetch` as "unavailable, retry later", and check registration if it persists. - Admission answers are cached per data centre: after the human registers an Origin or creates a key, allow up to 5 minutes before it works everywhere (an earlier 403 is remembered that long). A deleted key or origin can keep working for up to an hour. Don't change correct code because of a 403 in the first minutes after registering. Registration facts to tell the human: - Sign in with Nostr at https://gifs.nostr.build/developers (NIP-07 browser extension or a NIP-46 bunker such as Amber or nsec.app). - Registering a client costs 1000 sats, once, paid by Lightning (QR, wallet link or WebLN). The fee only keeps spam sign-ups out; it is not refunded if the client is deleted. - Limits: 5 clients per pubkey; per client 10 owners, 20 origins, 10 API keys, 10 User-Agents, 10 pending requests. - Registering requires accepting the attribution terms (section 2). --- ## 2. Attribution (required) Show "GIFs from nostr.build", linked to https://nostr.build, wherever these GIFs appear, for example in your GIF picker's header. Exact snippet: ```html GIFs from nostr.build ``` Make it visible (not hidden behind a menu), legible, and a real link. In native apps, open the link in the system browser. --- ## 3. Endpoints All are `GET`, return JSON, and accept `safe` (`1`, the default, excludes adult GIFs; `0` is refused with `403 adult_not_allowed`, so leave it out). ### GET /api/v1/search | Param | Type | Default | Notes | |---|---|---|---| | `q` | string, 1–500 chars | required | Words in any language, emoji, or both. See section 5 for how it is normalised. | | `limit` | 1–200 | 24 | Items in this page. | | `offset` | ≥ 0 | 0 | Past the end the page is shorter or empty (never an error). | | `mode` | `auto` \| `text` \| `semantic` | `auto` | `auto` blends full-text and meaning. `text` matches words only. `semantic` only by meaning (503 if the embedder is down). Leave it at `auto` in a picker. | Response: ```json { "build": "20260927T234106Z", "gen": 2, "q": "wave", "semantic": true, "count": 200, "offset": 0, "limit": 24, "items": [ /* Gif objects, section 4 */ ] } ``` - `q`: the query as it was searched (normalised). - `count`: the length of this query's ranked list, at most 200. It is not the number of matching GIFs in the index; 200 is the most any query returns. - `semantic`: `false` means the meaning leg did not run: with `mode=text` that is expected; under `mode=auto` it means the embedder was down, the results are words-only, and that degraded answer is cached for only a minute (the full answer replaces it once the embedder is back). - `build`: the index build. If it changes between pages of the same query, start again from offset 0. - `gen`: cache generation (ignore it unless debugging). ### GET /api/v1/suggest Autocomplete: the index's own terms that start with `q`, most used first. | Param | Type | Default | |---|---|---| | `q` | string, 1–500 chars | required | | `limit` | 1–20 | 8 | Response: `{ "build", "gen", "q", "terms": [{ "term": "cat face", "kind": "tag" | "character" | "title" | "text", "n": 412, "fuzzy"?: true }] }`. `fuzzy: true` marks trigram (typo-tolerant) matches that follow when prefix matches come up short (4+ characters). An emoji is completed by its name (🐱 suggests "cat face …"). ### GET /api/v1/gifs/{id}/similar GIFs that look like one GIF, nearest first. `id` is an item's `id` (for example `.gif`, pattern `^\w[\w.-]{0,199}$`). `limit` 1–50, default 24. Unknown id: `404 not_found` (cached like a 200). Response: `{ "build", "gen", "items": [Gif…] }`. ### Examples ```js // Browser: no headers. Origin is sent by the browser. const res = await fetch( `https://gifs.nostr.build/api/v1/search?q=${encodeURIComponent(q)}&limit=24`, { signal } ) ``` ```sh # Native / server: API key. curl -H 'Authorization: Bearer gnb_…' \ 'https://gifs.nostr.build/api/v1/search?q=good%20morning&limit=24' ``` Always build the query with `encodeURIComponent` (or `URLSearchParams`): queries contain spaces, emoji and non-Latin scripts. --- ## 4. The Gif object, and which file to use where ```json { "id": "20099e4e…59051.gif", "url": "https://gifs.nostr.build/20099e4e…59051.gif", "width": 224, "height": 126, "frames": 37, "duration": 3.7, "bytes": 806309, "format": "gif", "title": "Giant wave curling with an eye", "tags": ["wave", "ocean", "sea"], "lqip": "data:image/webp;base64,UklGRs4AAABXRUJQ…", "mp4": "https://gifs.nostr.build/mp4/orig/20099e4e…59051.gif", "previews": { "small": { "width": 171, "height": 96, "animated": "…/anim/96/…", "still": "…/still/96/…" }, "medium": { "width": 224, "height": 126, "animated": "…/anim/192/…", "still": "…/still/192/…" }, "w240": { "width": 224, "height": 126, "animated": "…/anim/w240/…", "still": "…/still/w240/…" }, "w480": { "width": 224, "height": 126, "animated": "…/anim/w480/…", "still": "…/still/w480/…" } } } ``` | Field | Use it for | Gotchas | |---|---|---| | `url` | What you post in the note; what "copy link" copies. | The original file (GIF or WebP per `format`), often MBs. Never use it as a grid thumbnail. | | `previews.w240` / `w480` | Grid tiles in columns: `w240` when the viewport is under 640 px, `w480` from 640 px (or pick by column width × devicePixelRatio). | Fitted to a width, never enlarged: a small GIF's `w480` is its own size. Same shape as the original. | | `previews.small` / `medium` | Horizontal strips (rows). | Fitted to at most 96 or 192 px tall, never enlarged: a small GIF's `medium` is shorter than 192 px. Lay each tile out from that preview's own `width`/`height`. | | `…animated` | The moving preview. | **Animated WebP even though the URL ends in `.gif`.** Do not infer type from the extension. `null` when the GIF is too large to animate: show `still` instead. | | `…still` | First frame (PNG): poster while loading, the preview under reduced motion, the fallback when `animated` is null or fails. | | | `lqip` | A tiny blurred placeholder (data URI) to paint before the preview loads. | **Remove it when the image loads**: many GIFs are transparent and the blur shows through otherwise. May be `null`. | | `mp4` | H.264 of the animation at source size, many times smaller than the GIF: best for large grids or full-size playback. | No audio, no loop flag: use `