{
  "openapi": "3.0.3",
  "info": {
    "title": "iwasthere Organizer Public API",
    "version": "1.0.0",
    "description": "Organizer-facing public API. Provides event CRUD, photo/video upload, indexing-status polling, share links, and plan/limit lookup.\n\n## Authentication\nEvery request needs an `Authorization: Bearer iwt_<...>` header. Issue keys in the dashboard at `/dashboard/developer`.\nScopes: `events:read` (read) / `events:write` (create/update/delete/upload). When issuing a key in the dashboard you can choose read-only (`events:read`) or read+write for least privilege.\n\n## Typical workflow (build a photo gallery)\n1. **Create an event** — `POST /events` → capture `id` and `shareLink` from the response.\n2. **Get upload URLs** — `POST /events/{eventId}/photos/presign` (body: `files[]`) → receive a presigned `url` per file.\n3. **Upload originals** — HTTP `PUT` each image binary directly to its `url` (S3, no auth header needed). Set the `Content-Type` header to the `contentType` returned for that file, or S3 rejects the PUT with 403.\n4. **Complete** — `POST /events/{eventId}/photos/complete` (body: `uploaded[]`) → triggers thumbnailing, dedup, and face indexing.\n5. **Poll status** — `GET /events/{eventId}/photos` until each photo's `indexingStatus` is `done`.\n6. **Share** — give the `shareLink` from step 1 (e.g. `https://iwasthere.pics/e/abc123`) to participants, who then find their own photos by selfie.\n\nFor video, step 3 becomes a multipart upload (`POST /events/{eventId}/videos` → `PUT` each part → `.../videos/{videoId}/complete`) and is **Pro-only**.\n\n## Plan gating\nOn limit exceed the API returns HTTP 403 with `upgrade: { message, url }` in the body. Relay `message` to the user. Success responses include a `quota` field with remaining limits.\n\n## Not provided\n**Participant face search is NOT available through this API (permanently non-public).** The server does not verify liveness, so exposing it would allow searching with someone else's face photo. There is no `/api/search` equivalent — do not look for one."
  },
  "servers": [
    {
      "url": "https://iwasthere.pics/api/public/v1",
      "description": "Production"
    }
  ],
  "security": [
    {
      "ApiKeyAuth": []
    }
  ],
  "tags": [
    {
      "name": "events",
      "description": "Event management. An event is a container for photos/videos and is shared with participants via its `shareLink`. Most workflows start here."
    },
    {
      "name": "photos",
      "description": "Photo upload/list/delete. Upload in 3 steps (presign → PUT → complete), then confirm indexing via indexingStatus."
    },
    {
      "name": "videos",
      "description": "Video upload/list (Pro only). Large multipart uploads. The free plan returns VIDEO_NOT_ALLOWED."
    },
    {
      "name": "plan",
      "description": "Plan/limit lookup. Check limits before acting to warn users proactively."
    }
  ],
  "paths": {
    "/events": {
      "get": {
        "tags": [
          "events"
        ],
        "summary": "List my events",
        "description": "Returns only events created by the API key owner. Each item includes photo/video count (`photoCount`), indexed count (`indexedCount`), expiry (`expiresAt`), and the full share URL (`shareLink`).",
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "List of events.",
            "content": {
              "application/json": {
                "example": {
                  "events": [
                    {
                      "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
                      "name": "2026 Company Workshop",
                      "dateFrom": "2026-07-21",
                      "dateTo": null,
                      "expiresAt": "2026-07-28T12:00:00.000Z",
                      "status": "active",
                      "shareLink": "https://iwasthere.pics/e/abc123",
                      "photoCount": 42,
                      "indexedCount": 42,
                      "createdAt": "2026-07-21T09:00:00.000Z"
                    }
                  ]
                }
              }
            }
          },
          "401": {
            "description": "Authentication failed. `UNAUTHORIZED` (missing/malformed header) or `INVALID_API_KEY` (hash mismatch/revoked).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicError"
                },
                "example": {
                  "error": "UNAUTHORIZED"
                }
              }
            }
          },
          "403": {
            "description": "Insufficient permission. `INSUFFICIENT_SCOPE` (key lacks the required scope) or `FORBIDDEN` (not the resource owner).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicError"
                },
                "example": {
                  "error": "FORBIDDEN"
                }
              }
            }
          },
          "429": {
            "description": "Rate limit exceeded (`RATE_LIMITED`). Wait for the `Retry-After` (seconds) header, then retry. Limits: free 60/h, pro 600/h.",
            "headers": {
              "Retry-After": {
                "schema": {
                  "type": "integer"
                },
                "description": "Seconds until retry is allowed"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicError"
                },
                "example": {
                  "error": "RATE_LIMITED",
                  "retryAfterSec": 1800
                }
              }
            }
          }
        }
      },
      "post": {
        "tags": [
          "events"
        ],
        "summary": "Create an event",
        "description": "Creates a new event (`events:write`). Omit `name` to get a date-based default name. Give the returned `shareLink` to participants. Exceeding the active-event limit returns 403 + `upgrade`.\n\n**Next step:** call `POST /events/{eventId}/photos/presign` with the returned `id` to start uploading photos.",
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "name": {
                    "type": "string",
                    "maxLength": 100
                  },
                  "dateFrom": {
                    "type": "string"
                  },
                  "description": {
                    "type": "string",
                    "maxLength": 500
                  }
                },
                "additionalProperties": false
              },
              "example": {
                "name": "2026 Company Workshop",
                "dateFrom": "2026-07-21"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Created. Includes `shareLink` (full URL) and remaining `quota`.",
            "content": {
              "application/json": {
                "example": {
                  "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
                  "shareLink": "https://iwasthere.pics/e/abc123",
                  "quota": {
                    "events": {
                      "used": 1,
                      "limit": 3
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Invalid request body.",
            "content": {
              "application/json": {
                "example": {
                  "error": "BAD_REQUEST"
                }
              }
            }
          },
          "401": {
            "description": "Authentication failed. `UNAUTHORIZED` (missing/malformed header) or `INVALID_API_KEY` (hash mismatch/revoked).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicError"
                },
                "example": {
                  "error": "UNAUTHORIZED"
                }
              }
            }
          },
          "403": {
            "description": "Plan limit exceeded (`ACTIVE_EVENT_LIMIT_EXCEEDED`). Relay the `upgrade.message` to the user and point them to `upgrade.url`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicError"
                },
                "example": {
                  "error": "ACTIVE_EVENT_LIMIT_EXCEEDED",
                  "current": 3,
                  "limit": 3,
                  "upgrade": {
                    "message": "The free plan allows up to 3 active events. Upgrade to Pro for up to 50.",
                    "url": "https://iwasthere.pics/dashboard/plan"
                  }
                }
              }
            }
          },
          "429": {
            "description": "Rate limit exceeded (`RATE_LIMITED`). Wait for the `Retry-After` (seconds) header, then retry. Limits: free 60/h, pro 600/h.",
            "headers": {
              "Retry-After": {
                "schema": {
                  "type": "integer"
                },
                "description": "Seconds until retry is allowed"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicError"
                },
                "example": {
                  "error": "RATE_LIMITED",
                  "retryAfterSec": 1800
                }
              }
            }
          }
        }
      }
    },
    "/events/{eventId}": {
      "get": {
        "tags": [
          "events"
        ],
        "summary": "Get a single event",
        "description": "Returns event detail (`events:read`), including `shareLink` (full URL), current `status` (active/expired/archived), and `portraitProtection`.",
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "parameters": [
          {
            "name": "eventId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "example": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
          }
        ],
        "responses": {
          "200": {
            "description": "Event detail.",
            "content": {
              "application/json": {
                "example": {
                  "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
                  "name": "2026 Company Workshop",
                  "dateFrom": "2026-07-21",
                  "dateTo": null,
                  "expiresAt": "2026-07-28T12:00:00.000Z",
                  "status": "active",
                  "portraitProtection": true,
                  "photoCount": 42,
                  "shareLink": "https://iwasthere.pics/e/abc123"
                }
              }
            }
          },
          "401": {
            "description": "Authentication failed. `UNAUTHORIZED` (missing/malformed header) or `INVALID_API_KEY` (hash mismatch/revoked).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicError"
                },
                "example": {
                  "error": "UNAUTHORIZED"
                }
              }
            }
          },
          "403": {
            "description": "Insufficient permission. `INSUFFICIENT_SCOPE` (key lacks the required scope) or `FORBIDDEN` (not the resource owner).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicError"
                },
                "example": {
                  "error": "FORBIDDEN"
                }
              }
            }
          },
          "404": {
            "description": "Target resource not found (`NOT_FOUND`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicError"
                },
                "example": {
                  "error": "NOT_FOUND"
                }
              }
            }
          },
          "429": {
            "description": "Rate limit exceeded (`RATE_LIMITED`). Wait for the `Retry-After` (seconds) header, then retry. Limits: free 60/h, pro 600/h.",
            "headers": {
              "Retry-After": {
                "schema": {
                  "type": "integer"
                },
                "description": "Seconds until retry is allowed"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicError"
                },
                "example": {
                  "error": "RATE_LIMITED",
                  "retryAfterSec": 1800
                }
              }
            }
          }
        }
      },
      "patch": {
        "tags": [
          "events"
        ],
        "summary": "Update an event (name/date/portrait protection)",
        "description": "Partial update (`events:write`). Only provided fields change. `portraitProtection` is Pro-only, so the free plan gets `PRO_REQUIRED` (403).",
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "parameters": [
          {
            "name": "eventId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "example": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "name": {
                    "type": "string",
                    "minLength": 1,
                    "maxLength": 50
                  },
                  "dateFrom": {
                    "nullable": true,
                    "type": "string"
                  },
                  "dateTo": {
                    "nullable": true,
                    "type": "string"
                  },
                  "portraitProtection": {
                    "type": "boolean"
                  }
                },
                "additionalProperties": false
              },
              "example": {
                "name": "2026 Summer Workshop"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Updated.",
            "content": {
              "application/json": {
                "example": {
                  "ok": true
                }
              }
            }
          },
          "400": {
            "description": "Validation failed (`INVALID_NAME`/`INVALID_VALUE`/`NO_FIELDS`).",
            "content": {
              "application/json": {
                "example": {
                  "error": "INVALID_NAME"
                }
              }
            }
          },
          "401": {
            "description": "Authentication failed. `UNAUTHORIZED` (missing/malformed header) or `INVALID_API_KEY` (hash mismatch/revoked).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicError"
                },
                "example": {
                  "error": "UNAUTHORIZED"
                }
              }
            }
          },
          "403": {
            "description": "Insufficient permission. `INSUFFICIENT_SCOPE` (key lacks the required scope) or `FORBIDDEN` (not the resource owner).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicError"
                },
                "example": {
                  "error": "FORBIDDEN"
                }
              }
            }
          },
          "404": {
            "description": "Target resource not found (`NOT_FOUND`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicError"
                },
                "example": {
                  "error": "NOT_FOUND"
                }
              }
            }
          },
          "429": {
            "description": "Rate limit exceeded (`RATE_LIMITED`). Wait for the `Retry-After` (seconds) header, then retry. Limits: free 60/h, pro 600/h.",
            "headers": {
              "Retry-After": {
                "schema": {
                  "type": "integer"
                },
                "description": "Seconds until retry is allowed"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicError"
                },
                "example": {
                  "error": "RATE_LIMITED",
                  "retryAfterSec": 1800
                }
              }
            }
          }
        }
      },
      "delete": {
        "tags": [
          "events"
        ],
        "summary": "Delete or purge an event",
        "description": "Deletes an event (`events:write`). Default is archive (delete files/embeddings, keep metadata/stats). `?purge=true` fully destroys it (removes the events row too). **Destructive action** — requires the exact UUID; no bulk-delete endpoint is provided (1 call = 1 resource).",
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "parameters": [
          {
            "name": "eventId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "example": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
          },
          {
            "name": "purge",
            "in": "query",
            "required": false,
            "schema": {
              "type": "boolean"
            },
            "description": "true = full purge (irreversible). Omit for archive.",
            "example": false
          }
        ],
        "responses": {
          "200": {
            "description": "Deleted. `archived:true` for archive, `purged:true` for purge.",
            "content": {
              "application/json": {
                "example": {
                  "ok": true,
                  "archived": true
                }
              }
            }
          },
          "401": {
            "description": "Authentication failed. `UNAUTHORIZED` (missing/malformed header) or `INVALID_API_KEY` (hash mismatch/revoked).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicError"
                },
                "example": {
                  "error": "UNAUTHORIZED"
                }
              }
            }
          },
          "403": {
            "description": "Insufficient permission. `INSUFFICIENT_SCOPE` (key lacks the required scope) or `FORBIDDEN` (not the resource owner).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicError"
                },
                "example": {
                  "error": "FORBIDDEN"
                }
              }
            }
          },
          "404": {
            "description": "Target resource not found (`NOT_FOUND`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicError"
                },
                "example": {
                  "error": "NOT_FOUND"
                }
              }
            }
          },
          "429": {
            "description": "Rate limit exceeded (`RATE_LIMITED`). Wait for the `Retry-After` (seconds) header, then retry. Limits: free 60/h, pro 600/h.",
            "headers": {
              "Retry-After": {
                "schema": {
                  "type": "integer"
                },
                "description": "Seconds until retry is allowed"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicError"
                },
                "example": {
                  "error": "RATE_LIMITED",
                  "retryAfterSec": 1800
                }
              }
            }
          }
        }
      }
    },
    "/events/{eventId}/photos": {
      "get": {
        "tags": [
          "photos"
        ],
        "summary": "List photos + indexing status",
        "description": "Returns uploaded photos (`events:read`). Poll each item's `indexingStatus` (`pending`/`processing`/`done`/`failed`) to know when face indexing is complete. When every photo is `done`, participant search is ready.",
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "parameters": [
          {
            "name": "eventId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "example": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
          }
        ],
        "responses": {
          "200": {
            "description": "List of photos.",
            "content": {
              "application/json": {
                "example": {
                  "photos": [
                    {
                      "id": "9a1b...",
                      "thumbnailUrl": "https://cdn.iwasthere.pics/thumbnails/....jpg",
                      "indexingStatus": "done",
                      "uploadedAt": "2026-07-21T09:05:00.000Z"
                    }
                  ]
                }
              }
            }
          },
          "401": {
            "description": "Authentication failed. `UNAUTHORIZED` (missing/malformed header) or `INVALID_API_KEY` (hash mismatch/revoked).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicError"
                },
                "example": {
                  "error": "UNAUTHORIZED"
                }
              }
            }
          },
          "403": {
            "description": "Insufficient permission. `INSUFFICIENT_SCOPE` (key lacks the required scope) or `FORBIDDEN` (not the resource owner).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicError"
                },
                "example": {
                  "error": "FORBIDDEN"
                }
              }
            }
          },
          "404": {
            "description": "Target resource not found (`NOT_FOUND`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicError"
                },
                "example": {
                  "error": "NOT_FOUND"
                }
              }
            }
          },
          "429": {
            "description": "Rate limit exceeded (`RATE_LIMITED`). Wait for the `Retry-After` (seconds) header, then retry. Limits: free 60/h, pro 600/h.",
            "headers": {
              "Retry-After": {
                "schema": {
                  "type": "integer"
                },
                "description": "Seconds until retry is allowed"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicError"
                },
                "example": {
                  "error": "RATE_LIMITED",
                  "retryAfterSec": 1800
                }
              }
            }
          }
        }
      }
    },
    "/events/{eventId}/photos/presign": {
      "post": {
        "tags": [
          "photos"
        ],
        "summary": "Get photo upload URLs (step 1/2)",
        "description": "Send the file list (`files[]`: name/size/type) to get a presigned `url` per file (`events:write`).\n\n**Next step:** HTTP `PUT` each image binary directly to its `url`, setting the `Content-Type` header to the **exact `contentType` value returned for that file**. The presigned URL signs the content type, so any mismatch (or a missing/auto-detected header) makes S3 reject the PUT with **403 SignatureDoesNotMatch** — do not guess it from the file extension. Then collect `photoId`/`s3Key` and call `POST .../photos/complete`. Supported: jpeg/png/webp/heic. Exceeding per-event photo count or file-size limits returns 403 + `upgrade`.",
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "parameters": [
          {
            "name": "eventId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "example": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "files": {
                    "minItems": 1,
                    "maxItems": 100,
                    "type": "array",
                    "items": {
                      "type": "object",
                      "properties": {
                        "name": {
                          "type": "string"
                        },
                        "size": {
                          "type": "integer",
                          "minimum": 0,
                          "exclusiveMinimum": true,
                          "maximum": 9007199254740991
                        },
                        "type": {
                          "type": "string"
                        }
                      },
                      "required": [
                        "name",
                        "size",
                        "type"
                      ],
                      "additionalProperties": false
                    }
                  }
                },
                "required": [
                  "files"
                ],
                "additionalProperties": false
              },
              "example": {
                "files": [
                  {
                    "name": "IMG_0001.jpg",
                    "size": 3145728,
                    "type": "image/jpeg"
                  }
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Presigned URL list. PUT each image to its url.",
            "content": {
              "application/json": {
                "example": {
                  "uploads": [
                    {
                      "photoId": "9a1b...",
                      "s3Key": "originals/3fa8.../9a1b....jpg",
                      "url": "https://s3...amazonaws.com/...&X-Amz-Signature=...",
                      "thumbnailKey": "thumbnails/3fa8.../9a1b....jpg",
                      "thumbnailUrl": "https://s3...amazonaws.com/...",
                      "name": "IMG_0001.jpg",
                      "contentType": "image/jpeg"
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "description": "Unsupported format or otherwise invalid request.",
            "content": {
              "application/json": {
                "example": {
                  "error": "UNSUPPORTED_FORMAT",
                  "filename": "doc.pdf"
                }
              }
            }
          },
          "401": {
            "description": "Authentication failed. `UNAUTHORIZED` (missing/malformed header) or `INVALID_API_KEY` (hash mismatch/revoked).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicError"
                },
                "example": {
                  "error": "UNAUTHORIZED"
                }
              }
            }
          },
          "403": {
            "description": "Plan limit exceeded (`PHOTO_LIMIT_EXCEEDED`). Relay the `upgrade.message` to the user and point them to `upgrade.url`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicError"
                },
                "example": {
                  "error": "PHOTO_LIMIT_EXCEEDED",
                  "current": 50,
                  "limit": 50,
                  "upgrade": {
                    "message": "The free plan allows up to 50 photos per event. Pro allows up to 500.",
                    "url": "https://iwasthere.pics/dashboard/plan"
                  }
                }
              }
            }
          },
          "404": {
            "description": "Target resource not found (`NOT_FOUND`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicError"
                },
                "example": {
                  "error": "NOT_FOUND"
                }
              }
            }
          },
          "429": {
            "description": "Rate limit exceeded (`RATE_LIMITED`). Wait for the `Retry-After` (seconds) header, then retry. Limits: free 60/h, pro 600/h.",
            "headers": {
              "Retry-After": {
                "schema": {
                  "type": "integer"
                },
                "description": "Seconds until retry is allowed"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicError"
                },
                "example": {
                  "error": "RATE_LIMITED",
                  "retryAfterSec": 1800
                }
              }
            }
          }
        }
      }
    },
    "/events/{eventId}/photos/complete": {
      "post": {
        "tags": [
          "photos"
        ],
        "summary": "Complete photo upload (step 2/2)",
        "description": "Register and post-process photos after their S3 PUT (`events:write`). Triggers thumbnailing, pHash dedup, EXIF extraction, and face indexing. Duplicates are auto-skipped (`skipped`).\n\n**Next step:** poll `GET .../photos` for `indexingStatus`.",
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "parameters": [
          {
            "name": "eventId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "example": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "uploaded": {
                    "minItems": 1,
                    "maxItems": 100,
                    "type": "array",
                    "items": {
                      "type": "object",
                      "properties": {
                        "photoId": {
                          "type": "string",
                          "format": "uuid",
                          "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
                        },
                        "s3Key": {
                          "type": "string"
                        },
                        "fileSize": {
                          "type": "number"
                        },
                        "processed": {
                          "type": "boolean"
                        },
                        "thumbnailKey": {
                          "type": "string"
                        },
                        "width": {
                          "type": "number"
                        },
                        "height": {
                          "type": "number"
                        },
                        "takenAt": {
                          "nullable": true,
                          "type": "string"
                        },
                        "gpsLat": {
                          "nullable": true,
                          "type": "string"
                        },
                        "gpsLng": {
                          "nullable": true,
                          "type": "string"
                        }
                      },
                      "required": [
                        "photoId",
                        "s3Key"
                      ],
                      "additionalProperties": false
                    }
                  }
                },
                "required": [
                  "uploaded"
                ],
                "additionalProperties": false
              },
              "example": {
                "uploaded": [
                  {
                    "photoId": "9a1b...",
                    "s3Key": "originals/3fa8.../9a1b....jpg",
                    "fileSize": 3145728
                  }
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Result. `indexed` = newly registered, `skipped` = duplicates skipped, plus `quota`.",
            "content": {
              "application/json": {
                "example": {
                  "indexed": 1,
                  "skipped": 0,
                  "quota": {
                    "events": {
                      "used": 1,
                      "limit": 3
                    },
                    "photos": {
                      "used": 43,
                      "limit": 500
                    }
                  }
                }
              }
            }
          },
          "401": {
            "description": "Authentication failed. `UNAUTHORIZED` (missing/malformed header) or `INVALID_API_KEY` (hash mismatch/revoked).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicError"
                },
                "example": {
                  "error": "UNAUTHORIZED"
                }
              }
            }
          },
          "403": {
            "description": "Insufficient permission. `INSUFFICIENT_SCOPE` (key lacks the required scope) or `FORBIDDEN` (not the resource owner).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicError"
                },
                "example": {
                  "error": "FORBIDDEN"
                }
              }
            }
          },
          "404": {
            "description": "Target resource not found (`NOT_FOUND`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicError"
                },
                "example": {
                  "error": "NOT_FOUND"
                }
              }
            }
          },
          "429": {
            "description": "Rate limit exceeded (`RATE_LIMITED`). Wait for the `Retry-After` (seconds) header, then retry. Limits: free 60/h, pro 600/h.",
            "headers": {
              "Retry-After": {
                "schema": {
                  "type": "integer"
                },
                "description": "Seconds until retry is allowed"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicError"
                },
                "example": {
                  "error": "RATE_LIMITED",
                  "retryAfterSec": 1800
                }
              }
            }
          }
        }
      }
    },
    "/photos/{photoId}": {
      "delete": {
        "tags": [
          "photos"
        ],
        "summary": "Delete a single photo",
        "description": "Deletes one photo (`events:write`). **Destructive action** — requires the exact `photoId` (UUID); no bulk delete.",
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "parameters": [
          {
            "name": "photoId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "example": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
          }
        ],
        "responses": {
          "200": {
            "description": "Deleted.",
            "content": {
              "application/json": {
                "example": {
                  "ok": true
                }
              }
            }
          },
          "401": {
            "description": "Authentication failed. `UNAUTHORIZED` (missing/malformed header) or `INVALID_API_KEY` (hash mismatch/revoked).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicError"
                },
                "example": {
                  "error": "UNAUTHORIZED"
                }
              }
            }
          },
          "403": {
            "description": "Insufficient permission. `INSUFFICIENT_SCOPE` (key lacks the required scope) or `FORBIDDEN` (not the resource owner).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicError"
                },
                "example": {
                  "error": "FORBIDDEN"
                }
              }
            }
          },
          "404": {
            "description": "Target resource not found (`NOT_FOUND`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicError"
                },
                "example": {
                  "error": "NOT_FOUND"
                }
              }
            }
          },
          "429": {
            "description": "Rate limit exceeded (`RATE_LIMITED`). Wait for the `Retry-After` (seconds) header, then retry. Limits: free 60/h, pro 600/h.",
            "headers": {
              "Retry-After": {
                "schema": {
                  "type": "integer"
                },
                "description": "Seconds until retry is allowed"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicError"
                },
                "example": {
                  "error": "RATE_LIMITED",
                  "retryAfterSec": 1800
                }
              }
            }
          }
        }
      }
    },
    "/events/{eventId}/videos": {
      "get": {
        "tags": [
          "videos"
        ],
        "summary": "List videos + processing status",
        "description": "Returns videos (`events:read`). Use `processingStatus` (keyframe extraction) and `indexingStatus` (face indexing) to track completion.",
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "parameters": [
          {
            "name": "eventId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "example": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
          }
        ],
        "responses": {
          "200": {
            "description": "List of videos.",
            "content": {
              "application/json": {
                "example": {
                  "videos": [
                    {
                      "id": "7c2d...",
                      "s3Key": "videos/3fa8.../7c2d....mp4",
                      "thumbnailUrl": "https://iwasthere.pics/...jpg",
                      "processingStatus": "done",
                      "fileSize": 104857600,
                      "uploadedAt": "2026-07-21T09:10:00.000Z",
                      "indexingStatus": "done"
                    }
                  ]
                }
              }
            }
          },
          "401": {
            "description": "Authentication failed. `UNAUTHORIZED` (missing/malformed header) or `INVALID_API_KEY` (hash mismatch/revoked).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicError"
                },
                "example": {
                  "error": "UNAUTHORIZED"
                }
              }
            }
          },
          "403": {
            "description": "Insufficient permission. `INSUFFICIENT_SCOPE` (key lacks the required scope) or `FORBIDDEN` (not the resource owner).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicError"
                },
                "example": {
                  "error": "FORBIDDEN"
                }
              }
            }
          },
          "404": {
            "description": "Target resource not found (`NOT_FOUND`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicError"
                },
                "example": {
                  "error": "NOT_FOUND"
                }
              }
            }
          },
          "429": {
            "description": "Rate limit exceeded (`RATE_LIMITED`). Wait for the `Retry-After` (seconds) header, then retry. Limits: free 60/h, pro 600/h.",
            "headers": {
              "Retry-After": {
                "schema": {
                  "type": "integer"
                },
                "description": "Seconds until retry is allowed"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicError"
                },
                "example": {
                  "error": "RATE_LIMITED",
                  "retryAfterSec": 1800
                }
              }
            }
          }
        }
      },
      "post": {
        "tags": [
          "videos"
        ],
        "summary": "Create video multipart upload session (Pro only, step 1/3)",
        "description": "Starts a large video upload session (`events:write`, **Pro only**). Send `file` (name/size/type) to get an `uploadId` and per-part presigned `partUrls`. Supported: mp4/mov/avi, up to 500MB.\n\n**Next step:** `PUT` each part (split into `partSize` bytes) to its `partUrls[].url`, collect the `ETag`s, then call `POST .../videos/{videoId}/complete`. The free plan returns `VIDEO_NOT_ALLOWED` (403).",
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "parameters": [
          {
            "name": "eventId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "example": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "file": {
                    "type": "object",
                    "properties": {
                      "name": {
                        "type": "string"
                      },
                      "size": {
                        "type": "integer",
                        "minimum": 0,
                        "exclusiveMinimum": true,
                        "maximum": 9007199254740991
                      },
                      "type": {
                        "type": "string"
                      }
                    },
                    "required": [
                      "name",
                      "size",
                      "type"
                    ],
                    "additionalProperties": false
                  }
                },
                "required": [
                  "file"
                ],
                "additionalProperties": false
              },
              "example": {
                "file": {
                  "name": "clip.mp4",
                  "size": 104857600,
                  "type": "video/mp4"
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Multipart session. PUT each part to partUrls.",
            "content": {
              "application/json": {
                "example": {
                  "videoId": "7c2d...",
                  "s3Key": "videos/3fa8.../7c2d....mp4",
                  "uploadId": "abc123uploadid",
                  "partSize": 10485760,
                  "partUrls": [
                    {
                      "partNumber": 1,
                      "url": "https://s3...&partNumber=1"
                    }
                  ]
                }
              }
            }
          },
          "401": {
            "description": "Authentication failed. `UNAUTHORIZED` (missing/malformed header) or `INVALID_API_KEY` (hash mismatch/revoked).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicError"
                },
                "example": {
                  "error": "UNAUTHORIZED"
                }
              }
            }
          },
          "403": {
            "description": "Plan limit exceeded (`VIDEO_NOT_ALLOWED`). Relay the `upgrade.message` to the user and point them to `upgrade.url`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicError"
                },
                "example": {
                  "error": "VIDEO_NOT_ALLOWED",
                  "upgrade": {
                    "message": "Video upload is a Pro-only feature.",
                    "url": "https://iwasthere.pics/dashboard/plan"
                  }
                }
              }
            }
          },
          "404": {
            "description": "Target resource not found (`NOT_FOUND`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicError"
                },
                "example": {
                  "error": "NOT_FOUND"
                }
              }
            }
          },
          "429": {
            "description": "Rate limit exceeded (`RATE_LIMITED`). Wait for the `Retry-After` (seconds) header, then retry. Limits: free 60/h, pro 600/h.",
            "headers": {
              "Retry-After": {
                "schema": {
                  "type": "integer"
                },
                "description": "Seconds until retry is allowed"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicError"
                },
                "example": {
                  "error": "RATE_LIMITED",
                  "retryAfterSec": 1800
                }
              }
            }
          }
        }
      }
    },
    "/events/{eventId}/videos/{videoId}/complete": {
      "post": {
        "tags": [
          "videos"
        ],
        "summary": "Complete video multipart upload (step 3/3)",
        "description": "Call after all parts are uploaded (`events:write`). Send `parts[]` (partNumber + etag) in order to finalize the S3 multipart upload and trigger keyframe extraction → face indexing.",
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "parameters": [
          {
            "name": "eventId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "example": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
          },
          {
            "name": "videoId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "example": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "s3Key": {
                    "type": "string"
                  },
                  "uploadId": {
                    "type": "string"
                  },
                  "fileSize": {
                    "type": "integer",
                    "minimum": 0,
                    "exclusiveMinimum": true,
                    "maximum": 9007199254740991
                  },
                  "parts": {
                    "minItems": 1,
                    "type": "array",
                    "items": {
                      "type": "object",
                      "properties": {
                        "partNumber": {
                          "type": "integer",
                          "minimum": 0,
                          "exclusiveMinimum": true,
                          "maximum": 9007199254740991
                        },
                        "etag": {
                          "type": "string"
                        }
                      },
                      "required": [
                        "partNumber",
                        "etag"
                      ],
                      "additionalProperties": false
                    }
                  }
                },
                "required": [
                  "s3Key",
                  "uploadId",
                  "fileSize",
                  "parts"
                ],
                "additionalProperties": false
              },
              "example": {
                "s3Key": "videos/3fa8.../7c2d....mp4",
                "uploadId": "abc123uploadid",
                "fileSize": 104857600,
                "parts": [
                  {
                    "partNumber": 1,
                    "etag": "\"e1a2...\""
                  }
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Completed.",
            "content": {
              "application/json": {
                "example": {
                  "ok": true,
                  "videoId": "7c2d..."
                }
              }
            }
          },
          "400": {
            "description": "Invalid s3Key etc.",
            "content": {
              "application/json": {
                "example": {
                  "error": "INVALID_S3KEY"
                }
              }
            }
          },
          "401": {
            "description": "Authentication failed. `UNAUTHORIZED` (missing/malformed header) or `INVALID_API_KEY` (hash mismatch/revoked).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicError"
                },
                "example": {
                  "error": "UNAUTHORIZED"
                }
              }
            }
          },
          "403": {
            "description": "Insufficient permission. `INSUFFICIENT_SCOPE` (key lacks the required scope) or `FORBIDDEN` (not the resource owner).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicError"
                },
                "example": {
                  "error": "FORBIDDEN"
                }
              }
            }
          },
          "404": {
            "description": "Target resource not found (`NOT_FOUND`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicError"
                },
                "example": {
                  "error": "NOT_FOUND"
                }
              }
            }
          },
          "429": {
            "description": "Rate limit exceeded (`RATE_LIMITED`). Wait for the `Retry-After` (seconds) header, then retry. Limits: free 60/h, pro 600/h.",
            "headers": {
              "Retry-After": {
                "schema": {
                  "type": "integer"
                },
                "description": "Seconds until retry is allowed"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicError"
                },
                "example": {
                  "error": "RATE_LIMITED",
                  "retryAfterSec": 1800
                }
              }
            }
          }
        }
      }
    },
    "/plan": {
      "get": {
        "tags": [
          "plan"
        ],
        "summary": "Get plan/limits",
        "description": "Returns the current plan (`free`/`pro`), active event count, and per-plan limits (`planLimits`) (`events:read`). Call before creating/uploading to check limits and warn users proactively.",
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "Plan/limits.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlanResponse"
                },
                "example": {
                  "plan": "free",
                  "activeEventCount": 1,
                  "planLimits": {
                    "maxPhotosPerEvent": 50,
                    "maxVideosPerEvent": 0,
                    "maxActiveEvents": 3,
                    "maxFileSizeBytes": 8388608,
                    "maxVideoFileSizeBytes": 0,
                    "retentionHours": 48
                  }
                }
              }
            }
          },
          "401": {
            "description": "Authentication failed. `UNAUTHORIZED` (missing/malformed header) or `INVALID_API_KEY` (hash mismatch/revoked).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicError"
                },
                "example": {
                  "error": "UNAUTHORIZED"
                }
              }
            }
          },
          "403": {
            "description": "Insufficient permission. `INSUFFICIENT_SCOPE` (key lacks the required scope) or `FORBIDDEN` (not the resource owner).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicError"
                },
                "example": {
                  "error": "FORBIDDEN"
                }
              }
            }
          },
          "429": {
            "description": "Rate limit exceeded (`RATE_LIMITED`). Wait for the `Retry-After` (seconds) header, then retry. Limits: free 60/h, pro 600/h.",
            "headers": {
              "Retry-After": {
                "schema": {
                  "type": "integer"
                },
                "description": "Seconds until retry is allowed"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicError"
                },
                "example": {
                  "error": "RATE_LIMITED",
                  "retryAfterSec": 1800
                }
              }
            }
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "ApiKeyAuth": {
        "type": "http",
        "scheme": "bearer",
        "bearerFormat": "iwt_<32bytes base64url>",
        "description": "API key issued in the dashboard at /dashboard/developer."
      }
    },
    "schemas": {
      "PublicEvent": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string"
          },
          "name": {
            "type": "string"
          },
          "dateFrom": {
            "nullable": true,
            "type": "string"
          },
          "dateTo": {
            "nullable": true,
            "type": "string"
          },
          "expiresAt": {
            "type": "string"
          },
          "status": {
            "type": "string",
            "enum": [
              "active",
              "expired",
              "archived"
            ]
          },
          "shareLink": {
            "type": "string"
          },
          "photoCount": {
            "type": "number"
          }
        },
        "required": [
          "id",
          "name",
          "dateFrom",
          "dateTo",
          "expiresAt",
          "status",
          "shareLink",
          "photoCount"
        ],
        "additionalProperties": false
      },
      "PublicError": {
        "type": "object",
        "properties": {
          "error": {
            "type": "string"
          },
          "upgrade": {
            "type": "object",
            "properties": {
              "message": {
                "type": "string"
              },
              "url": {
                "type": "string"
              }
            },
            "required": [
              "message",
              "url"
            ],
            "additionalProperties": false
          }
        },
        "required": [
          "error"
        ],
        "additionalProperties": false
      },
      "PlanResponse": {
        "type": "object",
        "properties": {
          "plan": {
            "type": "string",
            "enum": [
              "free",
              "pro"
            ]
          },
          "activeEventCount": {
            "type": "number"
          },
          "planLimits": {
            "type": "object",
            "properties": {
              "maxPhotosPerEvent": {
                "type": "number"
              },
              "maxVideosPerEvent": {
                "type": "number"
              },
              "maxActiveEvents": {
                "type": "number"
              },
              "maxFileSizeBytes": {
                "type": "number"
              },
              "maxVideoFileSizeBytes": {
                "type": "number"
              },
              "retentionHours": {
                "type": "number"
              }
            },
            "required": [
              "maxPhotosPerEvent",
              "maxVideosPerEvent",
              "maxActiveEvents",
              "maxFileSizeBytes",
              "maxVideoFileSizeBytes",
              "retentionHours"
            ],
            "additionalProperties": false
          }
        },
        "required": [
          "plan",
          "activeEventCount",
          "planLimits"
        ],
        "additionalProperties": false
      },
      "UpgradeSignal": {
        "type": "object",
        "properties": {
          "message": {
            "type": "string"
          },
          "url": {
            "type": "string"
          }
        },
        "required": [
          "message",
          "url"
        ],
        "additionalProperties": false
      },
      "Quota": {
        "type": "object",
        "description": "Per-resource usage/limit. Attached to success responses.",
        "additionalProperties": {
          "type": "object",
          "properties": {
            "used": {
              "type": "integer"
            },
            "limit": {
              "type": "integer"
            }
          }
        },
        "example": {
          "events": {
            "used": 1,
            "limit": 3
          },
          "photos": {
            "used": 43,
            "limit": 500
          }
        }
      }
    }
  },
  "x-error-codes": {
    "UNAUTHORIZED": "Missing/malformed auth header (401)",
    "INVALID_API_KEY": "API key hash mismatch or revoked (401)",
    "INSUFFICIENT_SCOPE": "Key lacks the required scope (403)",
    "FORBIDDEN": "Not the resource owner (403)",
    "RATE_LIMITED": "Rate limit exceeded (429) — see Retry-After",
    "NOT_FOUND": "Target resource not found (404)",
    "BAD_REQUEST": "Request body validation failed (400)",
    "ACTIVE_EVENT_LIMIT_EXCEEDED": "Active event limit exceeded (403 + upgrade)",
    "PHOTO_LIMIT_EXCEEDED": "Per-event photo limit exceeded (403 + upgrade)",
    "FILE_SIZE_EXCEEDED": "File size limit exceeded (403 + upgrade)",
    "VIDEO_NOT_ALLOWED": "Video is Pro-only (403 + upgrade)",
    "VIDEO_SIZE_EXCEEDED": "Video size limit exceeded (403 + upgrade)",
    "VIDEO_LIMIT_EXCEEDED": "Per-event video limit exceeded (403 + upgrade)",
    "PRO_REQUIRED": "Pro-only feature (403 + upgrade)"
  },
  "x-not-supported": {
    "faceSearch": "Participant face search is NOT available through this API (permanently non-public). It prevents abuse of searching with someone else's face photo while liveness is unverified. There is no /api/search equivalent."
  }
}
