# 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 `