{"openapi":"3.0.3","info":{"title":"Profile Pinning Service API","description":"IPFS profile management and pinning service for Circles UBI","version":"1.0.0","contact":{"name":"Circles UBI","url":"https://aboutcircles.com"}},"servers":[{"url":"","description":"API endpoints (via reverse proxy)"}],"tags":[{"name":"Profiles","description":"Profile retrieval and search"},{"name":"Content","description":"Raw CID content retrieval"},{"name":"Pinning","description":"IPFS pinning operations"},{"name":"Status","description":"CID status and history"},{"name":"Health","description":"Service health and metrics"}],"paths":{"/pin":{"post":{"tags":["Pinning"],"summary":"Pin a JSON profile to IPFS","description":"Pins a JSON profile to IPFS via Filebase and stores content in database.\n\n**Validation Limits:**\n- Maximum content size: **5 MB** (to allow base64 encoded images)\n- Maximum JSON nesting depth: **10 levels**\n- Content must be valid JSON\n\nInvalid content is rejected immediately with a clear error message.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileInput"},"example":{"name":"Alice","description":"Hello from Circles!","imageUrl":"ipfs://QmExample..."}}}},"responses":{"201":{"description":"Profile pinned successfully","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PinResponse"}}}},"400":{"description":"Validation failed","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"tooLarge":{"summary":"Content too large","value":{"error":"Content too large: 6291456 bytes (max: 5242880)","code":"VALIDATION_FAILED"}},"tooDeep":{"summary":"JSON too deeply nested","value":{"error":"Content too deeply nested: depth 15 (max: 10)","code":"VALIDATION_FAILED"}},"invalidJson":{"summary":"Invalid JSON","value":{"error":"Invalid request body","code":"INVALID_BODY"}}}}}},"413":{"description":"Content size exceeds server limit","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Content size 10485760 exceeds maximum 8388608","code":"SIZE_EXCEEDED"}}}}}}},"/pin-media":{"post":{"tags":["Pinning"],"summary":"Pin binary media (images) to IPFS","description":"Pins binary content like images to IPFS. Maximum size: 8 MB.","requestBody":{"required":true,"content":{"image/*":{"schema":{"type":"string","format":"binary"}},"application/octet-stream":{"schema":{"type":"string","format":"binary"}}}},"responses":{"201":{"description":"Media pinned successfully","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PinResponse"}}}},"413":{"description":"Content size exceeds limit","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/get":{"get":{"tags":["Profiles"],"summary":"Get profile by CID","description":"Retrieves profile content by CID from the database.\n\n**Note:** Only profile CIDs that have been indexed are served. This is not a generic IPFS proxy.","parameters":[{"name":"cid","in":"query","required":true,"description":"IPFS Content ID (CIDv0 starting with Qm or CIDv1 starting with b)","schema":{"type":"string","example":"QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG"}}],"responses":{"200":{"description":"Profile found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileResponse"}}}},"400":{"description":"Invalid CID format","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Invalid CID format. Expected CIDv0 (Qm...) or CIDv1 (b...)"}}}},"404":{"description":"CID not found in database","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"CID not found. Only profile CIDs are served."}}}}}}},"/getBatch":{"get":{"tags":["Profiles"],"summary":"Get multiple profiles by CID","description":"Retrieves multiple profiles in a single request. Maximum 50 CIDs per request.","parameters":[{"name":"cids","in":"query","required":true,"description":"Comma-separated CIDs or JSON array","schema":{"type":"string","example":"QmCid1,QmCid2,QmCid3"}}],"responses":{"200":{"description":"Profiles array (null for not found)","content":{"application/json":{"schema":{"type":"array","items":{"oneOf":[{"$ref":"#/components/schemas/ProfileResponse"},{"type":"null"}]}}}}},"400":{"description":"Invalid request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"batchTooLarge":{"summary":"Batch size exceeded","value":{"error":"Batch size exceeds maximum of 50"}},"noCids":{"summary":"No valid CIDs","value":{"error":"No valid CIDs provided"}}}}}}}}},"/raw/{cid}":{"get":{"tags":["Content"],"summary":"Get raw CID content","description":"Returns the exact bytes stored for a CID, without any profile-specific shaping.\n\nUnlike `/get` (which returns structured profile data), this endpoint returns raw content — useful for products, namespace chunks, index documents, and other non-profile payloads stored via `/pin` or `/pin-media`.\n\n**Content-Type:** Returns `application/json` if content starts with `{`, otherwise `application/octet-stream`.\n**Caching:** Immutable content — responses include `Cache-Control: public, max-age=31536000, immutable`.","parameters":[{"name":"cid","in":"path","required":true,"description":"IPFS Content ID (CIDv0 starting with Qm or CIDv1 starting with b)","schema":{"type":"string","example":"QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG"}}],"responses":{"200":{"description":"Raw content bytes","content":{"application/json":{"schema":{"type":"object","description":"Raw JSON content (when stored content is JSON)"}},"application/octet-stream":{"schema":{"type":"string","format":"binary","description":"Raw binary content"}}}},"400":{"description":"Invalid CID format","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Invalid CID format. Expected CIDv0 (Qm...) or CIDv1 (b...)"}}}},"404":{"description":"CID not found in database","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"CID not found"}}}}}}},"/profile/{address}":{"get":{"tags":["Profiles"],"summary":"Get profile by Ethereum address","parameters":[{"name":"address","in":"path","required":true,"description":"Ethereum address (with or without 0x prefix)","schema":{"type":"string","example":"0x1234567890abcdef1234567890abcdef12345678"}},{"name":"fetchComplete","in":"query","description":"If \"true\", enrich with fresh IPFS data (adds imageUrl, previewImageUrl, longitude, latitude)","schema":{"type":"string","enum":["true","false"],"default":"false"}}],"responses":{"200":{"description":"Profile found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileResponse"}}}},"400":{"description":"Invalid address format","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Invalid Ethereum address format"}}}},"404":{"description":"Profile not found"}}}},"/search":{"get":{"tags":["Profiles"],"summary":"Search profiles","description":"Search profiles by various criteria. At least one search parameter is required.","parameters":[{"name":"name","in":"query","description":"Search by name (partial match)","schema":{"type":"string"}},{"name":"description","in":"query","description":"Search by description (partial match)","schema":{"type":"string"}},{"name":"address","in":"query","description":"Filter by address","schema":{"type":"string"}},{"name":"cid","in":"query","description":"Filter by CID","schema":{"type":"string"}},{"name":"registeredName","in":"query","description":"Filter by registered name","schema":{"type":"string"}},{"name":"location","in":"query","description":"Search by location (partial match)","schema":{"type":"string"}},{"name":"type","in":"query","description":"Filter by avatar type","schema":{"type":"string","enum":["human","group","organization"]}},{"name":"limit","in":"query","description":"Maximum results (default: 50, max: 50)","schema":{"type":"integer","default":50,"maximum":50}},{"name":"offset","in":"query","description":"Number of results to skip for pagination (default: 0)","schema":{"type":"integer","default":0,"minimum":0}},{"name":"fetchComplete","in":"query","description":"If \"true\", enrich results with fresh IPFS data (slower)","schema":{"type":"string","enum":["true","false"],"default":"false"}}],"responses":{"200":{"description":"Search results","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ProfileResponse"}}}}},"400":{"description":"No search parameters provided","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"At least one search parameter is required"}}}}}}},"/search/text":{"get":{"tags":["Profiles"],"summary":"Full-text search","description":"Search profiles using PostgreSQL full-text search with ranking. Supports pagination via limit/offset and filtering by avatar type.","parameters":[{"name":"q","in":"query","required":true,"description":"Search query (min 2 characters)","schema":{"type":"string","minLength":2}},{"name":"type","in":"query","description":"Filter by avatar type","schema":{"type":"string","enum":["human","group","organization"]}},{"name":"limit","in":"query","description":"Maximum results (default: 50, max: 50)","schema":{"type":"integer","default":50,"maximum":50}},{"name":"offset","in":"query","description":"Number of results to skip for pagination (default: 0)","schema":{"type":"integer","default":0,"minimum":0}},{"name":"fetchComplete","in":"query","description":"If \"true\", enrich results with fresh IPFS data (slower)","schema":{"type":"string","enum":["true","false"],"default":"false"}}],"responses":{"200":{"description":"Search results ranked by relevance","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ProfileResponse"}}}}},"400":{"description":"Invalid query","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"tooShort":{"summary":"Query too short","value":{"error":"Query parameter \"q\" is required (min 2 characters)"}},"sanitized":{"summary":"Query too short after sanitization","value":{"error":"Query too short after sanitization"}}}}}}}}},"/search/addresses":{"post":{"tags":["Profiles"],"summary":"Batch lookup by addresses","description":"Look up profiles for multiple Ethereum addresses. Maximum 1000 addresses per request. Invalid addresses are silently skipped.","parameters":[{"name":"fetchComplete","in":"query","description":"If \"true\", enrich results with fresh IPFS data (slower)","schema":{"type":"string","enum":["true","false"],"default":"false"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"addresses":{"type":"array","items":{"type":"string"},"maxItems":1000,"description":"Array of Ethereum addresses (0x-prefixed)","example":["0x1234...","0xabcd..."]}},"required":["addresses"]}}}},"responses":{"200":{"description":"Array of profile objects (order matches found profiles, not input order)","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ProfileResponse"}}}}},"400":{"description":"Invalid request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"invalidBody":{"summary":"Invalid JSON body","value":{"error":"Invalid JSON body"}},"missingArray":{"summary":"Missing addresses array","value":{"error":"addresses array is required"}},"tooMany":{"summary":"Too many addresses","value":{"error":"Maximum 1000 addresses allowed"}}}}}}}}},"/cid/{cid}/status":{"get":{"tags":["Status"],"summary":"Get CID pinning status","description":"Returns pinning state, garbage collection status, reference count, and put-to-use tracking for a CID. Returns `exists: false` for CIDs not in the ephemeral table (but may still have refCount > 0 from on-chain roots).","parameters":[{"name":"cid","in":"path","required":true,"description":"IPFS CIDv0 (Qm...)","schema":{"type":"string"}}],"responses":{"200":{"description":"CID status (always returns 200 even if CID not found — check `exists` field)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CidStatus"}}}},"400":{"description":"Invalid CID format","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Invalid CID format. Expected CIDv0 (Qm...)","code":"INVALID_CID"}}}}}}},"/avatar/{address}/history":{"get":{"tags":["Status"],"summary":"Get profile CID history","description":"Returns the history of CID changes for an avatar address, ordered by block number descending (newest first).\n\nTo retrieve the actual profile content for a historical CID, use `GET /get?cid={cid}` with the CID from the history entry. Historical content is preserved in the database even after Filebase pins are removed.","parameters":[{"name":"address","in":"path","required":true,"description":"Ethereum address of the avatar","schema":{"type":"string"}},{"name":"limit","in":"query","description":"Maximum history entries to return (default: 50, max: 100)","schema":{"type":"integer","default":50,"maximum":100}},{"name":"offset","in":"query","description":"Number of entries to skip for pagination (default: 0)","schema":{"type":"integer","default":0,"minimum":0}}],"responses":{"200":{"description":"CID change history","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AvatarHistoryResponse"}}}},"400":{"description":"Invalid address format","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Invalid address format. Expected Ethereum address (0x...)","code":"INVALID_ADDRESS"}}}}}}},"/health":{"get":{"tags":["Health"],"summary":"Comprehensive health status","description":"Returns detailed health status including database, IPFS, and subscription state.","responses":{"200":{"description":"Service healthy or degraded","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HealthResponse"}}}},"503":{"description":"Service unhealthy","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HealthResponse"}}}}}}},"/health/live":{"get":{"tags":["Health"],"summary":"Liveness probe","description":"Simple check if the server is running. Used by Kubernetes/Docker health checks.","responses":{"200":{"description":"Server is alive","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","example":"ok"}}}}}}}}},"/health/ready":{"get":{"tags":["Health"],"summary":"Readiness probe","description":"Checks if the service can accept requests (database connected).","responses":{"200":{"description":"Service ready","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","example":"ready"}}}}}},"503":{"description":"Service not ready","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","example":"not ready"},"reason":{"type":"string","example":"database unavailable"}}}}}}}}},"/health/backfill":{"get":{"tags":["Health"],"summary":"Backfill status","description":"Returns the current state of the profile backfill process.","responses":{"200":{"description":"Backfill status","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BackfillStatus"}}}}}}}},"components":{"schemas":{"ProfileInput":{"type":"object","required":["name"],"properties":{"name":{"type":"string","description":"Profile display name","maxLength":100},"description":{"type":"string","description":"Profile description/bio","maxLength":500},"imageUrl":{"type":"string","description":"Profile image URL (IPFS or HTTPS)"},"previewImageUrl":{"type":"string","description":"Thumbnail image URL"},"location":{"type":"string","description":"Location name"},"geoLocation":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":2,"description":"[longitude, latitude]"}}},"ProfileResponse":{"type":"object","description":"Profile data. Base fields are always present. Fields marked \"enriched only\" appear only when fetchComplete=true.","properties":{"name":{"type":"string","nullable":true},"description":{"type":"string","nullable":true},"address":{"type":"string","description":"Ethereum address (lowercase)"},"CID":{"type":"string","description":"IPFS CID of the profile"},"lastUpdatedAt":{"type":"string","format":"date-time","nullable":true},"registeredName":{"type":"string","nullable":true},"location":{"type":"string","nullable":true},"avatarType":{"type":"string","nullable":true,"enum":["human","group","organization"],"description":"Avatar type (null for profiles not yet backfilled)"},"imageUrl":{"type":"string","nullable":true,"description":"Enriched only: IPFS or HTTPS image URL"},"previewImageUrl":{"type":"string","nullable":true,"description":"Enriched only: thumbnail image URL"},"longitude":{"type":"number","nullable":true,"description":"Enriched only: geo longitude"},"latitude":{"type":"number","nullable":true,"description":"Enriched only: geo latitude"}}},"PinResponse":{"type":"object","required":["cid"],"properties":{"cid":{"type":"string","description":"IPFS Content ID of the pinned content","example":"QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG"}}},"ErrorResponse":{"type":"object","required":["error"],"properties":{"error":{"type":"string","description":"Human-readable error message"},"code":{"type":"string","description":"Machine-readable error code","enum":["INVALID_BODY","VALIDATION_FAILED","SIZE_EXCEEDED","EMPTY_BODY","PIN_FAILED","INVALID_CID","INVALID_ADDRESS","HISTORY_FAILED","STATUS_FAILED"]}}},"CidStatus":{"type":"object","description":"Full CID status including pinning state, GC status, and reference tracking","properties":{"cid":{"type":"string","description":"The queried CID"},"exists":{"type":"boolean","description":"Whether this CID exists in the ephemeral tracking table"},"pinned":{"type":"boolean","description":"Whether this CID is currently pinned on Filebase (false when gc_done)"},"putToUse":{"type":"boolean","description":"Whether this CID has been linked to a profile on-chain"},"putToUseAt":{"type":"string","format":"date-time","nullable":true,"description":"Timestamp when the CID was first linked to a profile"},"refCount":{"type":"integer","description":"Number of on-chain root references pointing to this CID"},"gcStatus":{"type":"string","enum":["active","gc_claimed","gc_done"],"description":"Garbage collection lifecycle: active → gc_claimed → gc_done"},"pinnedAt":{"type":"string","format":"date-time","nullable":true,"description":"When the CID was originally pinned (null if CID not in ephemeral table)"}}},"ValidationLimits":{"type":"object","description":"Content validation limits","properties":{"maxContentSizeBytes":{"type":"integer","description":"Maximum content size in bytes","example":5242880},"maxJsonDepth":{"type":"integer","description":"Maximum JSON nesting depth","example":10}}},"HealthResponse":{"type":"object","properties":{"status":{"type":"string","enum":["ok","degraded","error"],"description":"Overall service health status"},"authoritative":{"type":"boolean","description":"Whether the service has complete data (not degraded)"},"dbConnected":{"type":"boolean","description":"Database connection status"},"subscriptionLag":{"type":"integer","description":"Number of blocks behind chain tip"},"ephemeralsActive":{"type":"integer","description":"Number of active ephemeral CIDs pending GC"},"liveSetSize":{"type":"integer","description":"Number of CIDs with positive reference count"}}},"BackfillStatus":{"type":"object","properties":{"enabled":{"type":"boolean","description":"Whether backfill is enabled"},"indexerConnected":{"type":"boolean","description":"Whether connected to the indexer"},"isComplete":{"type":"boolean","description":"Whether backfill has finished"},"lastBlock":{"type":"string","description":"Last processed block number"},"startedAt":{"type":"string","format":"date-time","nullable":true,"description":"When backfill started"},"completedAt":{"type":"string","format":"date-time","nullable":true,"description":"When backfill completed"}}},"AvatarHistoryResponse":{"type":"object","description":"CID history for an avatar address","properties":{"avatar":{"type":"string","description":"Normalized Ethereum address of the avatar"},"history":{"type":"array","items":{"type":"object","properties":{"cid":{"type":"string","nullable":true,"description":"IPFS CID of the profile at this point"},"blockNumber":{"type":"string","description":"Block number (as string for bigint safety)"},"timestamp":{"type":"string","format":"date-time","description":"ISO 8601 timestamp"}}}}}}}}}